diff options
author | Christian Blättler <blatc2@bfh.ch> | 2024-06-13 11:35:52 +0200 |
---|---|---|
committer | Christian Blättler <blatc2@bfh.ch> | 2024-06-13 11:35:52 +0200 |
commit | eb964dfae0a12f9a90eb066d610f627538f8997c (patch) | |
tree | 26a6cd74c9a29edce05b2dcd51cf497374bf8e30 /packages | |
parent | 9d0fc80a905e02a0a0b63dd547daac6e7b17fb52 (diff) | |
parent | f9d4ff5b43e48a07ac81d7e7ef800ddb12f5f90a (diff) | |
download | wallet-core-eb964dfae0a12f9a90eb066d610f627538f8997c.tar.xz |
Merge branch 'master' into feature/tokens
Diffstat (limited to 'packages')
129 files changed, 3242 insertions, 2031 deletions
diff --git a/packages/aml-backoffice-ui/package.json b/packages/aml-backoffice-ui/package.json index 749565946..9c33862f7 100644 --- a/packages/aml-backoffice-ui/package.json +++ b/packages/aml-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/aml-backoffice-ui", - "version": "0.10.7", + "version": "0.11.4", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "Back-office SPA for GNU Taler Exchange.", diff --git a/packages/anastasis-cli/package.json b/packages/anastasis-cli/package.json index 5a9d6abea..40bdb927e 100644 --- a/packages/anastasis-cli/package.json +++ b/packages/anastasis-cli/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/anastasis-cli", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json index 576acc988..c987f0ceb 100644 --- a/packages/anastasis-core/package.json +++ b/packages/anastasis-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/anastasis-core", - "version": "0.10.7", + "version": "0.11.4", "description": "", "main": "./lib/index.js", "module": "./lib/index.js", diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index 05fa4a49f..a48db5c25 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -43,7 +43,7 @@ import { URL, j2s, } from "@gnu-taler/taler-util"; -import { HttpResponse } from "@gnu-taler/taler-util/http"; +import { HttpResponse, createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { anastasisData } from "./anastasis-data.js"; import { codecForChallengeInstructionMessage, @@ -139,6 +139,11 @@ export * from "./challenge-feedback-types.js"; const logger = new Logger("anastasis-core:index.ts"); +const http = createPlatformHttpLib({ + enableThrottling: true, + requireTls: false, +}); + const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data"; function getContinents(): ContinentInfo[] { @@ -279,9 +284,9 @@ async function getProviderInfo( providerBaseUrl: string, ): Promise<AuthenticationProviderStatus> { // FIXME: Use a reasonable timeout here. - let resp: Response; + let resp: HttpResponse; try { - resp = await fetch(new URL("config", providerBaseUrl).href); + resp = await http.fetch(new URL("config", providerBaseUrl).href); } catch (e) { console.warn( "Encountered an HTTP error whilst trying to get the provider's config: ", @@ -293,7 +298,7 @@ async function getProviderInfo( hint: "request to anastasis provider failed", }; } - if (!resp.ok) { + if (resp.status < 200 || resp.status > 299) { console.warn("Got bad response code whilst getting provider config", resp); return { status: "error", @@ -556,7 +561,7 @@ async function uploadSecret( // FIXME: Get this from the params reqUrl.searchParams.set("timeout_ms", "500"); } - const resp = await fetch(reqUrl.href, { + const resp = await http.fetch(reqUrl.href, { method: "POST", headers: { "content-type": "application/json", @@ -646,7 +651,7 @@ async function uploadSecret( reqUrl.searchParams.set("timeout_ms", "500"); } logger.info(`uploading policy to ${prov.provider_url}`); - const resp = await fetch(reqUrl.href, { + const resp = await http.fetch(reqUrl.href, { method: "POST", headers: { "Anastasis-Policy-Signature": encodeCrock(sig), @@ -757,14 +762,14 @@ async function downloadPolicyFromProvider( const acctKeypair = accountKeypairDerive(userId); const reqUrl = new URL(`policy/${acctKeypair.pub}`, providerUrl); reqUrl.searchParams.set("version", `${version}`); - const resp = await fetch(reqUrl.href); + const resp = await http.fetch(reqUrl.href); if (resp.status !== 200) { logger.info( `Could not download policy from provider ${providerUrl}, status ${resp.status}`, ); return undefined; } - const body = await resp.arrayBuffer(); + const body = await resp.bytes(); const bodyDecrypted = await decryptRecoveryDocument( userId, encodeCrock(body), @@ -981,10 +986,10 @@ async function requestTruth( const hresp = await getResponseHash(truth, solveRequest); - let resp: Response; + let resp: HttpResponse; try { - resp = await fetch(url.href, { + resp = await http.fetch(url.href, { method: "POST", headers: { Accept: "application/json", @@ -1022,7 +1027,7 @@ async function requestTruth( truth.provider_salt, ); - const respBody = new Uint8Array(await resp.arrayBuffer()); + const respBody = new Uint8Array(await resp.bytes()); const keyShare = await decryptKeyShare( encodeCrock(respBody), userId, @@ -1138,10 +1143,10 @@ async function selectChallenge( } } - let resp: Response; + let resp: HttpResponse; try { - resp = await fetch(url.href, { + resp = await http.fetch(url.href, { method: "POST", headers: { Accept: "application/json", @@ -1859,7 +1864,7 @@ export async function discoverPolicies( ); const acctKeypair = accountKeypairDerive(userId); const reqUrl = new URL(`policy/${acctKeypair.pub}/meta`, providerUrl); - const resp = await fetch(reqUrl.href); + const resp = await http.fetch(reqUrl.href); if (resp.status !== 200) { logger.warn(`Could not fetch policy metadate from ${reqUrl.href}`); continue; diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index 108b1476e..9f56489d1 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/anastasis-webui", - "version": "0.10.7", + "version": "0.11.4", "license": "MIT", "type": "module", "scripts": { diff --git a/packages/auditor-backoffice-ui/package.json b/packages/auditor-backoffice-ui/package.json index 776c179b4..ce420417c 100644 --- a/packages/auditor-backoffice-ui/package.json +++ b/packages/auditor-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/auditor-backoffice-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/packages/bank-ui/package.json b/packages/bank-ui/package.json index f06905a93..db89e58be 100644 --- a/packages/bank-ui/package.json +++ b/packages/bank-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/bank-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-OR-LATER", "type": "module", "scripts": { diff --git a/packages/bank-ui/src/hooks/preferences.ts b/packages/bank-ui/src/hooks/preferences.ts index bb3dcb153..4cb5e6a95 100644 --- a/packages/bank-ui/src/hooks/preferences.ts +++ b/packages/bank-ui/src/hooks/preferences.ts @@ -31,8 +31,6 @@ interface Preferences { showWithdrawalSuccess: boolean; showDemoDescription: boolean; showInstallWallet: boolean; - maxWithdrawalAmount: number; - fastWithdrawal: boolean; showDebugInfo: boolean; } @@ -41,17 +39,13 @@ export const codecForPreferences = (): Codec<Preferences> => .property("showWithdrawalSuccess", codecForBoolean()) .property("showDemoDescription", codecForBoolean()) .property("showInstallWallet", codecForBoolean()) - .property("fastWithdrawal", codecForBoolean()) .property("showDebugInfo", codecForBoolean()) - .property("maxWithdrawalAmount", codecForNumber()) .build("Settings"); const defaultPreferences: Preferences = { showWithdrawalSuccess: true, showDemoDescription: true, showInstallWallet: true, - maxWithdrawalAmount: 25, - fastWithdrawal: false, showDebugInfo: false, }; @@ -82,7 +76,6 @@ export function usePreferences(): [ export function getAllBooleanPreferences(): Array<keyof Preferences> { return [ - "fastWithdrawal", "showDebugInfo", "showDemoDescription", "showInstallWallet", @@ -95,16 +88,12 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { - case "maxWithdrawalAmount": - return i18n.str`Max withdrawal amount`; case "showWithdrawalSuccess": return i18n.str`Show withdrawal confirmation`; case "showDemoDescription": return i18n.str`Show demo description`; case "showInstallWallet": return i18n.str`Show install wallet first`; - case "fastWithdrawal": - return i18n.str`Use fast withdrawal form`; case "showDebugInfo": return i18n.str`Show debug info`; } diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts index 19c097d18..5544c4e23 100644 --- a/packages/bank-ui/src/pages/OperationState/state.ts +++ b/packages/bank-ui/src/pages/OperationState/state.ts @@ -18,6 +18,7 @@ import { Amounts, HttpStatusCode, TalerCoreBankErrorsByMethod, + TalerCorebankApi, TalerError, assertUnreachable, parsePaytoUri, @@ -33,6 +34,7 @@ import { useSessionState } from "../../hooks/session.js"; import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { Props, State } from "./index.js"; +import { useSettingsContext } from "../../context/settings.js"; export function useComponentState({ currency, @@ -41,7 +43,8 @@ export function useComponentState({ routeHere, onAuthorizationRequired, }: Props): utils.RecursiveState<State> { - const [settings] = usePreferences(); + const [preference] = usePreferences(); + const settings = useSettingsContext(); const [bankState, updateBankState] = useBankState(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; @@ -52,15 +55,22 @@ export function useComponentState({ const [failure, setFailure] = useState< TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined >(); - const amount = settings.maxWithdrawalAmount; + const amount = settings.defaultSuggestedAmount; async function doSilentStart() { // FIXME: if amount is not enough use balance const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`); if (!creds) return; - const resp = await bank.createWithdrawal(creds, { - amount: Amounts.stringify(parsedAmount), - }); + const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = + settings.fastWithdrawalForm + ? { + suggested_amount: Amounts.stringify(parsedAmount), + } + : { + amount: Amounts.stringify(parsedAmount), + }; + + const resp = await bank.createWithdrawal(creds, params); if (resp.type === "fail") { setFailure(resp); return; @@ -73,7 +83,7 @@ export function useComponentState({ if (withdrawalOperationId === undefined) { doSilentStart(); } - }, [settings.fastWithdrawal, amount]); + }, [settings.fastWithdrawalForm, amount]); if (failure) { return { @@ -174,7 +184,7 @@ export function useComponentState({ } if (data.status === "confirmed") { - if (!settings.showWithdrawalSuccess) { + if (!preference.showWithdrawalSuccess) { updateBankState("currentWithdrawalOperationId", undefined); // onClose() } diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx index 3bf891504..0fb8c0ac1 100644 --- a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -79,6 +79,7 @@ export function PaytoWireTransferForm({ routeHere, onAuthorizationRequired, limit, + balance, }: Props): VNode { const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form"); const isRawPayto = inputType !== "form"; @@ -111,6 +112,16 @@ export function PaytoWireTransferForm({ ? ("x-taler-bank" as const) : ("iban" as const); + const wireFee = + config.wire_transfer_fees === undefined + ? Amounts.zeroOfCurrency(config.currency) + : Amounts.parseOrThrow(config.wire_transfer_fees); + + const limitWithFee = + Amounts.cmp(limit, wireFee) === 1 + ? Amounts.sub(limit, wireFee).amount + : Amounts.zeroOfAmount(limit); + const errorsWire = undefinedIfEmpty({ account: !account ? i18n.str`Required` @@ -124,7 +135,7 @@ export function PaytoWireTransferForm({ ? i18n.str`Required` : !parsedAmount ? i18n.str`Not valid` - : validateAmount(parsedAmount, limit, i18n), + : validateAmount(parsedAmount, limitWithFee, i18n), }); const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); @@ -134,7 +145,7 @@ export function PaytoWireTransferForm({ ? i18n.str`Required` : !parsed ? i18n.str`Does not follow the pattern` - : validateRawPayto(parsed, limit, url.host, i18n, paytoType), + : validateRawPayto(parsed, limitWithFee, url.host, i18n, paytoType), }); async function doSend() { @@ -479,9 +490,9 @@ export function PaytoWireTransferForm({ e.preventDefault(); }} > - <div class="p-4 sm:p-8"> + <div class="m-4"> {!isRawPayto ? ( - <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 "> {(() => { switch (paytoType) { case "x-taler-bank": { @@ -622,7 +633,45 @@ export function PaytoWireTransferForm({ </div> </div> )} + {Amounts.cmp(limitWithFee, balance) > 0 ? ( + <p class="mt-2 text-sm text-gray-900"> + <i18n.Translate> + You can transfer{" "} + <RenderAmount + value={limitWithFee} + spec={config.currency_specification} + /> + </i18n.Translate> + </p> + ) : undefined} </div> + {Amounts.isZero(wireFee) ? undefined : ( + <div class="px-4 my-4"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-6"> + <dl class="mt-4 space-y-4"> + <Fragment> + <div class="flex items-center justify-between "> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Cost</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={wireFee} + negative + withColor + spec={config.currency_specification} + /> + </dd> + </div> + </Fragment> + </dl> + </div> + </div> + </div> + )} <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> {routeCancel ? ( <a diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx index 359d4c18f..2a21295c7 100644 --- a/packages/bank-ui/src/pages/QrCodeSection.tsx +++ b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -86,10 +86,10 @@ export function QrCodeSection({ <div class="mt-4 mb-4 text-sm text-gray-500"> <p> <i18n.Translate> - You will see the details of the operation in your wallet - including the fees (if applies). If you still don't have one you - can install it following instructions in - </i18n.Translate>{" "} + Your wallet will display the details of the transaction + including the fees (if applicable). If you do not yet have a + wallet, please follow the instructions on + </i18n.Translate> <a class="font-semibold text-gray-500 hover:text-gray-400" name="wallet page" diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx index a9c652643..7cf2c7881 100644 --- a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -19,6 +19,7 @@ import { AmountJson, Amounts, HttpStatusCode, + TalerCorebankApi, TranslatedString, assertUnreachable, parseWithdrawUri, @@ -45,6 +46,7 @@ import { RenderAmount, doAutoFocus, } from "./PaytoWireTransferForm.js"; +import { useSettingsContext } from "../context/settings.js"; const RefAmount = forwardRef(InputAmount); @@ -64,7 +66,7 @@ function OldWithdrawalForm({ routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const [settings] = usePreferences(); + const settings = useSettingsContext(); // const walletInegrationApi = useTalerWalletIntegrationAPI() // const { navigateTo } = useNavigationContext(); @@ -79,7 +81,7 @@ function OldWithdrawalForm({ const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [amountStr, setAmountStr] = useState<string | undefined>( - `${settings.maxWithdrawalAmount}`, + `${settings.defaultSuggestedAmount ?? 1}`, ); const [notification, notify, handleError] = useLocalNotification(); @@ -141,9 +143,15 @@ function OldWithdrawalForm({ async function doStart() { if (!parsedAmount || !creds) return; await handleError(async () => { - const resp = await api.createWithdrawal(creds, { - amount: Amounts.stringify(parsedAmount), - }); + const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = + settings.fastWithdrawalForm + ? { + suggested_amount: Amounts.stringify(parsedAmount), + } + : { + amount: Amounts.stringify(parsedAmount), + }; + const resp = await api.createWithdrawal(creds, params); if (resp.type === "ok") { const uri = parseWithdrawUri(resp.body.taler_withdraw_uri); if (!uri) { @@ -234,9 +242,9 @@ function OldWithdrawalForm({ </i18n.Translate> </p> {Amounts.cmp(limit, balance) > 0 ? ( - <p class="mt-2 text-sm text-gray-500"> + <p class="mt-2 text-sm text-gray-900"> <i18n.Translate> - Your account allows you to withdraw{" "} + You can withdraw{" "} <RenderAmount value={limit} spec={config.currency_specification} @@ -340,7 +348,8 @@ export function WalletWithdrawForm({ routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = usePreferences(); + const [pref, updatePref] = usePreferences(); + const settings = useSettingsContext(); return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> @@ -357,11 +366,11 @@ export function WalletWithdrawForm({ </div> <div class="col-span-2"> - {settings.showInstallWallet && ( + {pref.showInstallWallet && ( <Attention title={i18n.str`You need a Taler wallet`} onClose={() => { - updateSettings("showInstallWallet", false); + updatePref("showInstallWallet", false); }} > <i18n.Translate> @@ -379,7 +388,7 @@ export function WalletWithdrawForm({ </Attention> )} - {!settings.fastWithdrawal ? ( + {!settings.fastWithdrawalForm ? ( <OldWithdrawalForm focus={focus} routeOperationDetails={routeOperationDetails} diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index 853dd7bae..b270c447a 100644 --- a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -17,6 +17,7 @@ import { AbsoluteTime, AmountJson, + Amounts, HttpStatusCode, PaytoUri, PaytoUriIBAN, @@ -79,6 +80,11 @@ export function WithdrawalConfirmationQuestion({ lib: { bank: api }, } = useBankCoreApiContext(); + const wireFee = + config.wire_transfer_fees === undefined + ? Amounts.zeroOfCurrency(config.currency) + : Amounts.parseOrThrow(config.wire_transfer_fees); + async function doTransfer() { await handleError(async () => { if (!creds) return; @@ -357,6 +363,23 @@ export function WithdrawalConfirmationQuestion({ /> </dd> </div> + {Amounts.isZero(wireFee) ? undefined : ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Cost</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={wireFee} + negative + withColor + spec={config.currency_specification} + /> + </dd> + </div> + </Fragment> + )} </dl> </div> </div> diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx index 6db0e5512..0e2144d77 100644 --- a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -15,6 +15,7 @@ */ import { AbsoluteTime, + AccountLetter, HttpStatusCode, TalerCorebankApi, TalerError, @@ -200,28 +201,17 @@ export function ShowAccountDetails({ } const url = bank.getRevenueAPI(account); - url.username = account; const baseURL = url.href; - + const revenueURL = new URL(baseURL) + revenueURL.username = account; + revenueURL.password = creds?.token ?? "" const ac = parsePaytoUri(result.body.payto_uri); const payto = !ac?.isKnown ? undefined : ac; - let accountLetter: string | undefined = undefined; - if (payto) { - switch (payto.targetType) { - case "iban": { - accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\niban=${payto.iban}\nreceiver-name=${result.body.name}\n`; - break; - } - case "x-taler-bank": { - accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naccount=${payto.account}\nhost=${payto.host}\nreceiver-name=${result.body.name}\n`; - break; - } - case "bitcoin": { - accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naddress=${payto.address}\nreceiver-name=${result.body.name}\n`; - break; - } + const accountLetter : AccountLetter | undefined = !payto + ? undefined + : { + accountURI: result.body.payto_uri, infoURL: revenueURL.href } - } return ( <Fragment> @@ -327,7 +317,7 @@ export function ShowAccountDetails({ name="account-type" id="account-type" disabled={true} - value={account} + value={payto.targetType} autocomplete="off" /> </div> @@ -372,16 +362,16 @@ export function ShowAccountDetails({ <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" - for="iban" + for="account-name" > - {i18n.str`IBAN`} + {i18n.str`Account name`} </label> <div class="mt-2"> <input type="text" class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" + name="account-name" + id="account-name" disabled={true} value={payto.account} autocomplete="off" @@ -389,7 +379,7 @@ export function ShowAccountDetails({ </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - International Bank Account Number. + Bank account identifier for wire transfers. </i18n.Translate> </p> </div> @@ -486,7 +476,7 @@ export function ShowAccountDetails({ <i18n.Translate>Cancel</i18n.Translate> </a> <CopyButton - getContent={() => accountLetter ?? ""} + getContent={() => !accountLetter ? "" : JSON.stringify(accountLetter)} class="flex text-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > <i18n.Translate>Copy</i18n.Translate> @@ -498,3 +488,4 @@ export function ShowAccountDetails({ </Fragment> ); } + diff --git a/packages/bank-ui/src/settings.json b/packages/bank-ui/src/settings.json index df5fe75ce..f14168e77 100644 --- a/packages/bank-ui/src/settings.json +++ b/packages/bank-ui/src/settings.json @@ -2,6 +2,8 @@ "backendBaseURL": "http://bank.taler.test:1180/", "simplePasswordForRandomAccounts": true, "allowRandomAccountCreation": true, + "fastWithdrawalForm": true, + "defaultSuggestedAmount": 11, "bankName": "Taler DEVELOPMENT Bank", "topNavSites": { "Exchange": "http://Exchnage.taler.test:1180/", diff --git a/packages/bank-ui/src/settings.ts b/packages/bank-ui/src/settings.ts index c085c7cd8..6d8f7b850 100644 --- a/packages/bank-ui/src/settings.ts +++ b/packages/bank-ui/src/settings.ts @@ -20,6 +20,7 @@ import { canonicalizeBaseUrl, codecForBoolean, codecForMap, + codecForNumber, codecForString, codecOptional, } from "@gnu-taler/taler-util"; @@ -45,6 +46,17 @@ export interface UiSettings { // - value: link target, where the user is going to be redirected // default: empty list topNavSites?: Record<string, string>; + // Use the withdrawal form which redirect the user to the wallet + // without asking the amount to the user. + // - true: on withdrawal creation the spa will use suggested_amount instead + // of fixed amount + // - false: on withdrawal creation the spa will use fixed amount + // default: false + fastWithdrawalForm?: boolean; + // When the withdrawal form use the suggested amount the bank + // will send a default value that the user can change. + // default: 10 + defaultSuggestedAmount?: number; } /** @@ -56,12 +68,16 @@ const defaultSettings: UiSettings = { simplePasswordForRandomAccounts: false, allowRandomAccountCreation: false, topNavSites: {}, + fastWithdrawalForm: false, + defaultSuggestedAmount: 10, }; const codecForUISettings = (): Codec<UiSettings> => buildCodecForObject<UiSettings>() .property("backendBaseURL", codecOptional(codecForString())) .property("allowRandomAccountCreation", codecOptional(codecForBoolean())) + .property("fastWithdrawalForm", codecOptional(codecForBoolean())) + .property("defaultSuggestedAmount", codecOptional(codecForNumber())) .property( "simplePasswordForRandomAccounts", codecOptional(codecForBoolean()), diff --git a/packages/challenger-ui/package.json b/packages/challenger-ui/package.json index 8234e2385..7cc73771b 100644 --- a/packages/challenger-ui/package.json +++ b/packages/challenger-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/challenger-ui", - "version": "0.10.7", + "version": "0.11.4", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "UI for GNU Challenger.", diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json index 376265c0f..ce3123619 100644 --- a/packages/idb-bridge/package.json +++ b/packages/idb-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/idb-bridge", - "version": "0.10.7", + "version": "0.11.4", "description": "IndexedDB implementation that uses SQLite3 as storage", "main": "./dist/idb-bridge.js", "module": "./lib/index.js", @@ -38,6 +38,6 @@ "failFast": true }, "optionalDependencies": { - "better-sqlite3": "9.4.0" + "better-sqlite3": "10.0.0" } } diff --git a/packages/merchant-backend-ui/package.json b/packages/merchant-backend-ui/package.json index bd16317f5..bc8627312 100644 --- a/packages/merchant-backend-ui/package.json +++ b/packages/merchant-backend-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/merchant-backend-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-or-later", "scripts": { "compile": "tsc && ./build.mjs", diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json index e80604777..8aabdce87 100644 --- a/packages/merchant-backoffice-ui/package.json +++ b/packages/merchant-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/merchant-backoffice-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx index a0c15c77c..4ac798afe 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -18,13 +18,10 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { - parsePaytoUri, - PaytoUriGeneric, - stringifyPaytoUri, -} from "@gnu-taler/taler-util"; +import { parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; import { COUNTRY_TABLE } from "../../utils/constants.js"; import { undefinedIfEmpty } from "../../utils/table.js"; import { FormErrors, FormProvider } from "./FormProvider.js"; @@ -32,7 +29,6 @@ import { Input } from "./Input.js"; import { InputGroup } from "./InputGroup.js"; import { InputSelector } from "./InputSelector.js"; import { InputProps, useField } from "./useField.js"; -import { useEffect, useState } from "preact/hooks"; export interface Props<T> extends InputProps<T> { isValid?: (e: any) => boolean; @@ -108,13 +104,13 @@ function validateEthereum_path1( * bank.com/path * bank.com/path/subpath/ */ -const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+(\/[a-zA-Z0-9-.]+)*\/?$/ +const DOMAIN_REGEX = + /^[a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9-_](?:\.[a-zA-Z0-9-_]{2,})+(:[0-9]+)?(\/[a-zA-Z0-9-.]+)*\/?$/; function validateTalerBank_path1( addr: string, i18n: ReturnType<typeof useTranslationContext>["i18n"], ): string | undefined { - console.log(addr, DOMAIN_REGEX.test(addr)) try { const valid = DOMAIN_REGEX.test(addr); if (valid) return undefined; @@ -206,6 +202,7 @@ export function InputPaytoForm<T>({ const { value: initialValueStr, onChange } = useField<T>(name); const initialPayto = parsePaytoUri(initialValueStr ?? ""); + const paths = !initialPayto ? [] : initialPayto.targetPath.split("/"); const initialPath1 = paths.length >= 1 ? paths[0] : undefined; const initialPath2 = paths.length >= 2 ? paths[1] : undefined; @@ -219,6 +216,22 @@ export function InputPaytoForm<T>({ path2: initialPath2, }; const [value, setValue] = useState<Partial<Entity>>(initial); + useEffect(() => { + const nv = parsePaytoUri(initialValueStr ?? ""); + const paths = !initialPayto ? [] : initialPayto.targetPath.split("/"); + if (nv !== undefined && nv.isKnown) { + if (nv.targetType === "iban" && paths.length >= 2) { + //FIXME: workaround EBIC not supported + paths[0] = paths[1] + } + setValue({ + target: nv.targetType, + params: nv.params, + path1: paths.length >= 1 ? paths[0] : undefined, + path2: paths.length >= 2 ? paths[1] : undefined, + }); + } + }, [initialValueStr]); const { i18n } = useTranslationContext(); @@ -252,7 +265,8 @@ export function InputPaytoForm<T>({ (k) => (errors as any)[k] !== undefined, ); - const path1WithSlash = value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1 + const path1WithSlash = + value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1; const str = hasErrors || !value.target ? undefined @@ -268,37 +282,6 @@ export function InputPaytoForm<T>({ onChange(str as any); }, [str]); - // const submit = useCallback((): void => { - // // const accounts: TalerMerchantApi.AccountAddDetails[] = paytos; - // // const alreadyExists = - // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; - // // if (!alreadyExists) { - // const newValue: TalerMerchantApi.AccountAddDetails = { - // payto_uri: paytoURL, - // }; - // if (value.auth) { - // if (value.auth.url) { - // newValue.credit_facade_url = value.auth.url; - // } - // if (value.auth.type === "none") { - // newValue.credit_facade_credentials = { - // type: "none", - // }; - // } - // if (value.auth.type === "basic") { - // newValue.credit_facade_credentials = { - // type: "basic", - // username: value.auth.username ?? "", - // password: value.auth.password ?? "", - // }; - // } - // } - // onChange(newValue as any); - // // } - // // valueHandler(defaultTarget); - // }, [value]); - - //FIXME: translating plural singular return ( <InputGroup name="payto" label={label} fixed tooltip={tooltip}> <FormProvider<Entity> @@ -413,11 +396,17 @@ export function InputPaytoForm<T>({ return v; }} tooltip={i18n.str`Bank host.`} - help={<Fragment> - <div><i18n.Translate>Without scheme and may include subpath:</i18n.Translate></div> - <div>bank.com/</div> - <div>bank.com/path/subpath/</div> - </Fragment>} + help={ + <Fragment> + <div> + <i18n.Translate> + Without scheme and may include subpath: + </i18n.Translate> + </div> + <div>bank.com/</div> + <div>bank.com/path/subpath/</div> + </Fragment> + } /> <Input<Entity> name="path2" diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx index 864d09f48..efcca302f 100644 --- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -60,22 +60,6 @@ export function DefaultInstanceFormFields({ tooltip={i18n.str`Legal name of the business represented by this instance.`} /> - <TextField name="asdasd" label=""> - <i18n.Translate> - Choose individual if you don't have or are not required to have legal business permission. - </i18n.Translate> - </TextField> - - <InputSelector<Entity> - name="user_type" - label={i18n.str`Selling as`} - tooltip={i18n.str`Different type of account can have different rules and requirements.`} - values={["business", "individual"]} - toStr={(d: string) => { - return d.toUpperCase(); - }} - /> - <Input<Entity> name="email" label={i18n.str`Email`} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index dbe21e0e9..aeb49e81d 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -111,7 +111,7 @@ export function Sidebar({ mobile }: Props): VNode { <li> <a href={"/templates"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-qrcode" /> </span> <span class="menu-item-label"> <i18n.Translate>Templates</i18n.Translate> @@ -166,7 +166,7 @@ export function Sidebar({ mobile }: Props): VNode { <li> <a href={"/webhooks"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-webhook" /> </span> <span class="menu-item-label"> <i18n.Translate>Webhooks</i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx index 1335d0f77..43062d13e 100644 --- a/packages/merchant-backoffice-ui/src/components/modal/index.tsx +++ b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -24,9 +24,14 @@ import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; import { Spinner } from "../exception/loading.js"; -import { FormProvider } from "../form/FormProvider.js"; +import { FormErrors, FormProvider } from "../form/FormProvider.js"; import { Input } from "../form/Input.js"; import { useSessionContext } from "../../context/session.js"; +import { + AccountLetter, + codecForAccountLetter, + PaytoString, +} from "@gnu-taler/taler-util"; interface Props { active?: boolean; @@ -201,6 +206,88 @@ export function ClearConfirmModal({ ); } +interface ImportingAccountModalProps { + onCancel: () => void; + onConfirm: (account: AccountLetter) => void; +} + +export function ImportingAccountModal({ + onCancel, + onConfirm, +}: ImportingAccountModalProps): VNode { + const { i18n } = useTranslationContext(); + const [letter, setLetter] = useState<string>(); + let parsed = undefined; + try { + parsed = JSON.parse(letter ?? ""); + } catch (e) { + parsed = undefined; + } + let account: AccountLetter | undefined = undefined; + let parsingError: string | undefined = undefined; + try { + account = + parsed !== undefined ? codecForAccountLetter().decode(parsed) : undefined; + } catch (e) { + account = undefined; + if (e instanceof Error) { + parsingError = e.message; + } + } + const errors: FormErrors<{ letter: string }> = { + letter: !letter + ? i18n.str`required` + : parsed === undefined + ? i18n.str`letter should be a JSON string` + : account === undefined + ? i18n.str`JSON string is invalid` + : undefined, + }; + return ( + <ConfirmModal + label={i18n.str`Import`} + description={i18n.str`Importing an account from the bank`} + active + onCancel={onCancel} + disabled={account === undefined} + onConfirm={() => onConfirm(account!)} + > + <p> + <i18n.Translate> + You can export your account settings from the Libeufin Bank's account + profile. Paste the content in the next field. + </i18n.Translate> + </p> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Account information</i18n.Translate> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input + class="input" + value={letter ?? ""} + onChange={(e) => { + setLetter(e.currentTarget.value); + }} + /> + </p> + {letter !== undefined && errors.letter && ( + <p class="help is-danger">{errors.letter}</p> + )} + {parsingError !== undefined && ( + <p class="help is-danger">{parsingError}</p> + )} + </div> + </div> + </div> + </ConfirmModal> + ); +} + interface DeleteModalProps { element: { id: string; name: string }; onCancel: () => void; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx index d05375b6c..d0e7a83cd 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx @@ -31,6 +31,7 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { ImportingAccountModal } from "../../../../components/modal/index.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { safeConvertURL } from "../update/UpdatePage.js"; @@ -46,6 +47,7 @@ const accountAuthType = ["none", "basic"]; export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); + const [importing, setImporting] = useState(false); const [state, setState] = useState<Partial<Entity>>({}); const facadeURL = safeConvertURL(state.credit_facade_url); const errors: FormErrors<Entity> = { @@ -115,9 +117,25 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { credit_facade_url, }); }; - return ( <div> + {importing && <ImportingAccountModal onCancel={()=> {setImporting(false)}} onConfirm={(ac) => { + state.payto_uri = ac.accountURI + const u = new URL(ac.infoURL) + u.password = "" + if (u.username || u.password) { + state.credit_facade_credentials = { + type: "basic", + password: u.password, + username: u.username, + } + state.repeatPassword = u.password + } + u.password = "" + u.username = "" + state.credit_facade_url = u.href; + setImporting(false) + }} />} <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -171,6 +189,16 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </FormProvider> <div class="buttons is-right mt-5"> + <button + class="button is-info" + data-tooltip={i18n.str`Need to complete marked fields`} + onClick={() => { + setImporting(true) + }} + > + <i18n.Translate>Import from bank</i18n.Translate> + </button> + {onBack && ( <button class="button" onClick={onBack}> <i18n.Translate>Cancel</i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx index 9bab33f6f..aa1481a2e 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -24,6 +24,7 @@ import { HttpStatusCode, OperationFail, OperationOk, + PaytoString, TalerError, TalerMerchantApi, TalerRevenueHttpClient, @@ -67,51 +68,55 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { const resp = await testRevenueAPI( revenueAPI, request.credit_facade_credentials, + request.payto_uri, ); + if (resp instanceof TalerError) { + setNotif({ + message: i18n.str`Could not add bank account`, + type: "ERROR", + description: i18n.str`The request to check the revenue API failed.`, + details: JSON.stringify(resp.errorDetail, undefined, 2), + }); + return; + } if (resp.type === "fail") { switch (resp.case) { - case TestRevenueErrorType.NO_CONFIG: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, - }); - return; - } - case TestRevenueErrorType.CLIENT_BAD_REQUEST: { + case HttpStatusCode.BadRequest: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", description: i18n.str`Server replied with "bad request".`, }); return; + } - case TestRevenueErrorType.UNAUTHORIZED: { + case HttpStatusCode.Unauthorized: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", description: i18n.str`Unauthorized, try with another credentials.`, }); return; + } - case TestRevenueErrorType.NOT_FOUND: { + case HttpStatusCode.NotFound: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", - description: i18n.str`Check facade URL, server replied with "not found".`, + description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, }); return; } - case TestRevenueErrorType.GENERIC_ERROR: { + case TestRevenueErrorType.ANOTHER_ACCOUNT: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", - description: resp.detail.hint, + description: i18n.str`The account info URL returned information from an account which is not the same in the account form: ${resp.detail.hint}`, }); return; } default: { - assertUnreachable(resp.case); + assertUnreachable(resp); } } } @@ -136,17 +141,18 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { } export enum TestRevenueErrorType { - NO_CONFIG, - CLIENT_BAD_REQUEST, - UNAUTHORIZED, - NOT_FOUND, - GENERIC_ERROR, + ANOTHER_ACCOUNT, } export async function testRevenueAPI( revenueAPI: URL, creds: FacadeCredentials | undefined, -): Promise<OperationOk<void> | OperationFail<TestRevenueErrorType>> { + account: PaytoString, +): Promise<OperationOk<void> | OperationFail<HttpStatusCode.NotFound> +| OperationFail<HttpStatusCode.Unauthorized> +| OperationFail<HttpStatusCode.BadRequest> +| OperationFail<TestRevenueErrorType.ANOTHER_ACCOUNT> +| TalerError> { const api = new TalerRevenueHttpClient( revenueAPI.href, new BrowserFetchHttpLib(), @@ -167,69 +173,33 @@ export async function testRevenueAPI( const config = await api.getConfig(auth); if (config.type === "fail") { - switch (config.case) { - case HttpStatusCode.Unauthorized: { - return { - type: "fail", - case: TestRevenueErrorType.UNAUTHORIZED, - detail: { - code: 1, - }, - }; - } - case HttpStatusCode.NotFound: { - return { - type: "fail", - case: TestRevenueErrorType.NO_CONFIG, - detail: { - code: 1, - }, - }; - } - } + return config; } const history = await api.getHistory(auth); if (history.type === "fail") { - switch (history.case) { - case HttpStatusCode.BadRequest: { - return { - type: "fail", - case: TestRevenueErrorType.CLIENT_BAD_REQUEST, - detail: { - code: 1, - }, - }; - } - case HttpStatusCode.Unauthorized: { - return { - type: "fail", - case: TestRevenueErrorType.UNAUTHORIZED, - detail: { - code: 1, - }, - }; - } - case HttpStatusCode.NotFound: { - return { - type: "fail", - case: TestRevenueErrorType.NOT_FOUND, - detail: { - code: 1, - }, - }; - } - } + return history; } - } catch (err) { - if (err instanceof TalerError) { + if (history.body.credit_account !== account) { return { type: "fail", - case: TestRevenueErrorType.GENERIC_ERROR, - detail: err.errorDetail, + case: TestRevenueErrorType.ANOTHER_ACCOUNT, + detail: { + code: 1, + hint: history.body.credit_account + }, }; } + } catch (err) { + if (err instanceof TalerError) { + return err; + // return { + // type: "fail", + // case: TestRevenueErrorType.GENERIC_ERROR, + // detail: err.errorDetail, + // }; + } } return opFixedSuccess(undefined); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx index efe484402..a9cb2805b 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx @@ -48,7 +48,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-bank" /> </span> <i18n.Translate>Bank accounts</i18n.Translate> </p> @@ -240,9 +240,6 @@ function Table({ <th> <i18n.Translate>IBAN</i18n.Translate> </th> - <th> - <i18n.Translate>BIC</i18n.Translate> - </th> <th /> </tr> </thead> @@ -263,12 +260,6 @@ function Table({ > {ac.iban} </td> - <td - onClick={(): void => onSelect(acc)} - style={{ cursor: "pointer" }} - > - {ac.bic ?? ""} - </td> <td class="is-actions-cell right-sticky"> <div class="buttons is-right"> <button diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx index 70942fd55..9116aaa62 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -88,51 +88,55 @@ export default function UpdateValidator({ const resp = await testRevenueAPI( revenueAPI, request.credit_facade_credentials, + result.body.payto_uri, ); + if (resp instanceof TalerError) { + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: i18n.str`The request to check the revenue API failed.`, + details: JSON.stringify(resp.errorDetail, undefined, 2), + }); + return; + } if (resp.type === "fail") { switch (resp.case) { - case TestRevenueErrorType.NO_CONFIG: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, - }); - return; - } - case TestRevenueErrorType.CLIENT_BAD_REQUEST: { + case HttpStatusCode.BadRequest: { setNotif({ message: i18n.str`Could not create account`, type: "ERROR", description: i18n.str`Server replied with "bad request".`, }); return; + } - case TestRevenueErrorType.UNAUTHORIZED: { + case HttpStatusCode.Unauthorized: { setNotif({ message: i18n.str`Could not create account`, type: "ERROR", description: i18n.str`Unauthorized, try with another credentials.`, }); return; + } - case TestRevenueErrorType.NOT_FOUND: { + case HttpStatusCode.NotFound: { setNotif({ message: i18n.str`Could not create account`, type: "ERROR", - description: i18n.str`Check facade URL, server replied with "not found".`, + description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, }); return; } - case TestRevenueErrorType.GENERIC_ERROR: { + case TestRevenueErrorType.ANOTHER_ACCOUNT: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", - description: resp.detail.hint, + description: i18n.str`The account info URL returned information from an account which is not the same in the account form: ${resp.detail.hint}`, }); return; } default: { - assertUnreachable(resp.case) + assertUnreachable(resp); } } } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx index d5522c2d4..a16817bab 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx @@ -101,7 +101,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { /> <Input<Entity> name="otp_device_description" - label={i18n.str`Descripiton`} + label={i18n.str`Description`} tooltip={i18n.str`Useful to identify the device physically`} /> <InputSelector<Entity> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx index afe3c98e2..e4206ff7d 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx @@ -52,7 +52,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-lock" /> </span> <i18n.Translate>OTP Devices</i18n.Translate> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx index 50262be17..336a336ed 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -145,7 +145,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { template_id: state.id!, template_description: state.description!, template_contract, - required_currency: contract_amount !== undefined ? undefined : config.currency, editable_defaults: { amount: !state.amount_editable ? undefined : (state.amount ?? zero), summary: !state.summary_editable ? undefined : (state.summary ?? ""), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx index 082e622e3..4c55bae2a 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx @@ -56,7 +56,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-qrcode" /> </span> <i18n.Translate>Templates</i18n.Translate> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx index 32c5637aa..d284fda67 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -161,7 +161,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { return onUpdate({ template_description: state.description!, template_contract, - required_currency: contract_amount !== undefined ? undefined : config.currency, editable_defaults: { amount: !state.amount_editable ? undefined : (state.amount ?? zero), summary: !state.summary_editable ? undefined : (state.summary ?? ""), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx index 919285e78..877bd30e5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx @@ -52,7 +52,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-webhook" /> </span> <i18n.Translate>Webhooks</i18n.Translate> </p> diff --git a/packages/pogen/package.json b/packages/pogen/package.json index 24edc348b..81d66125f 100644 --- a/packages/pogen/package.json +++ b/packages/pogen/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/pogen", - "version": "0.10.7", + "version": "0.11.4", "bin": { "pogen": "bin/pogen" }, diff --git a/packages/taler-harness/debian/changelog b/packages/taler-harness/debian/changelog index 269c6b99d..a891cc7ba 100644 --- a/packages/taler-harness/debian/changelog +++ b/packages/taler-harness/debian/changelog @@ -1,3 +1,27 @@ +taler-harness (0.11.4) unstable; urgency=low + + * Release 0.11.4 + + -- Florian Dold <dold@taler.net> Mon, 10 Jun 2024 19:57:55 +0200 + +taler-harness (0.11.3) unstable; urgency=low + + * Release 0.11.3 + + -- Florian Dold <dold@taler.net> Fri, 07 Jun 2024 19:12:44 +0200 + +taler-harness (0.11.2) unstable; urgency=low + + * Release 0.11.2 + + -- Florian Dold <dold@taler.net> Wed, 05 Jun 2024 20:17:56 +0200 + +taler-harness (0.11.1) unstable; urgency=low + + * Release 0.11.1 + + -- Florian Dold <dold@taler.net> Mon, 27 May 2024 14:46:35 -0600 + taler-harness (0.10.7) unstable; urgency=low * Release 0.10.7 diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json index 38d640f51..bca870c8b 100644 --- a/packages/taler-harness/package.json +++ b/packages/taler-harness/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-harness", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.12.0" diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 136ec3d15..4fc462ddf 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -274,6 +274,7 @@ export class GlobalTestState { procs: ProcessWrapper[]; servers: http.Server[]; inShutdown: boolean = false; + stepSet: Set<string> = new Set(); constructor(params: GlobalTestParams) { this.testDir = params.testDir; this.procs = []; @@ -423,6 +424,9 @@ export class GlobalTestState { // Now we just log, later we may report the steps that were done // to easily see where the test hangs. console.info(`STEP: ${stepName}`); + if (this.stepSet.has(stepName)) { + throw Error(`duplicate step (${stepName})`); + } } } diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts index 4e3ce66b9..d194b0d36 100644 --- a/packages/taler-harness/src/harness/helpers.ts +++ b/packages/taler-harness/src/harness/helpers.ts @@ -116,6 +116,8 @@ export interface EnvOptions { mixedAgeRestriction?: boolean; + skipWireFeeCreation?: boolean; + additionalExchangeConfig?(e: ExchangeService): void; additionalMerchantConfig?(m: MerchantService): void; additionalBankConfig?(b: BankService): void; @@ -466,11 +468,12 @@ export async function createSimpleTestkudosEnvironmentV3( bank.corebankApiBaseUrl, ).href; - const exchangeBankAccount = { + const exchangeBankAccount: HarnessExchangeBankAccount = { wireGatewayApiBaseUrl, accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, accountPaytoUri: exchangePaytoUri, + skipWireFeeCreation: opts.skipWireFeeCreation === true, }; await exchange.addBankAccount("1", exchangeBankAccount); diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts index 69e45f678..34d18d87d 100644 --- a/packages/taler-harness/src/integrationtests/test-currency-scope.ts +++ b/packages/taler-harness/src/integrationtests/test-currency-scope.ts @@ -17,13 +17,14 @@ /** * Imports. */ -import { Duration, j2s } from "@gnu-taler/taler-util"; +import { Duration, TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { + BankService, ExchangeService, - FakebankService, GlobalTestState, + HarnessExchangeBankAccount, MerchantService, generateRandomPayto, setupDb, @@ -31,6 +32,7 @@ import { import { createWalletDaemonWithClient, withdrawViaBankV2, + withdrawViaBankV3, } from "../harness/helpers.js"; /** @@ -44,7 +46,7 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { nameSuffix: "exchange2", }); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: dbDefault.connStr, @@ -72,17 +74,25 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { database: dbDefault.connStr, }); - const exchangeOneBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - await exchangeOne.addBankAccount("1", exchangeOneBankAccount); - - const exchangeTwoBankAccount = await bank.createExchangeAccount( - "myexchange2", - "x", - ); - await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + let exchangeOneBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + }; + + let exchangeTwoBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange2/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange2", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange2"), + }; bank.setSuggestedExchange( exchangeOne, @@ -93,6 +103,31 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { await bank.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + await bankClient.registerAccountExtended({ + name: exchangeOneBankAccount.accountName, + username: exchangeOneBankAccount.accountName, + password: exchangeOneBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeOneBankAccount.accountPaytoUri, + }); + await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + + await bankClient.registerAccountExtended({ + name: exchangeTwoBankAccount.accountName, + username: exchangeTwoBankAccount.accountName, + password: exchangeTwoBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeTwoBankAccount.accountPaytoUri, + }); + await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + // Set up the first exchange exchangeOne.addOfferedCoins(defaultCoinConfig); @@ -139,16 +174,16 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { // Withdraw digital cash into the wallet. - const w1 = await withdrawViaBankV2(t, { + const w1 = await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeOne, amount: "TESTKUDOS:6", }); - const w2 = await withdrawViaBankV2(t, { + const w2 = await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeTwo, amount: "TESTKUDOS:6", }); diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts index b5cf0770f..26e843073 100644 --- a/packages/taler-harness/src/integrationtests/test-multiexchange.ts +++ b/packages/taler-harness/src/integrationtests/test-multiexchange.ts @@ -17,13 +17,14 @@ /** * Imports. */ -import { Duration, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { Duration, TalerCorebankApiClient, TalerMerchantApi } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { + BankService, ExchangeService, - FakebankService, GlobalTestState, + HarnessExchangeBankAccount, MerchantService, generateRandomPayto, setupDb, @@ -32,6 +33,7 @@ import { createWalletDaemonWithClient, makeTestPaymentV2, withdrawViaBankV2, + withdrawViaBankV3, } from "../harness/helpers.js"; /** @@ -45,7 +47,7 @@ export async function runMultiExchangeTest(t: GlobalTestState) { nameSuffix: "exchange2", }); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: dbDefault.connStr, @@ -73,17 +75,25 @@ export async function runMultiExchangeTest(t: GlobalTestState) { database: dbDefault.connStr, }); - const exchangeOneBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + let exchangeOneBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + }; - const exchangeTwoBankAccount = await bank.createExchangeAccount( - "myexchange2", - "x", - ); - await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + let exchangeTwoBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange2/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange2", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange2"), + }; bank.setSuggestedExchange( exchangeOne, @@ -94,6 +104,31 @@ export async function runMultiExchangeTest(t: GlobalTestState) { await bank.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + await bankClient.registerAccountExtended({ + name: exchangeOneBankAccount.accountName, + username: exchangeOneBankAccount.accountName, + password: exchangeOneBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeOneBankAccount.accountPaytoUri, + }); + await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + + await bankClient.registerAccountExtended({ + name: exchangeTwoBankAccount.accountName, + username: exchangeTwoBankAccount.accountName, + password: exchangeTwoBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeTwoBankAccount.accountPaytoUri, + }); + await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + // Set up the first exchange exchangeOne.addOfferedCoins(defaultCoinConfig); @@ -141,16 +176,16 @@ export async function runMultiExchangeTest(t: GlobalTestState) { // Withdraw digital cash into the wallet. - await withdrawViaBankV2(t, { + await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeOne, amount: "TESTKUDOS:6", }); - await withdrawViaBankV2(t, { + await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeTwo, amount: "TESTKUDOS:6", }); diff --git a/packages/taler-harness/src/integrationtests/test-payment-template.ts b/packages/taler-harness/src/integrationtests/test-payment-template.ts index fc4cd1198..af92d43c5 100644 --- a/packages/taler-harness/src/integrationtests/test-payment-template.ts +++ b/packages/taler-harness/src/integrationtests/test-payment-template.ts @@ -93,7 +93,9 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { WalletApiOperation.PreparePayForTemplate, { talerPayTemplateUri, - templateParams: {}, + templateParams: { + amount: "TESTKUDOS:1", + }, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-refund-auto.ts b/packages/taler-harness/src/integrationtests/test-refund-auto.ts index 5fcfa066a..6e02071af 100644 --- a/packages/taler-harness/src/integrationtests/test-refund-auto.ts +++ b/packages/taler-harness/src/integrationtests/test-refund-auto.ts @@ -17,7 +17,12 @@ /** * Imports. */ -import { Duration, MerchantApiClient } from "@gnu-taler/taler-util"; +import { + Duration, + MerchantApiClient, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { GlobalTestState } from "../harness/harness.js"; import { @@ -47,67 +52,134 @@ export async function runRefundAutoTest(t: GlobalTestState) { await wres.withdrawalFinishedCond; - // Set up order. - const orderResp = await merchantClient.createOrder({ - order: { - summary: "Buy me!", - amount: "TESTKUDOS:5", - fulfillment_url: "taler://fulfillment-success/thx", - auto_refund: { - d_us: 3000 * 1000, + // Test case where the auto-refund happens + { + // Set up order. + const orderResp = await merchantClient.createOrder({ + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + auto_refund: { + d_us: 3000 * 1000, + }, }, - }, - refund_delay: Duration.toTalerProtocolDuration( - Duration.fromSpec({ minutes: 5 }), - ), - }); + refund_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 5 }), + ), + }); - let orderStatus = await merchantClient.queryPrivateOrderStatus({ - orderId: orderResp.order_id, - }); + let orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); - t.assertTrue(orderStatus.order_status === "unpaid"); + t.assertTrue(orderStatus.order_status === "unpaid"); - // Make wallet pay for the order + // Make wallet pay for the order - const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, { - talerPayUri: orderStatus.taler_pay_uri, - }); + const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, { + talerPayUri: orderStatus.taler_pay_uri, + }); - await walletClient.call(WalletApiOperation.ConfirmPay, { - transactionId: r1.transactionId, - }); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: r1.transactionId, + }); - // Check if payment was successful. + // Check if payment was successful. - orderStatus = await merchantClient.queryPrivateOrderStatus({ - orderId: orderResp.order_id, - }); + orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); - t.assertTrue(orderStatus.order_status === "paid"); + t.assertTrue(orderStatus.order_status === "paid"); - const ref = await merchantClient.giveRefund({ - amount: "TESTKUDOS:5", - instance: "default", - justification: "foo", - orderId: orderResp.order_id, - }); + const ref = await merchantClient.giveRefund({ + amount: "TESTKUDOS:5", + instance: "default", + justification: "foo", + orderId: orderResp.order_id, + }); + + console.log(ref); + + // The wallet should now automatically pick up the refund. + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + + const transactions = await walletClient.call( + WalletApiOperation.GetTransactions, + { + sort: "stable-ascending", + }, + ); + console.log(JSON.stringify(transactions, undefined, 2)); + + const transactionTypes = transactions.transactions.map((x) => x.type); + t.assertDeepEqual(transactionTypes, ["withdrawal", "payment", "refund"]); + } + + // Now test the case where the auto-refund just expires + + { + // Set up order. + const orderResp = await merchantClient.createOrder({ + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + auto_refund: { + d_us: 3000 * 1000, + }, + }, + refund_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 5 }), + ), + }); + + let orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); + + t.assertTrue(orderStatus.order_status === "unpaid"); - console.log(ref); + // Make wallet pay for the order - // The wallet should now automatically pick up the refund. - await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, { + talerPayUri: orderStatus.taler_pay_uri, + }); - const transactions = await walletClient.call( - WalletApiOperation.GetTransactions, - {}, - ); - console.log(JSON.stringify(transactions, undefined, 2)); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: r1.transactionId, + }); - const transactionTypes = transactions.transactions.map((x) => x.type); - t.assertDeepEqual(transactionTypes, ["withdrawal", "payment", "refund"]); + // Check if payment was successful. - await t.shutdown(); + orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); + + t.assertTrue(orderStatus.order_status === "paid"); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: r1.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AutoRefund, + }, + }); + // Only time-travel the wallet + await walletClient.call(WalletApiOperation.TestingSetTimetravel, { + offsetMs: 5000, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: r1.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); + } } runRefundAutoTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts index e6c84b75d..046bd5aed 100644 --- a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts +++ b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts @@ -24,11 +24,12 @@ import { NotificationType, PreparePayResultType, TalerCorebankApiClient, + j2s, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; import { - BankService, + BankService, ExchangeService, GlobalTestState, MerchantService, @@ -78,7 +79,10 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { await exchange.addBankAccount("1", { accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.baseUrl, + ).href, accountPaytoUri: exchangePaytoUri, }); @@ -129,29 +133,42 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { const { walletClient } = await createWalletDaemonWithClient(t, { name: "w1", + persistent: true, }); const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl()); - // Withdraw digital cash into the wallet. + t.logStep("exchangeUpdated1Cond"); + // Withdraw digital cash into the wallet. + t.logStep("Withdraw digital cash into the wallet."); const wres = await withdrawViaBankV3(t, { walletClient, bankClient, exchange, amount: "TESTKUDOS:15", }); + t.logStep("wait"); await wres.withdrawalFinishedCond; - const exchangeUpdated1Cond = walletClient.waitForNotificationCond( (x) => - x.type === NotificationType.ExchangeStateTransition && - x.exchangeBaseUrl === exchange.baseUrl, + { + t.logStep(`EXCHANGE UPDATE, ${j2s(x)}`) + return x.type === NotificationType.ExchangeStateTransition && + x.exchangeBaseUrl === exchange.baseUrl + } ); + t.logStep("waiting tx"); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:15"); + } + // Travel into the future, the deposit expiration is two years // into the future. - console.log("applying first time travel"); + t.logStep("applying first time travel"); await applyTimeTravelV2( Duration.toMilliseconds(Duration.fromSpec({ days: 400 })), { @@ -162,9 +179,16 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { ); // The time travel should cause exchanges to update. + t.logStep("The time travel should cause exchanges to update"); await exchangeUpdated1Cond; + t.logStep("exchange updated, waiting for tx"); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:15"); + } + t.logStep("withdrawing second time"); const wres2 = await withdrawViaBankV3(t, { walletClient, bankClient, @@ -173,8 +197,14 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { }); await wres2.withdrawalFinishedCond; + t.logStep("witdrawn, waiting tx"); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:35"); + } + const exchangeUpdated2Cond = walletClient.waitForNotificationCond( (x) => x.type === NotificationType.ExchangeStateTransition && @@ -183,7 +213,7 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { // Travel into the future, the deposit expiration is two years // into the future. - console.log("applying second time travel"); + t.logStep("applying second time travel"); await applyTimeTravelV2( Duration.toMilliseconds(Duration.fromSpec({ years: 2, months: 6 })), { @@ -194,8 +224,13 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { ); // The time travel should cause exchanges to update. + t.logStep("The time travel should cause exchanges to update."); await exchangeUpdated2Cond; await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:35"); + } // At this point, the original coins should've been refreshed. // It would be too late to refresh them now, as we're past diff --git a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts index b9d028efd..a2573eda1 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts @@ -17,21 +17,16 @@ /** * Imports. */ -import { Duration, Logger, NotificationType, TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util"; +import { Duration, Logger, NotificationType, j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; import { - BankService, - ExchangeService, - FakebankService, GlobalTestState, - MerchantService, - generateRandomPayto, setupDb, } from "../harness/harness.js"; import { applyTimeTravelV2, - createWalletDaemonWithClient, + createSimpleTestkudosEnvironmentV3, withdrawViaBankV3, } from "../harness/helpers.js"; @@ -45,89 +40,14 @@ export async function runWalletDenomExpireTest(t: GlobalTestState) { const db = await setupDb(t); - const bank = await BankService.create(t, { - allowRegistrations: true, - currency: "TESTKUDOS", - database: db.connStr, - httpPort: 8082, - }); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - let receiverName = "Exchange"; - let exchangeBankUsername = "exchange"; - let exchangeBankPassword = "mypw"; - let exchangePaytoUri = generateRandomPayto(exchangeBankUsername); - - await exchange.addBankAccount("1", { - accountName: exchangeBankUsername, - accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, - accountPaytoUri: exchangePaytoUri, - }); - - bank.setSuggestedExchange(exchange, exchangePaytoUri); - - await bank.start(); - - await bank.pingUntilAvailable(); - - const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { - auth: { - username: "admin", - password: "adminpw", - }, - }); - - await bankClient.registerAccountExtended({ - name: receiverName, - password: exchangeBankPassword, - username: exchangeBankUsername, - is_taler_exchange: true, - payto_uri: exchangePaytoUri, - }); - - exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS")); - - await exchange.start(); - await exchange.pingUntilAvailable(); - - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); - - console.log("merchant started, configuring instances"); - - await merchant.addInstanceWithWireAccount({ - id: "default", - name: "Default Instance", - paytoUris: [generateRandomPayto("merchant-default")], - }); + const coinConfig = makeNoFeeCoinConfig("TESTKUDOS"); - await merchant.addInstanceWithWireAccount({ - id: "minst1", - name: "minst1", - paytoUris: [generateRandomPayto("minst1")], - }); - - console.log("setup done!"); - - const { walletClient } = await createWalletDaemonWithClient(t, { - name: "default", - }); + const { + walletClient, + bankClient, + exchange, + merchant, + } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); // Withdraw digital cash into the wallet. diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts index b36e6ef61..3a1b467c3 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts @@ -21,6 +21,7 @@ import { AmountString, ExchangeUpdateStatus, NotificationType, + TalerCorebankApiClient, j2s, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -30,11 +31,14 @@ import { ExchangeService, FakebankService, GlobalTestState, + HarnessExchangeBankAccount, + generateRandomPayto, setupDb, } from "../harness/harness.js"; import { createWalletDaemonWithClient, withdrawViaBankV2, + withdrawViaBankV3, } from "../harness/helpers.js"; /** @@ -51,7 +55,7 @@ export async function runWalletExchangeUpdateTest( nameSuffix: "two", }); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: db.connStr, @@ -75,10 +79,27 @@ export async function runWalletExchangeUpdateTest( database: db2.connStr, }); - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + // const exchangeBankAccount = await bank.createExchangeAccount( + // "myexchange", + // "x", + // ); + + let exchangeBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + }; await exchangeOne.addBankAccount("1", exchangeBankAccount); await exchangeTwo.addBankAccount("1", exchangeBankAccount); @@ -88,6 +109,14 @@ export async function runWalletExchangeUpdateTest( await bank.start(); + bankClient.registerAccountExtended({ + name: exchangeBankAccount.accountName, + username: exchangeBankAccount.accountName, + password: exchangeBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeBankAccount.accountPaytoUri, + }); + exchangeOne.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); exchangeTwo.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); @@ -108,9 +137,9 @@ export async function runWalletExchangeUpdateTest( t.assertDeepEqual(exchangesListResult.exchanges.length, 0); - const wres = await withdrawViaBankV2(t, { + const wres = await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeOne, amount: "TESTKUDOS:10", }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts index ac1244446..4062e186d 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts @@ -22,71 +22,32 @@ import { Duration, PaymentInsufficientBalanceDetails, TalerErrorCode, - WalletNotification, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { - ExchangeService, - FakebankService, GlobalTestState, - MerchantService, - WalletClient, - WalletService, generateRandomPayto, setupDb, } from "../harness/harness.js"; -import { withdrawViaBankV2 } from "../harness/helpers.js"; +import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3 } from "../harness/helpers.js"; export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { // Set up test environment const db = await setupDb(t); - const bank = await FakebankService.create(t, { - allowRegistrations: true, - currency: "TESTKUDOS", - database: db.connStr, - httpPort: 8082, - }); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchangeBankAccount.skipWireFeeCreation = true; - exchange.addBankAccount("1", exchangeBankAccount); - - bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); - - await bank.start(); - - await bank.pingUntilAvailable(); - const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")); - exchange.addCoinConfigList(coinConfig); - - await exchange.start(); - await exchange.pingUntilAvailable(); - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); + let { + bankClient, + exchange, + merchant, + walletService, + walletClient, + } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, { + skipWireFeeCreation: true, + }); await merchant.addInstanceWithWireAccount({ id: "default", @@ -106,24 +67,6 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { ), }); - const walletService = new WalletService(t, { - name: "wallet", - useInMemoryDb: true, - }); - await walletService.start(); - await walletService.pingUntilAvailable(); - - const allNotifications: WalletNotification[] = []; - - const walletClient = new WalletClient({ - name: "wallet", - unixPath: walletService.socketPath, - onNotification(n) { - console.log("got notification", n); - allNotifications.push(n); - }, - }); - await walletClient.connect(); await walletClient.client.call(WalletApiOperation.InitWallet, { config: { testing: { @@ -132,9 +75,9 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { }, }); - const wres = await withdrawViaBankV2(t, { + const wres = await withdrawViaBankV3(t, { amount: "TESTKUDOS:10", - bank, + bankClient, exchange, walletClient, }); @@ -146,10 +89,12 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { depositPaytoUri: "payto://x-taler-bank/localhost/foobar", }); }); + t.assertDeepEqual( exc.errorDetail.code, TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, ); + const insufficientBalanceDetails: PaymentInsufficientBalanceDetails = exc.errorDetail.insufficientBalanceDetails; diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts index a13095883..3ec2a3bcd 100644 --- a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts +++ b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts @@ -46,7 +46,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { "TESTKUDOS:10", ); - // Hand it to the wallet + t.logStep("Hand it to the wallet") const r1 = await walletClient.client.call( WalletApiOperation.GetWithdrawalDetailsForUri, @@ -55,7 +55,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); - // Withdraw + t.logStep("Withdraw") const r2 = await walletClient.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, @@ -65,6 +65,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); + t.logStep("wait confirmed") const withdrawalBankConfirmedCond = walletClient.waitForNotificationCond( (x) => { return ( @@ -76,6 +77,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); + t.logStep("wait finished") const withdrawalFinishedCond = walletClient.waitForNotificationCond((x) => { return ( x.type === NotificationType.TransactionStateTransition && @@ -84,6 +86,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { ); }); + t.logStep("wait withdraw coins") const withdrawalReserveReadyCond = walletClient.waitForNotificationCond( (x) => { return ( @@ -95,7 +98,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); - // Do it twice to check idempotency + t.logStep("Do it twice to check idempotency") const r3 = await walletClient.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, { @@ -104,9 +107,10 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); + t.logStep("stop wirewatch") await exchange.stopWirewatch(); - // Check status before withdrawal is confirmed by bank. + t.logStep("Check status before withdrawal is confirmed by bank.") { const txn = await walletClient.client.call( WalletApiOperation.GetTransactions, @@ -122,7 +126,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false); } - // Confirm it + t.logStep("Confirm it") await bankClient.confirmWithdrawalOperation(user.username, { withdrawalOperationId: wop.withdrawal_id, @@ -132,6 +136,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { // Check status after withdrawal is confirmed by bank, // but before funds are wired to the exchange. + t.logStep("Check status after withdrawal") { const txn = await walletClient.client.call( WalletApiOperation.GetTransactions, @@ -147,11 +152,13 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false); } + t.logStep("start wirewatch") await exchange.startWirewatch(); + t.logStep("wait reserve") await withdrawalReserveReadyCond; - // Check status after funds were wired. + t.logStep("Check status after funds were wired.") { const txn = await walletClient.client.call( WalletApiOperation.GetTransactions, @@ -169,7 +176,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { await withdrawalFinishedCond; - // Check balance + t.logStep("Check balance") const balResp = await walletClient.client.call( WalletApiOperation.GetBalances, diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts index 615feafa7..c55e1faf0 100644 --- a/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts +++ b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts @@ -33,9 +33,11 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import * as http from "node:http"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { + BankService, ExchangeService, FakebankService, GlobalTestState, + HarnessExchangeBankAccount, MerchantService, generateRandomPayto, setupDb, @@ -135,7 +137,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { const db = await setupDb(t); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: db.connStr, @@ -156,17 +158,40 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { database: db.connStr, }); - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchangeBankAccount.conversionUrl = "http://localhost:8071/"; + let exchangeBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + conversionUrl: "http://localhost:8071/", + }; + await exchange.addBankAccount("1", exchangeBankAccount); await bank.start(); await bank.pingUntilAvailable(); + const bankClientAuth = { + username: "admin", + password: "adminpw", + }; + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: bankClientAuth, + }); + + await bankClient.registerAccountExtended({ + name: exchangeBankAccount.accountName, + username: exchangeBankAccount.accountName, + password: exchangeBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeBankAccount.accountPaytoUri, + }); + exchange.addOfferedCoins(defaultCoinConfig); await exchange.start(); @@ -194,7 +219,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { ), }); - const { walletClient, walletService } = await createWalletDaemonWithClient( + const { walletClient } = await createWalletDaemonWithClient( t, { name: "wallet" }, ); @@ -203,11 +228,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { // Create a withdrawal operation - const bankAccessApiClient = new TalerCorebankApiClient( - bank.corebankApiBaseUrl, - ); - - const user = await bankAccessApiClient.createRandomBankUser(); + const user = await bankClient.createRandomBankUser(); await walletClient.call(WalletApiOperation.AddExchange, { exchangeBaseUrl: exchange.baseUrl, @@ -277,10 +298,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { const wireGatewayApiClient = new WireGatewayApiClient( exchangeBankAccount.wireGatewayApiBaseUrl, { - auth: { - username: exchangeBankAccount.accountName, - password: exchangeBankAccount.accountPassword, - }, + auth: bankClientAuth, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts index 1c65de7d9..0657d2da7 100644 --- a/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts +++ b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts @@ -90,7 +90,10 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { await exchange.addBankAccount("1", { accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.baseUrl, + ).href, accountPaytoUri: exchangePaytoUri, }); @@ -133,12 +136,9 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { const user = await bankClient.createRandomBankUser(); bankClient.setAuth(user); - const wop = await bankClient.createWithdrawalOperation( - user.username, - amount, - ); + const wop = await bankClient.createWithdrawalOperation(user.username, amount); - // Hand it to the wallet + t.logStep("Hand it to the wallet") const details = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForUri, @@ -149,10 +149,13 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { console.log(j2s(details)); + const myAmount = details.amount; + t.assertTrue(!!myAmount); + const amountDetails = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { - amount: details.amount, + amount: myAmount, exchangeBaseUrl: details.possibleExchanges[0].exchangeBaseUrl, }, ); @@ -162,23 +165,25 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { t.assertAmountEquals(amountDetails.amountEffective, "TESTKUDOS:5"); t.assertAmountEquals(amountDetails.amountRaw, "TESTKUDOS:7.5"); + t.logStep("Complete all pending operations") + await wallet.runPending(); - // Withdraw (AKA select) + t.logStep("Withdraw (AKA select)") await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: exchange.baseUrl, talerWithdrawUri: wop.taler_withdraw_uri, }); - // Confirm it + t.logStep("Confirm it") await bankClient.confirmWithdrawalOperation(user.username, { withdrawalOperationId: wop.withdrawal_id, }); await wallet.runUntilDone(); - // Check balance + t.logStep("Check balance") const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {}); console.log(j2s(balResp)); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts new file mode 100644 index 000000000..ffc7249b8 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts @@ -0,0 +1,70 @@ +/* + This file is part of GNU Taler + (C) 2020 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/> + */ + +/** + * Imports. + */ +import { j2s } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js"; + +/** + * Run test for bank-integrated withdrawal with flexible amount, + * i.e. the amount is chosen by the wallet. + */ +export async function runWithdrawalFlexTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bankClient, exchange } = + await createSimpleTestkudosEnvironmentV3(t); + + // Create a withdrawal operation + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth(user); + const wop = await bankClient.createWithdrawalOperation( + user.username, + undefined, + ); + + const r1 = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + console.log(j2s(r1)); + + // Withdraw + + const r2 = await walletClient.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + amount: "TESTKUDOS:10", + }, + ); + + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: wop.withdrawal_id, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); +} + +runWithdrawalFlexTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index eb2ae7fa6..4588310b1 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -113,14 +113,15 @@ import { runWalletRefreshTest } from "./test-wallet-refresh.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; import { runWallettestingTest } from "./test-wallettesting.js"; import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; +import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js"; import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js"; import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js"; +import { runWithdrawalFlexTest } from "./test-withdrawal-flex.js"; import { runWithdrawalHandoverTest } from "./test-withdrawal-handover.js"; import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js"; import { runWithdrawalManualTest } from "./test-withdrawal-manual.js"; -import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; /** * Test runner. @@ -232,6 +233,7 @@ const allTests: TestMainFunction[] = [ runPeerPushLargeTest, runWithdrawalHandoverTest, runWithdrawalAmountTest, + runWithdrawalFlexTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index 74b2d6155..87e6a7cfa 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-util", - "version": "0.10.7", + "version": "0.11.4", "description": "Generic helper functionality for GNU Taler", "type": "module", "types": "./lib/index.node.d.ts", diff --git a/packages/taler-util/src/CancellationToken.ts b/packages/taler-util/src/CancellationToken.ts index 3aa576d77..5f38f0c7b 100644 --- a/packages/taler-util/src/CancellationToken.ts +++ b/packages/taler-util/src/CancellationToken.ts @@ -172,7 +172,7 @@ class CancellationToken { } = CancellationToken.create(); let timer: NodeJS.Timeout | null; - timer = setTimeout(() => originalCancel(CancellationToken.timeout), ms); + timer = setTimeout(() => originalCancel(`CancellationToken.timeout ${ms}`), ms); const disposeTimer = () => { if (timer == null) return; clearTimeout(timer); diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts index e9f442af6..e1409087f 100644 --- a/packages/taler-util/src/bank-api-client.ts +++ b/packages/taler-util/src/bank-api-client.ts @@ -385,7 +385,7 @@ export class TalerCorebankApiClient { async createWithdrawalOperation( user: string, - amount: string, + amount: string | undefined, ): Promise<WithdrawalOperationInfo> { const url = new URL(`accounts/${user}/withdrawals`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts index 9378d25e8..d68177e4e 100644 --- a/packages/taler-util/src/errors.ts +++ b/packages/taler-util/src/errors.ts @@ -166,6 +166,11 @@ export interface DetailsMap { [TalerErrorCode.WALLET_DB_UNAVAILABLE]: { innerError: TalerErrorDetail | undefined; }; + [TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED]: { + exchangeBaseUrl: string; + tosStatus: string; + currentEtag: string | undefined; + }; } type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : empty; diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts index 75e6a627a..23740328b 100644 --- a/packages/taler-util/src/http-client/bank-integration.ts +++ b/packages/taler-util/src/http-client/bank-integration.ts @@ -50,7 +50,9 @@ export type TalerBankIntegrationErrorsByMethod< * The API is used by the wallets. */ export class TalerBankIntegrationHttpClient { - public readonly PROTOCOL_VERSION = "2:0:2"; + public static readonly PROTOCOL_VERSION = "2:0:1"; + public readonly PROTOCOL_VERSION = + TalerBankIntegrationHttpClient.PROTOCOL_VERSION; httpLib: HttpRequestLibrary; @@ -147,6 +149,10 @@ export class TalerBankIntegrationHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return opKnownTalerFailure(details.code, details); default: return opUnknownFailure(resp, details); } diff --git a/packages/taler-util/src/http-client/bank-revenue.ts b/packages/taler-util/src/http-client/bank-revenue.ts index 34afe7d86..8331856a9 100644 --- a/packages/taler-util/src/http-client/bank-revenue.ts +++ b/packages/taler-util/src/http-client/bank-revenue.ts @@ -25,6 +25,7 @@ import { LibtoolVersion } from "../libtool-version.js"; import { FailCasesByMethod, ResultByMethod, + opFixedSuccess, opKnownHttpFailure, opSuccessFromHttp, opUnknownFailure, @@ -117,6 +118,9 @@ export class TalerRevenueHttpClient { switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForRevenueIncomingHistory()); + // FIXME: missing in docs + case HttpStatusCode.NoContent: + return opFixedSuccess({incoming_transactions: [], credit_account: "" }); case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Unauthorized: diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts index edddf7d94..3e6d857cb 100644 --- a/packages/taler-util/src/http-client/types.ts +++ b/packages/taler-util/src/http-client/types.ts @@ -360,6 +360,7 @@ export const codecForCoreBankConfig = (): Codec<TalerCorebankApi.Config> => ), ) .property("wire_type", codecOptionalDefault(codecForString(), "iban")) + .property("wire_transfer_fees", codecOptional(codecForAmountString())) .build("TalerCorebankApi.Config"); //FIXME: implement this codec @@ -902,7 +903,6 @@ export const codecForTemplateDetails = .property("template_description", codecForString()) .property("otp_id", codecOptional(codecForString())) .property("template_contract", codecForTemplateContractDetails()) - .property("required_currency", codecOptional(codecForString())) .property( "editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()), @@ -931,7 +931,6 @@ export const codecForWalletTemplateDetails = (): Codec<TalerMerchantApi.WalletTemplateDetails> => buildCodecForObject<TalerMerchantApi.WalletTemplateDetails>() .property("template_contract", codecForTemplateContractDetails()) - .property("required_currency", codecOptional(codecForString())) .property( "editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()), @@ -1311,9 +1310,12 @@ export const codecForBankWithdrawalOperationStatus = codecForConstString("confirmed"), ), ) - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) + .property("currency", codecOptional(codecForCurrencyName())) + .property("suggested_amount", codecOptional(codecForAmountString())) + .property("card_fees", codecOptional(codecForAmountString())) .property("sender_wire", codecOptional(codecForPaytoString())) - .property("suggested_exchange", codecOptional(codecForString())) + .property("suggested_exchange", codecOptional(codecForURL())) .property("confirm_transfer_url", codecOptional(codecForURL())) .property("wire_types", codecForList(codecForString())) .property("selected_reserve_pub", codecOptional(codecForString())) @@ -2028,20 +2030,53 @@ export namespace TalerBankIntegrationApi { // confirmed: the transfer has been confirmed and registered by the bank status: WithdrawalOperationStatus; - // Amount that will be withdrawn with this operation - // (raw amount without fee considerations). - amount: AmountString; + // Currency used for the withdrawal. + // MUST be present when amount is absent. + // @since v2, may become mandatory in the future. + currency?: string; - // Bank account of the customer that is withdrawing, as a - // payto URI. + // Amount that will be withdrawn with this operation + // (raw amount without fee considerations). Only + // given once the amount is fixed and cannot be changed. + // Optional since **vC2EC**. + amount?: AmountString | undefined; + + // Suggestion for the amount to be withdrawn with this + // operation. Given if a suggestion was made but the + // user may still change the amount. + // Optional since **vC2EC**. + suggested_amount?: AmountString | undefined; + + // Maximum amount that the wallet can choose to withdraw. + // Only applicable when the amount is not fixed. + // @since **vC2EC**. + max_amount?: AmountString | undefined; + + // The non-Taler card fees the customer will have + // to pay to the bank / payment service provider + // they are using to make the withdrawal. + // @since **vC2EC** + card_fees?: AmountString | undefined; + + // Bank account of the customer that is debiting, as an + // RFC 8905 payto URI. sender_wire?: PaytoString; - // Suggestion for an exchange given by the bank. + // Base URL of the suggested exchange. The bank may have + // neither a suggestion nor a requirement for the exchange. + // This value is typically set in the bank's configuration. suggested_exchange?: string; + // Base URL of an exchange that must be used. Optional, + // not given *unless* a particular exchange is mandatory. + // This value is typically set in the bank's configuration. + // @since **vC2EC** + required_exchange?: string; + // URL that the user needs to navigate to in order to // complete some final confirmation (e.g. 2FA). - // It may contain withdrawal operation id + // Only applicable when status is selected or pending. + // It may contain the withdrawal operation id. confirm_transfer_url?: string; // Wire transfer types supported by the bank. @@ -2051,17 +2086,24 @@ export namespace TalerBankIntegrationApi { // only non-null if status is selected or confirmed. selected_reserve_pub?: string; - // Exchange account selected by the wallet + // Exchange account selected by the wallet; // only non-null if status is selected or confirmed. + // @since **v1** selected_exchange_account?: string; } export interface BankWithdrawalOperationPostRequest { - // Reserve public key. + // Reserve public key that should become the wire transfer + // subject to fund the withdrawal. reserve_pub: string; // Payto address of the exchange selected for the withdrawal. selected_exchange: PaytoString; + + // Selected amount to be transferred. Optional if the + // backend already knows the amount. + // @since **vC2EC** + amount?: AmountString | undefined; } export interface BankWithdrawalOperationPostResponse { @@ -2075,7 +2117,7 @@ export namespace TalerBankIntegrationApi { // URL that the user needs to navigate to in order to // complete some final confirmation (e.g. 2FA). // - // Only applicable when status is selected. + // Only applicable when status is selected or pending. // It may contain withdrawal operation id confirm_transfer_url?: string; } @@ -2150,12 +2192,31 @@ export namespace TalerCorebankApi { // Default to 'iban' is missing // @since v4, may become mandatory in the future. wire_type: string; + + // Wire transfer execution fees. + // @since v4, will become mandatory in the next version. + wire_transfer_fees?: AmountString; } export interface BankAccountCreateWithdrawalRequest { - // Amount to withdraw. - amount: AmountString; + // Amount to withdraw. If given, the wallet + // cannot change the amount. + // Optional since **vC2EC**. + amount?: AmountString; + + // Suggested amount to withdraw. The wallet can + // still change the suggestion. + // @since **vC2EC** + suggested_amount?: AmountString; + + // The non-Taler card fees the customer will have + // to pay to the account owner, bank and/or + // payment service provider + // they are using to make this withdrawal. + // @since **vC2EC** + card_fees?: AmountString; } + export interface BankAccountCreateWithdrawalResponse { // ID of the withdrawal, can be used to view/modify the withdrawal operation. withdrawal_id: string; @@ -2498,10 +2559,6 @@ export namespace TalerCorebankApi { export interface CashoutInfo { cashout_id: number; - /** - * @deprecated since 4, use new 2fa - */ - status?: "pending" | "aborted" | "confirmed"; } export interface GlobalCashouts { // Every string represents a cash-out operation ID. @@ -4693,17 +4750,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateContractDetails { // Human-readable summary for the template. @@ -4755,17 +4801,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateSummaryResponse { @@ -4791,17 +4826,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateDetails { @@ -4820,17 +4844,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface UsingTemplateDetails { // Summary of the template diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts index b4e4ebbe7..f60c82fc3 100644 --- a/packages/taler-util/src/http-impl.qtart.ts +++ b/packages/taler-util/src/http-impl.qtart.ts @@ -118,7 +118,10 @@ export class HttpLibImpl implements HttpRequestLibrary { // Just like WHATWG fetch(), the qjs http client doesn't // really support cancellation, so cancellation here just // means that the result is ignored! - const fetchProm = qjsOs.fetchHttp(url, { + const { + promise: fetchProm, + cancelFn + } = qjsOs.fetchHttp(url, { method, data, headers: headersList, @@ -135,6 +138,7 @@ export class HttpLibImpl implements HttpRequestLibrary { if (opt?.cancellationToken) { cancelCancelledHandler = opt.cancellationToken.onCancelled(() => { + cancelFn(); cancelPromCap.reject(new RequestCancelledError()); }); } diff --git a/packages/taler-util/src/invariants.ts b/packages/taler-util/src/invariants.ts index c6e9b8113..113d697c3 100644 --- a/packages/taler-util/src/invariants.ts +++ b/packages/taler-util/src/invariants.ts @@ -33,7 +33,7 @@ export class InvariantViolatedError extends Error { * * A violation of this invariant means that the database is inconsistent. */ -export function checkDbInvariant(b: boolean, m?: string): asserts b { +export function checkDbInvariant(b: boolean, m: string): asserts b { if (!b) { if (m) { throw Error(`BUG: database invariant failed (${m})`); diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts index d4dfe7589..a8a8c3299 100644 --- a/packages/taler-util/src/notifications.ts +++ b/packages/taler-util/src/notifications.ts @@ -128,7 +128,7 @@ export enum ObservabilityEventType { TaskStart = "task-start", TaskStop = "task-stop", TaskReset = "task-reset", - ShepherdTaskResult = "sheperd-task-result", + ShepherdTaskResult = "shepherd-task-result", DeclareTaskDependency = "declare-task-dependency", CryptoStart = "crypto-start", CryptoFinishSuccess = "crypto-finish-success", diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts index a471d0b87..39c25cffd 100644 --- a/packages/taler-util/src/payto.ts +++ b/packages/taler-util/src/payto.ts @@ -15,7 +15,7 @@ */ import { generateFakeSegwitAddress } from "./bitcoin.js"; -import { Codec, Context, DecodingError, renderContext } from "./codec.js"; +import { Codec, Context, DecodingError, buildCodecForObject, codecForStringURL, renderContext } from "./codec.js"; import { URLSearchParams } from "./url.js"; export type PaytoUri = @@ -291,3 +291,21 @@ export function talerPaytoFromExchangeReserve( return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; } + +/** + * The account letter is all the information + * the merchant backend requires from the + * bank account to check transfer. + * + */ +export type AccountLetter = { + accountURI: PaytoString; + infoURL: string; +}; + +export const codecForAccountLetter = + (): Codec<AccountLetter> => + buildCodecForObject<AccountLetter>() + .property("infoURL", codecForStringURL(true)) + .property("accountURI", codecForPaytoString()) + .build("AccountLetter"); diff --git a/packages/taler-util/src/qtart.ts b/packages/taler-util/src/qtart.ts index e298a157c..6a5984973 100644 --- a/packages/taler-util/src/qtart.ts +++ b/packages/taler-util/src/qtart.ts @@ -17,7 +17,10 @@ export interface QjsHttpOptions { } export interface QjsOsLib { - fetchHttp(url: string, options?: QjsHttpOptions): Promise<QjsHttpResp>; + fetchHttp(url: string, options?: QjsHttpOptions): { + promise: Promise<QjsHttpResp>, + cancelFn: () => number, + }; postMessageToHost(s: string): void; setMessageFromHostHandler(h: (s: string) => void): void; rename(oldPath: string, newPath: string): number; diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts index 9985e74b3..f77357407 100644 --- a/packages/taler-util/src/taler-error-codes.ts +++ b/packages/taler-util/src/taler-error-codes.ts @@ -354,7 +354,7 @@ export enum TalerErrorCode { /** * The backend could not locate a required template to generate an HTML reply. The system administrator should check if the resource files are installed in the correct location and are readable to the service. - * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500). + * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406). * (A value of 0 indicates that the error is generated client-side). */ GENERIC_FAILED_TO_LOAD_TEMPLATE = 74, @@ -1945,7 +1945,7 @@ export enum TalerErrorCode { /** - * The payto-URI hash did not match. Hence the request was denied. + * The KYC authorization signature was invalid. Hence the request was denied. * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). * (A value of 0 indicates that the error is generated client-side). */ @@ -2017,6 +2017,22 @@ export enum TalerErrorCode { /** + * The exchange is unaware of the given requirement row. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + EXCHANGE_KYC_CHECK_REQUEST_UNKNOWN = 1939, + + + /** + * The exchange has no account public key to check the KYC authorization signature against. Hence the request was denied. The user should do a wire transfer to the exchange with the KYC authorization key in the subject. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + EXCHANGE_KYC_CHECK_AUTHORIZATION_KEY_UNKNOWN = 1940, + + + /** * The exchange does not know a contract under the given contract public key. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -2105,6 +2121,14 @@ export enum TalerErrorCode { /** + * The product category is not known to the backend. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_GENERIC_CATEGORY_UNKNOWN = 2003, + + + /** * The proposal is not known to the backend. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -2561,6 +2585,14 @@ export enum TalerErrorCode { /** + * Invalid token because it was already used, is expired or not yet valid. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_INVALID = 2183, + + + /** * The contract hash does not match the given order ID. * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). * (A value of 0 indicates that the error is generated client-side). @@ -2921,6 +2953,14 @@ export enum TalerErrorCode { /** + * A token family referenced in this order is either expired or not valid yet. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_NOT_VALID = 2534, + + + /** * The exchange says it does not know this transfer. * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). * (A value of 0 indicates that the error is generated client-side). @@ -3057,6 +3097,14 @@ export enum TalerErrorCode { /** + * A category with the same name exists already. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_CATEGORIES_CONFLICT_CATEGORY_EXISTS = 2651, + + + /** * The update would have reduced the total amount of product lost, which is not allowed. * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). * (A value of 0 indicates that the error is generated client-side). @@ -3233,6 +3281,22 @@ export enum TalerErrorCode { /** + * The auditor refused the connection due to a lack of authorization. + * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401). + * (A value of 0 indicates that the error is generated client-side). + */ + AUDITOR_GENERIC_UNAUTHORIZED = 3001, + + + /** + * This method is not allowed here. + * Returned with an HTTP status code of #MHD_HTTP_METHOD_NOT_ALLOWED (405). + * (A value of 0 indicates that the error is generated client-side). + */ + AUDITOR_GENERIC_METHOD_NOT_ALLOWED = 3002, + + + /** * The signature from the exchange on the deposit confirmation is invalid. * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). * (A value of 0 indicates that the error is generated client-side). @@ -3633,6 +3697,22 @@ export enum TalerErrorCode { /** + * Specified amount will not work for this withdrawal. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_AMOUNT_DIFFERS = 5148, + + + /** + * The backend requires an amount to be specified. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_AMOUNT_REQUIRED = 5149, + + + /** * The sync service failed find the account in its database. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -4049,6 +4129,14 @@ export enum TalerErrorCode { /** + * A wallet-core request failed because the user needs to first accept the exchange's terms of service. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_EXCHANGE_TOS_NOT_ACCEPTED = 7037, + + + /** * We encountered a timeout with our payment backend. * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). * (A value of 0 indicates that the error is generated client-side). @@ -4609,6 +4697,62 @@ export enum TalerErrorCode { /** + * The Donau is not aware of the donation unit requested for the operation. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_GENERIC_DONATION_UNIT_UNKNOWN = 8611, + + + /** + * The Donau failed to talk to the process responsible for its private donation unit keys or the helpers had no donation units (properly) configured. + * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONATION_UNIT_HELPER_UNAVAILABLE = 8612, + + + /** + * The Donau failed to talk to the process responsible for its private signing keys. + * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_SIGNKEY_HELPER_UNAVAILABLE = 8613, + + + /** + * The response from the online signing key helper process was malformed. + * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_SIGNKEY_HELPER_BUG = 8614, + + + /** + * The number of segments included in the URI does not match the number of segments expected by the endpoint. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_GENERIC_WRONG_NUMBER_OF_SEGMENTS = 8615, + + + /** + * The signature of the donation receipt is not valid. + * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONATION_RECEIPT_SIGNATURE_INVALID = 8616, + + + /** + * The client re-used a unique donor identifier nonce, which is not allowed. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONOR_IDENTIFIER_NONCE_REUSE = 8617, + + + /** * A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information. * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts index e2536b74a..66f98ea9a 100644 --- a/packages/taler-util/src/taler-types.ts +++ b/packages/taler-util/src/taler-types.ts @@ -978,7 +978,7 @@ export class WithdrawOperationStatusResponse { aborted: boolean; - amount: string; + amount: string | undefined; sender_wire?: string; @@ -1557,7 +1557,7 @@ export const codecForWithdrawOperationStatusResponse = .property("selection_done", codecForBoolean()) .property("transfer_done", codecForBoolean()) .property("aborted", codecForBoolean()) - .property("amount", codecForString()) + .property("amount", codecOptional(codecForString())) .property("sender_wire", codecOptional(codecForString())) .property("suggested_exchange", codecOptional(codecForString())) .property("confirm_transfer_url", codecOptional(codecForString())) diff --git a/packages/taler-util/src/transactions-types.ts b/packages/taler-util/src/transactions-types.ts index cee3de9fa..a6ac5aec6 100644 --- a/packages/taler-util/src/transactions-types.ts +++ b/packages/taler-util/src/transactions-types.ts @@ -105,8 +105,11 @@ export enum TransactionMajorState { Done = "done", Aborting = "aborting", Aborted = "aborted", - Suspended = "suspended", Dialog = "dialog", + Finalizing = "finalizing", + // Plain suspended is always a suspended pending state. + Suspended = "suspended", + SuspendedFinalizing = "suspended-finalizing", SuspendedAborting = "suspended-aborting", Failed = "failed", Expired = "expired", @@ -324,7 +327,7 @@ export interface TransactionWithdrawal extends TransactionCommon { /** * Exchange of the withdrawal. */ - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; /** * Amount that got subtracted from the reserve balance. diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index d472af187..d23780145 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -54,7 +54,7 @@ import { canonicalizeBaseUrl, } from "./index.js"; import { VersionMatchResult } from "./libtool-version.js"; -import { PaytoUri } from "./payto.js"; +import { PaytoString, PaytoUri, codecForPaytoString } from "./payto.js"; import { AgeCommitmentProof } from "./taler-crypto.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { @@ -229,11 +229,13 @@ interface GetPlanForWalletInitiatedOperation { export interface ConvertAmountRequest { amount: AmountString; type: TransactionAmountMode; + depositPaytoUri: PaytoString; } export const codecForConvertAmountRequest = buildCodecForObject<ConvertAmountRequest>() .property("amount", codecForAmountString()) + .property("depositPaytoUri", codecForPaytoString()) .property( "type", codecForEither( @@ -663,11 +665,11 @@ export interface CoinDumpJson { withdrawal_reserve_pub: string | undefined; coin_status: CoinStatus; spend_allocation: - | { - id: string; - amount: AmountString; - } - | undefined; + | { + id: string; + amount: AmountString; + } + | undefined; /** * Information about the age restriction */ @@ -801,7 +803,7 @@ export const codecForPreparePayResultPaymentPossible = ) .build("PreparePayResultPaymentPossible"); -export interface BalanceDetails { } +export interface BalanceDetails {} /** * Detailed reason for why the wallet's balance is insufficient. @@ -984,9 +986,14 @@ export interface PreparePayResultAlreadyConfirmed { export interface BankWithdrawDetails { status: WithdrawalOperationStatus; - amount: AmountJson; + currency: string; + amount: AmountJson | undefined; + editableAmount: boolean; + maxAmount: AmountJson | undefined; + wireFee: AmountJson | undefined; senderWire?: string; - suggestedExchange?: string; + exchange?: string; + editableExchange: boolean; confirmTransferUrl?: string; wireTypes: string[]; operationId: string; @@ -1331,6 +1338,7 @@ export enum ExchangeTosStatus { Pending = "pending", Proposed = "proposed", Accepted = "accepted", + MissingTos = "missing-tos", } export enum ExchangeEntryStatus { @@ -1846,18 +1854,16 @@ export interface GetWithdrawalDetailsForAmountRequest { export interface PrepareBankIntegratedWithdrawalRequest { talerWithdrawUri: string; - selectedExchange?: string; } export const codecForPrepareBankIntegratedWithdrawalRequest = (): Codec<PrepareBankIntegratedWithdrawalRequest> => buildCodecForObject<PrepareBankIntegratedWithdrawalRequest>() .property("talerWithdrawUri", codecForString()) - .property("selectedExchange", codecOptional(codecForString())) .build("PrepareBankIntegratedWithdrawalRequest"); export interface PrepareBankIntegratedWithdrawalResponse { - transactionId?: string; + transactionId: TransactionIdStr; info: WithdrawUriInfoResponse; } @@ -1883,6 +1889,13 @@ export interface AcceptBankIntegratedWithdrawalRequest { talerWithdrawUri: string; exchangeBaseUrl: string; forcedDenomSel?: ForcedDenomSel; + /** + * Amount to withdraw. + * If the bank's withdrawal operation uses a fixed amount, + * this field must either be left undefined or its value must match + * the amount from the withdrawal operation. + */ + amount?: AmountString; restrictAge?: number; } @@ -1892,6 +1905,7 @@ export const codecForAcceptBankIntegratedWithdrawalRequest = .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("talerWithdrawUri", codecForString()) .property("forcedDenomSel", codecForAny()) + .property("amount", codecOptional(codecForAmountString())) .property("restrictAge", codecOptional(codecForNumber())) .build("AcceptBankIntegratedWithdrawalRequest"); @@ -2047,7 +2061,7 @@ export interface CheckPayTemplateRequest { export type CheckPayTemplateReponse = { templateDetails: TalerMerchantApi.WalletTemplateDetails; supportedCurrencies: string[]; -} +}; export const codecForCheckPayTemplateRequest = (): Codec<CheckPayTemplateRequest> => @@ -2352,8 +2366,13 @@ export interface WithdrawUriInfoResponse { operationId: string; status: WithdrawalOperationStatus; confirmTransferUrl?: string; - amount: AmountString; + currency: string; + amount: AmountString | undefined; + editableAmount: boolean; + maxAmount: AmountString | undefined; + wireFee: AmountString | undefined; defaultExchangeBaseUrl?: string; + editableExchange: boolean; possibleExchanges: ExchangeListItem[]; } @@ -2371,7 +2390,12 @@ export const codecForWithdrawUriInfoResponse = codecForConstString("confirmed"), ), ) - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) + .property("maxAmount", codecOptional(codecForAmountString())) + .property("wireFee", codecOptional(codecForAmountString())) + .property("currency", codecForString()) + .property("editableAmount", codecForBoolean()) + .property("editableExchange", codecForBoolean()) .property("defaultExchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .property("possibleExchanges", codecForList(codecForExchangeListItem())) .build("WithdrawUriInfoResponse"); diff --git a/packages/taler-wallet-cli/debian/changelog b/packages/taler-wallet-cli/debian/changelog index e136caa61..5fa99e801 100644 --- a/packages/taler-wallet-cli/debian/changelog +++ b/packages/taler-wallet-cli/debian/changelog @@ -1,3 +1,27 @@ +taler-wallet-cli (0.11.4) unstable; urgency=low + + * Release 0.11.4 + + -- Florian Dold <dold@taler.net> Mon, 10 Jun 2024 19:57:55 +0200 + +taler-wallet-cli (0.11.3) unstable; urgency=low + + * Release 0.11.3 + + -- Florian Dold <dold@taler.net> Fri, 07 Jun 2024 19:12:44 +0200 + +taler-wallet-cli (0.11.2) unstable; urgency=low + + * Release 0.11.2 + + -- Florian Dold <dold@taler.net> Wed, 05 Jun 2024 20:17:56 +0200 + +taler-wallet-cli (0.11.1) unstable; urgency=low + + * Release 0.11.1 + + -- Florian Dold <dold@taler.net> Mon, 27 May 2024 14:46:35 -0600 + taler-wallet-cli (0.10.7) unstable; urgency=low * Release 0.10.7 diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json index 922556749..ecc8252e6 100644 --- a/packages/taler-wallet-cli/package.json +++ b/packages/taler-wallet-cli/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-cli", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index a1b008f5e..5bde7db01 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -1231,6 +1231,16 @@ advancedCli }); advancedCli + .subcommand("resetAllRetries", "reset-all-retries", { + help: "Reset all retry counters.", + }) + .action(async (args) => { + await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + await wallet.client.call(WalletApiOperation.TestingResetAllRetries, {}); + }); + }); + +advancedCli .subcommand("tasks", "tasks", { help: "Show active wallet-core tasks.", }) diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 46b3cef4e..c710861d3 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-core", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index 15904b470..09d5ae75d 100644 --- a/packages/taler-wallet-core/src/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -805,9 +805,10 @@ async function backupRecoveryTheirs( let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); - checkDbInvariant(!!backupStateEntry); + checkDbInvariant(!!backupStateEntry, `no backup entry`); checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, ); backupStateEntry.value.lastBackupNonce = undefined; backupStateEntry.value.lastBackupTimestamp = undefined; @@ -913,7 +914,10 @@ export async function provideBackupState( }, ); if (bs) { - checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); + checkDbInvariant( + bs.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, + ); return bs.value; } // We need to generate the key outside of the transaction @@ -941,6 +945,7 @@ export async function provideBackupState( } checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, ); return backupStateEntry.value; }); @@ -952,7 +957,10 @@ export async function getWalletBackupState( ): Promise<WalletBackupConfState> { const bs = await tx.config.get(ConfigRecordKey.WalletBackupState); checkDbInvariant(!!bs, "wallet backup state should be in DB"); - checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); + checkDbInvariant( + bs.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, + ); return bs.value; } @@ -962,7 +970,7 @@ export async function setWalletDeviceId( ): Promise<void> { await provideBackupState(wex); await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { - let backupStateEntry: ConfigRecord | undefined = await tx.config.get( + const backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); if ( diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts index 76e604324..381028906 100644 --- a/packages/taler-wallet-core/src/balance.ts +++ b/packages/taler-wallet-core/src/balance.ts @@ -69,8 +69,8 @@ import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js"; import { DepositOperationStatus, ExchangeEntryDbRecordStatus, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, PeerPushDebitStatus, RefreshGroupRecord, RefreshOperationStatus, @@ -304,8 +304,8 @@ export async function getBalancesInsideTransaction( const balanceStore: BalancesStore = new BalancesStore(wex, tx); const keyRangeActive = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.exchanges.iter().forEachAsync(async (ex) => { @@ -379,6 +379,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in kyc state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl); break; @@ -389,6 +393,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in aml state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingAml(currency, wg.exchangeBaseUrl); break; @@ -408,6 +416,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in confirmed state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingConfirmation( currency, diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index a60e41ecd..db6384c93 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -691,7 +691,7 @@ export function checkAccountRestriction( switch (myRestriction.type) { case "deny": return { ok: false }; - case "regex": + case "regex": { const regex = new RegExp(myRestriction.payto_regex); if (!regex.test(paytoUri)) { return { @@ -700,6 +700,7 @@ export function checkAccountRestriction( hintI18n: myRestriction.human_hint_i18n, }; } + } } } return { @@ -909,7 +910,7 @@ async function selectPayCandidates( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); if (denom.isRevoked) { logger.trace("denom is revoked"); continue; diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts index edaba5ba4..13c875575 100644 --- a/packages/taler-wallet-core/src/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -31,6 +31,8 @@ import { ExchangeUpdateStatus, Logger, RefreshReason, + TalerError, + TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, @@ -57,11 +59,11 @@ import { PurchaseRecord, RecoupGroupRecord, RefreshGroupRecord, - RewardRecord, WalletDbReadWriteTransaction, WithdrawalGroupRecord, timestampPreciseToDb, } from "./db.js"; +import { ReadyExchangeSummary } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; @@ -121,7 +123,10 @@ export async function makeCoinAvailable( coinRecord.exchangeBaseUrl, coinRecord.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinRecord.denomPubHash}`, + ); const ageRestriction = coinRecord.maxAge; let car = await tx.coinAvailability.get([ coinRecord.exchangeBaseUrl, @@ -175,13 +180,19 @@ export async function spendCoins( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coin.denomPubHash}`, + ); const coinAvailability = await tx.coinAvailability.get([ coin.exchangeBaseUrl, coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAvailability); + checkDbInvariant( + !!coinAvailability, + `age denom info is missing for ${coin.maxAge}`, + ); const contrib = csi.contributions[i]; if (coin.status !== CoinStatus.Fresh) { const alloc = coin.spendAllocation; @@ -213,7 +224,6 @@ export async function spendCoins( amount: Amounts.stringify(remaining.amount), coinPub: coin.coinPub, }); - checkDbInvariant(!!coinAvailability); if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, @@ -258,6 +268,9 @@ export enum TombstoneTag { export function getExchangeTosStatusFromRecord( exchange: ExchangeEntryRecord, ): ExchangeTosStatus { + if (exchange.tosCurrentEtag == null) { + return ExchangeTosStatus.MissingTos; + } if (!exchange.tosAcceptedEtag) { return ExchangeTosStatus.Proposed; } @@ -558,6 +571,28 @@ export function getAutoRefreshExecuteThreshold(d: { } /** + * Type and schema definitions for pending tasks in the wallet. + * + * These are only used internally, and are not part of the stable public + * interface to the wallet. + */ + +export enum PendingTaskType { + ExchangeUpdate = "exchange-update", + Purchase = "purchase", + Refresh = "refresh", + Recoup = "recoup", + RewardPickup = "reward-pickup", + Withdraw = "withdraw", + Deposit = "deposit", + Backup = "backup", + PeerPushDebit = "peer-push-debit", + PeerPullCredit = "peer-pull-credit", + PeerPushCredit = "peer-push-credit", + PeerPullDebit = "peer-pull-debit", +} + +/** * Parsed representation of task identifiers. */ export type ParsedTaskIdentifier = @@ -660,9 +695,6 @@ export namespace TaskIdentifiers { exchBaseUrl, )}` as TaskIdStr; } - export function forTipPickup(tipRecord: RewardRecord): TaskIdStr { - return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr; - } export function forRefresh( refreshGroupRecord: RefreshGroupRecord, ): TaskIdStr { @@ -747,28 +779,6 @@ export interface TransactionContext { deleteTransaction(): Promise<void>; } -/** - * Type and schema definitions for pending tasks in the wallet. - * - * These are only used internally, and are not part of the stable public - * interface to the wallet. - */ - -export enum PendingTaskType { - ExchangeUpdate = "exchange-update", - Purchase = "purchase", - Refresh = "refresh", - Recoup = "recoup", - RewardPickup = "reward-pickup", - Withdraw = "withdraw", - Deposit = "deposit", - Backup = "backup", - PeerPushDebit = "peer-push-debit", - PeerPullCredit = "peer-pull-credit", - PeerPushCredit = "peer-push-credit", - PeerPullDebit = "peer-pull-debit", -} - declare const __taskIdStr: unique symbol; export type TaskIdStr = string & { [__taskIdStr]: true }; @@ -799,7 +809,7 @@ export async function genericWaitForState( flag.raise(); } }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + const unregisterOnCancelled = wex.cancellationToken.onCancelled((reason) => { cancelNotif(); flag.raise(); }); @@ -819,5 +829,25 @@ export async function genericWaitForState( } catch (e) { unregisterOnCancelled(); cancelNotif(); + throw e; + } +} + +export function requireExchangeTosAcceptedOrThrow( + exchange: ReadyExchangeSummary, +): void { + switch (exchange.tosStatus) { + case ExchangeTosStatus.Accepted: + case ExchangeTosStatus.MissingTos: + break; + default: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, + { + exchangeBaseUrl: exchange.exchangeBaseUrl, + currentEtag: exchange.tosCurrentEtag, + tosStatus: exchange.tosStatus, + }, + ); } } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 44c241aed..5c381eea7 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -248,6 +248,9 @@ export function timestampOptionalAbsoluteFromDb( * 0x0103_nnnn: aborting * 0x0110_nnnn: suspended * 0x0113_nnnn: suspended-aborting + * a=2: finalizing + * 0x0200_nnnn: finalizing + * 0x0210_nnnn: suspended-finalizing * a=5: final * 0x0500_nnnn: done * 0x0501_nnnn: failed @@ -260,12 +263,12 @@ export function timestampOptionalAbsoluteFromDb( /** * First possible operation status in the active range (inclusive). */ -export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000; +export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000; /** * LAST possible operation status in the active range (inclusive). */ -export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff; +export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff; /** * Status of a withdrawal. @@ -395,6 +398,8 @@ export interface ReserveBankInfo { timestampBankConfirmed: DbPreciseTimestamp | undefined; wireTypes: string[] | undefined; + + currency: string | undefined; } /** @@ -918,92 +923,6 @@ export interface CoinAllocation { amount: AmountString; } -/** - * Status of a reward we got from a merchant. - */ -export interface RewardRecord { - /** - * Has the user accepted the tip? Only after the tip has been accepted coins - * withdrawn from the tip may be used. - */ - acceptedTimestamp: DbPreciseTimestamp | undefined; - - /** - * The tipped amount. - */ - rewardAmountRaw: AmountString; - - /** - * Effect on the balance (including fees etc). - */ - rewardAmountEffective: AmountString; - - /** - * Timestamp, the tip can't be picked up anymore after this deadline. - */ - rewardExpiration: DbProtocolTimestamp; - - /** - * The exchange that will sign our coins, chosen by the merchant. - */ - exchangeBaseUrl: string; - - /** - * Base URL of the merchant that is giving us the tip. - */ - merchantBaseUrl: string; - - /** - * Denomination selection made by the wallet for picking up - * this tip. - * - * FIXME: Put this into some DenomSelectionCacheRecord instead of - * storing it here! - */ - denomsSel: DenomSelectionState; - - denomSelUid: string; - - /** - * Tip ID chosen by the wallet. - */ - walletRewardId: string; - - /** - * Secret seed used to derive planchets for this tip. - */ - secretSeed: string; - - /** - * The merchant's identifier for this reward. - */ - merchantRewardId: string; - - createdTimestamp: DbPreciseTimestamp; - - /** - * The url to be redirected after the tip is accepted. - */ - next_url: string | undefined; - - /** - * Timestamp for when the wallet finished picking up the tip - * from the merchant. - */ - pickedUpTimestamp: DbPreciseTimestamp | undefined; - - status: RewardRecordStatus; -} - -export enum RewardRecordStatus { - PendingPickup = 0x0100_0000, - SuspendedPickup = 0x0110_0000, - DialogAccept = 0x0101_0000, - Done = 0x0500_0000, - Aborted = 0x0500_0000, - Failed = 0x0501_000, -} - export enum RefreshCoinStatus { Pending = 0x0100_0000, Finished = 0x0500_0000, @@ -1178,10 +1097,15 @@ export enum PurchaseStatus { /** * Query for refund (until auto-refund deadline is reached). + * + * Legacy state for compatibility. */ PendingQueryingAutoRefund = 0x0100_0004, SuspendedQueryingAutoRefund = 0x0110_0004, + FinalizingQueryingAutoRefund = 0x0200_0001, + SuspendedFinalizingQueryingAutoRefund = 0x0210_0001, + PendingAcceptRefund = 0x0100_0005, SuspendedPendingAcceptRefund = 0x0110_0005, @@ -1197,11 +1121,6 @@ export enum PurchaseStatus { DialogShared = 0x0101_0001, /** - * The user has rejected the proposal. - */ - AbortedProposalRefused = 0x0503_0000, - - /** * Downloading or processing the proposal has failed permanently. */ FailedClaim = 0x0501_0000, @@ -1224,13 +1143,18 @@ export enum PurchaseStatus { DoneRepurchaseDetected = 0x0500_0001, /** - * The payment has been aborted. + * The user has rejected the proposal. */ - AbortedIncompletePayment = 0x0503_0000, + AbortedProposalRefused = 0x0503_0000, AbortedRefunded = 0x0503_0001, AbortedOrderDeleted = 0x0503_0002, + + /** + * The payment has been aborted. + */ + AbortedIncompletePayment = 0x0503_0003, } /** @@ -1439,6 +1363,7 @@ export interface WgInfoBankIntegrated { * a Taler-integrated bank. */ bankInfo: ReserveBankInfo; + /** * Info about withdrawal accounts, possibly including currency conversion. */ @@ -1530,7 +1455,7 @@ export interface WithdrawalGroupRecord { * The exchange base URL that we're withdrawing from. * (Redundantly stored, as the reserve record also has this info.) */ - exchangeBaseUrl: string; + exchangeBaseUrl?: string; /** * When was the withdrawal operation started started? @@ -1976,7 +1901,7 @@ export enum PeerPullPaymentCreditStatus { SuspendedCreatePurse = 0x0110_0000, SuspendedReady = 0x0110_0001, SuspendedMergeKycRequired = 0x0110_0002, - SuspendedWithdrawing = 0x0110_0000, + SuspendedWithdrawing = 0x0110_0003, SuspendedAbortingDeletePurse = 0x0113_0000, @@ -2630,9 +2555,10 @@ export const WalletStoresV1 = { ]), }, ), + // Just a tombstone at this point. rewards: describeStore( "rewards", - describeContents<RewardRecord>({ keyPath: "walletRewardId" }), + describeContents<any>({ keyPath: "walletRewardId" }), { byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ "merchantRewardId", @@ -2940,6 +2866,8 @@ export interface DbDump { }; } +const logger = new Logger("db.ts"); + export async function exportSingleDb( idb: IDBFactory, dbName: string, @@ -3081,8 +3009,6 @@ export interface FixupDescription { */ export const walletDbFixups: FixupDescription[] = []; -const logger = new Logger("db.ts"); - export async function applyFixups( db: DbAccess<typeof WalletStoresV1>, ): Promise<void> { diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index d3085ecb4..ec9655e6f 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -123,7 +123,7 @@ export async function topupReserveWithBank(args: TopupReserveWithBankArgs) { ); const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri); const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri); - if (!bankInfo.suggestedExchange) { + if (!bankInfo.exchange) { throw Error("no suggested exchange"); } const plainPaytoUris = diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index c4cd98d73..2004c12cb 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -387,11 +387,19 @@ export function computeDepositTransactionActions( case DepositOperationStatus.Finished: return [TransactionAction.Delete]; case DepositOperationStatus.PendingDeposit: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case DepositOperationStatus.SuspendedDeposit: return [TransactionAction.Resume]; case DepositOperationStatus.Aborting: - return [TransactionAction.Fail, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Fail, + TransactionAction.Suspend, + ]; case DepositOperationStatus.Aborted: return [TransactionAction.Delete]; case DepositOperationStatus.Failed: @@ -399,9 +407,17 @@ export function computeDepositTransactionActions( case DepositOperationStatus.SuspendedAborting: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.PendingKyc: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case DepositOperationStatus.PendingTrack: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case DepositOperationStatus.SuspendedKyc: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.SuspendedTrack: @@ -441,7 +457,7 @@ async function refundDepositGroup( { storeNames: ["coins"] }, async (tx) => { const coinRecord = await tx.coins.get(coinPub); - checkDbInvariant(!!coinRecord); + checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`); return coinRecord.exchangeBaseUrl; }, ); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index d8063d561..dd88fa836 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -28,7 +28,6 @@ import { AgeRestriction, Amount, Amounts, - AsyncFlag, CancellationToken, CoinRefreshRequest, CoinStatus, @@ -53,6 +52,7 @@ import { GetExchangeResourcesResponse, GetExchangeTosResult, GlobalFees, + HttpStatusCode, LibtoolVersion, Logger, NotificationType, @@ -79,6 +79,7 @@ import { WireInfo, assertUnreachable, checkDbInvariant, + checkLogicInvariant, codecForExchangeKeysJson, durationMul, encodeCrock, @@ -93,6 +94,8 @@ import { getExpiry, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { PendingTaskType, @@ -103,6 +106,7 @@ import { TransactionContext, computeDbBackoff, constructTaskIdentifier, + genericWaitForState, getAutoRefreshExecuteThreshold, getExchangeEntryStatusFromRecord, getExchangeState, @@ -861,6 +865,41 @@ async function downloadExchangeKeysInfo( }; } +type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string }; + +/** + * Download metadata about an exchange's terms of service. + */ +async function downloadTosMeta( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TosMetaResult> { + logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`); + const reqUrl = new URL("terms", exchangeBaseUrl); + + // FIXME: We can/should make a HEAD request here. + // Not sure if qtart supports it at the moment. + const resp = await wex.http.fetch(reqUrl.href, { + cancellationToken: wex.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NotFound: + case HttpStatusCode.NotImplemented: + return { type: "not-found" }; + case HttpStatusCode.Ok: + break; + default: + throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp)); + } + + const etag = resp.headers.get("etag") || "unknown"; + return { + type: "ok", + etag, + }; +} + async function downloadTosFromAcceptedFormat( wex: WalletExecutionContext, baseUrl: string, @@ -977,9 +1016,7 @@ async function startUpdateExchangeEntry( wex.ws.exchangeCache.clear(); await tx.exchanges.put(r); const newExchangeState = getExchangeState(r); - // Reset retries for updating the exchange entry. const taskId = TaskIdentifiers.forExchangeUpdate(r); - await tx.operationRetries.delete(taskId); return { oldExchangeState, newExchangeState, taskId }; }, ); @@ -989,6 +1026,8 @@ async function startUpdateExchangeEntry( newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, }); + logger.info(`start update ${exchangeBaseUrl} task ${taskId}`); + await wex.taskScheduler.resetTaskRetries(taskId); } @@ -1008,132 +1047,6 @@ export interface ReadyExchangeSummary { scopeInfo: ScopeInfo; } -async function internalWaitReadyExchange( - wex: WalletExecutionContext, - canonUrl: string, - exchangeNotifFlag: AsyncFlag, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - const operationId = constructTaskIdentifier({ - tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, - }); - while (true) { - if (wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - logger.info(`waiting for ready exchange ${canonUrl}`); - const { exchange, exchangeDetails, retryInfo, scopeInfo } = - await wex.db.runReadOnlyTx( - { - storeNames: [ - "exchanges", - "exchangeDetails", - "operationRetries", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - ], - }, - async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeRecordsInternal( - tx, - canonUrl, - ); - const retryInfo = await tx.operationRetries.get(operationId); - let scopeInfo: ScopeInfo | undefined = undefined; - if (exchange && exchangeDetails) { - scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); - } - return { exchange, exchangeDetails, retryInfo, scopeInfo }; - }, - ); - - if (!exchange) { - throw Error("exchange entry does not exist anymore"); - } - - let ready = false; - - switch (exchange.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - ready = true; - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - // If the update is forced, - // we wait until we're in a full "ready" state, - // as we're not happy with the stale information. - if (!options.forceUpdate) { - ready = true; - } - break; - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - default: { - if (retryInfo) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - } - } - } - - if (!ready) { - logger.info("waiting for exchange update notification"); - await exchangeNotifFlag.wait(); - logger.info("done waiting for exchange update notification"); - exchangeNotifFlag.reset(); - continue; - } - - if (!exchangeDetails) { - throw Error("invariant failed"); - } - - if (!scopeInfo) { - throw Error("invariant failed"); - } - - const res: ReadyExchangeSummary = { - currency: exchangeDetails.currency, - exchangeBaseUrl: canonUrl, - masterPub: exchangeDetails.masterPublicKey, - tosStatus: getExchangeTosStatusFromRecord(exchange), - tosAcceptedEtag: exchange.tosAcceptedEtag, - wireInfo: exchangeDetails.wireInfo, - protocolVersionRange: exchangeDetails.protocolVersionRange, - tosCurrentEtag: exchange.tosCurrentEtag, - tosAcceptedTimestamp: timestampOptionalPreciseFromDb( - exchange.tosAcceptedTimestamp, - ), - scopeInfo, - }; - - if (options.expectedMasterPub) { - if (res.masterPub !== options.expectedMasterPub) { - throw Error( - "public key of the exchange does not match expected public key", - ); - } - } - return res; - } -} - /** * Ensure that a fresh exchange entry exists for the given * exchange base URL. @@ -1155,6 +1068,8 @@ export async function fetchFreshExchange( forceUpdate?: boolean; } = {}, ): Promise<ReadyExchangeSummary> { + logger.info(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); + if (!options.forceUpdate) { const cachedResp = wex.ws.exchangeCache.get(baseUrl); if (cachedResp) { @@ -1184,39 +1099,131 @@ async function waitReadyExchange( } = {}, ): Promise<ReadyExchangeSummary> { logger.trace(`waiting for exchange ${canonUrl} to become ready`); - // FIXME: We should use Symbol.dispose magic here for cleanup! - const exchangeNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.ExchangeStateTransition && - notif.exchangeBaseUrl === canonUrl - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - exchangeNotifFlag.raise(); - } + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - exchangeNotifFlag.raise(); + let res: ReadyExchangeSummary | undefined = undefined; + + await genericWaitForState(wex, { + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ); + }, + async checkState(): Promise<boolean> { + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(canonUrl); + const exchangeDetails = await getExchangeRecordsInternal( + tx, + canonUrl, + ); + const retryInfo = await tx.operationRetries.get(operationId); + let scopeInfo: ScopeInfo | undefined = undefined; + if (exchange && exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo( + tx, + exchangeDetails, + ); + } + return { exchange, exchangeDetails, retryInfo, scopeInfo }; + }, + ); + + if (!exchange) { + throw Error("exchange entry does not exist anymore"); + } + + let ready = false; + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + ready = true; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + // If the update is forced, + // we wait until we're in a full "ready" state, + // as we're not happy with the stale information. + if (!options.forceUpdate) { + ready = true; + } + break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + default: { + if (retryInfo) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + } + } + } + + if (!ready) { + return false; + } + + if (!exchangeDetails) { + throw Error("invariant failed"); + } + + if (!scopeInfo) { + throw Error("invariant failed"); + } + + const mySummary: ReadyExchangeSummary = { + currency: exchangeDetails.currency, + exchangeBaseUrl: canonUrl, + masterPub: exchangeDetails.masterPublicKey, + tosStatus: getExchangeTosStatusFromRecord(exchange), + tosAcceptedEtag: exchange.tosAcceptedEtag, + wireInfo: exchangeDetails.wireInfo, + protocolVersionRange: exchangeDetails.protocolVersionRange, + tosCurrentEtag: exchange.tosCurrentEtag, + tosAcceptedTimestamp: timestampOptionalPreciseFromDb( + exchange.tosAcceptedTimestamp, + ), + scopeInfo, + }; + + if (options.expectedMasterPub) { + if (mySummary.masterPub !== options.expectedMasterPub) { + throw Error( + "public key of the exchange does not match expected public key", + ); + } + } + res = mySummary; + return true; + }, }); - try { - const res = await internalWaitReadyExchange( - wex, - canonUrl, - exchangeNotifFlag, - options, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - unregisterOnCancelled(); - cancelNotif(); - } + checkLogicInvariant(!!res); + return res; } function checkPeerPaymentsDisabled( @@ -1359,7 +1366,6 @@ export async function updateExchangeFromUrlHandler( ); refreshCheckNecessary = false; } - if (!(updateNecessary || refreshCheckNecessary)) { logger.trace("update not necessary, running again later"); return TaskRunResult.runAgainAt( @@ -1421,15 +1427,7 @@ export async function updateExchangeFromUrlHandler( logger.trace("finished validating exchange /wire info"); - // We download the text/plain version here, - // because that one needs to exist, and we - // will get the current etag from the response. - const tosDownload = await downloadTosFromAcceptedFormat( - wex, - exchangeBaseUrl, - timeout, - ["text/plain"], - ); + const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl); logger.trace("updating exchange info in database"); @@ -1522,7 +1520,14 @@ export async function updateExchangeFromUrlHandler( }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; - r.tosCurrentEtag = tosDownload.tosEtag; + switch (tosMeta.type) { + case "not-found": + r.tosCurrentEtag = undefined; + break; + case "ok": + r.tosCurrentEtag = tosMeta.etag; + break; + } if (existingDetails?.rowId) { newDetails.rowId = existingDetails.rowId; } @@ -1548,7 +1553,10 @@ export async function updateExchangeFromUrlHandler( r.cachebreakNextUpdate = false; await tx.exchanges.put(r); const drRowId = await tx.exchangeDetails.put(newDetails); - checkDbInvariant(typeof drRowId.key === "number"); + checkDbInvariant( + typeof drRowId.key === "number", + "exchange details key is not a number", + ); for (const sk of keysInfo.signingKeys) { // FIXME: validate signing keys before inserting them @@ -2227,10 +2235,12 @@ export async function markExchangeUsed( logger.info(`marking exchange ${exchangeBaseUrl} as used`); const exch = await tx.exchanges.get(exchangeBaseUrl); if (!exch) { + logger.info(`exchange ${exchangeBaseUrl} NOT found`); return { notif: undefined, }; } + const oldExchangeState = getExchangeState(exch); switch (exch.entryStatus) { case ExchangeEntryDbRecordStatus.Ephemeral: diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts index 1f7d95959..5b399a0a7 100644 --- a/packages/taler-wallet-core/src/instructedAmountConversion.ts +++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -283,7 +283,7 @@ async function getAvailableDenoms( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); if (denom.isRevoked || !denom.isOffered) { continue; } @@ -472,7 +472,7 @@ export async function getMaxDepositAmount( export function getMaxDepositAmountForAvailableCoins( denoms: AvailableCoins, currency: string, -) { +): AmountWithFee { const zero = Amounts.zeroOfCurrency(currency); if (!denoms.list.length) { // no coins in the database @@ -663,8 +663,13 @@ function rankDenominationForWithdrawals( //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; + + const rate1 = Amounts.isZero(d1.denomWithdraw) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d1.value, d1.denomWithdraw).quotient; + const rate2 = Amounts.isZero(d2.denomWithdraw) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d2.value, d2.denomWithdraw).quotient; const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; return ( contribCmp || @@ -719,8 +724,13 @@ function rankDenominationForDeposit( //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const rate1 = Amounts.isZero(d1.denomDeposit) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d1.value, d1.denomDeposit).quotient; + const rate2 = Amounts.isZero(d2.denomDeposit) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; return ( contribCmp || diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 090a11cf0..ee154252f 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -34,7 +34,6 @@ import { assertUnreachable, AsyncFlag, checkDbInvariant, - CheckPaymentResponse, CheckPayTemplateReponse, CheckPayTemplateRequest, codecForAbortResponse, @@ -342,7 +341,6 @@ export class PayMerchantTransactionContext implements TransactionContext { return; } await tx.purchases.put(purchase); - await tx.operationRetries.delete(this.taskId); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }, @@ -1028,11 +1026,17 @@ async function storeFirstPaySuccess( purchase.merchantPaySig = payResponse.sig; purchase.posConfirmation = payResponse.pos_confirmation; const dl = purchase.download; - checkDbInvariant(!!dl); + checkDbInvariant( + !!dl, + `purchase ${purchase.orderId} without ct downloaded`, + ); const contractTermsRecord = await tx.contractTerms.get( dl.contractTermsHash, ); - checkDbInvariant(!!contractTermsRecord); + checkDbInvariant( + !!contractTermsRecord, + `no contract terms found for purchase ${purchase.orderId}`, + ); const contractData = extractContractData( contractTermsRecord.contractTermsRaw, dl.contractTermsHash, @@ -1625,6 +1629,9 @@ export async function checkPayForTemplate( throw TalerError.fromUncheckedDetail(cfg.detail); } + // FIXME: Put body.currencies *and* body.currency in the set of + // supported currencies. + return { templateDetails, supportedCurrencies: Object.keys(cfg.body.currencies), @@ -2086,6 +2093,7 @@ export async function processPurchase( case PurchaseStatus.PendingPayingReplay: return processPurchasePay(wex, proposalId); case PurchaseStatus.PendingQueryingRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: return processPurchaseQueryRefund(wex, purchase); case PurchaseStatus.PendingQueryingAutoRefund: return processPurchaseAutoRefund(wex, purchase); @@ -2110,6 +2118,7 @@ export async function processPurchase( case PurchaseStatus.SuspendedPendingAcceptRefund: case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingRefund: + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: case PurchaseStatus.FailedAbort: case PurchaseStatus.FailedPaidByOther: return TaskRunResult.finished(); @@ -2155,7 +2164,7 @@ async function processPurchasePay( logger.trace(`paying with session ID ${sessionId}`); const payInfo = purchase.payInfo; - checkDbInvariant(!!payInfo, "payInfo"); + checkDbInvariant(!!payInfo, `purchase ${purchase.orderId} without payInfo`); const download = await expectProposalDownload(wex, purchase); @@ -2487,6 +2496,9 @@ const transitionSuspend: { [PurchaseStatus.PendingQueryingAutoRefund]: { next: PurchaseStatus.SuspendedQueryingAutoRefund, }, + [PurchaseStatus.FinalizingQueryingAutoRefund]: { + next: PurchaseStatus.SuspendedFinalizingQueryingAutoRefund, + }, }; const transitionResume: { @@ -2509,6 +2521,9 @@ const transitionResume: { [PurchaseStatus.SuspendedQueryingAutoRefund]: { next: PurchaseStatus.PendingQueryingAutoRefund, }, + [PurchaseStatus.SuspendedFinalizingQueryingAutoRefund]: { + next: PurchaseStatus.FinalizingQueryingAutoRefund, + }, }; export function computePayMerchantTransactionState( @@ -2637,6 +2652,16 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Failed, minor: TransactionMinorState.PaidByOther, }; + case PurchaseStatus.FinalizingQueryingAutoRefund: + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.AutoRefund, + }; + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + return { + major: TransactionMajorState.SuspendedFinalizing, + minor: TransactionMinorState.AutoRefund, + }; default: assertUnreachable(purchaseRecord.purchaseStatus); } @@ -2648,21 +2673,45 @@ export function computePayMerchantTransactionActions( switch (purchaseRecord.purchaseStatus) { // Pending States case PurchaseStatus.PendingDownloadingProposal: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingPaying: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingPayingReplay: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingQueryingAutoRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingQueryingRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingAcceptRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; // Suspended Pending States case PurchaseStatus.SuspendedDownloadingProposal: return [TransactionAction.Resume, TransactionAction.Abort]; @@ -2682,14 +2731,18 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Resume, TransactionAction.Abort]; // Aborting States case PurchaseStatus.AbortingWithRefund: - return [TransactionAction.Fail, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Fail, + TransactionAction.Suspend, + ]; case PurchaseStatus.SuspendedAbortingWithRefund: return [TransactionAction.Fail, TransactionAction.Resume]; // Dialog States case PurchaseStatus.DialogProposed: - return []; + return [TransactionAction.Retry]; case PurchaseStatus.DialogShared: - return []; + return [TransactionAction.Retry]; // Final States case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedOrderDeleted: @@ -2707,6 +2760,14 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Delete]; case PurchaseStatus.FailedPaidByOther: return [TransactionAction.Delete]; + case PurchaseStatus.FinalizingQueryingAutoRefund: + return [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Delete, + ]; + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + return [TransactionAction.Resume, TransactionAction.Delete]; default: assertUnreachable(purchaseRecord.purchaseStatus); } @@ -2909,8 +2970,12 @@ async function processPurchaseAutoRefund( logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { - return; + switch (p.purchaseStatus) { + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: + break; + default: + return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.Done; @@ -2956,8 +3021,12 @@ async function processPurchaseAutoRefund( logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { - return; + switch (p.purchaseStatus) { + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: + break; + default: + return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; @@ -2997,7 +3066,7 @@ async function processPurchaseAbortingRefund( for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; const coin = await tx.coins.get(coinPub); - checkDbInvariant(!!coin, "expected coin to be present"); + checkDbInvariant(!!coin, `coin not found for ${coinPub}`); abortingCoins.push({ coin_pub: coinPub, contribution: Amounts.stringify(payCoinSelection.coinContributions[i]), @@ -3501,7 +3570,8 @@ async function storeRefunds( if (isAborting) { myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; } else if (shouldCheckAutoRefund) { - myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; + myPurchase.purchaseStatus = + PurchaseStatus.FinalizingQueryingAutoRefund; } else { myPurchase.purchaseStatus = PurchaseStatus.Done; } diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts index bfd39b657..a1729ced7 100644 --- a/packages/taler-wallet-core/src/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -140,10 +140,10 @@ export async function getMergeReserveInfo( { storeNames: ["exchanges", "reserves"] }, async (tx) => { const ex = await tx.exchanges.get(req.exchangeBaseUrl); - checkDbInvariant(!!ex); + checkDbInvariant(!!ex, `no exchange record for ${req.exchangeBaseUrl}`); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); - checkDbInvariant(!!reserve); + checkDbInvariant(!!reserve, `reserver ${ex.currentMergeReserveRowId} missing in db`); return reserve; } const reserve: ReserveRecord = { @@ -151,7 +151,7 @@ export async function getMergeReserveInfo( reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); - checkDbInvariant(typeof insertResp.key === "number"); + checkDbInvariant(typeof insertResp.key === "number", `reserve key is not a number`); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts index 840c244d0..3e7fdd36b 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -59,6 +59,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -933,6 +934,11 @@ export async function checkPeerPullPaymentInitiation( Amounts.parseOrThrow(req.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to check pull payment from ${exchangeUrl}, can't select denominations for instructed amount (${req.amount}`, + ); + } logger.trace(`got withdrawal info`); @@ -1021,7 +1027,8 @@ export async function initiatePeerPullPayment( const exchangeBaseUrl = maybeExchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, @@ -1039,7 +1046,10 @@ export async function initiatePeerPullPayment( const withdrawalGroupId = encodeCrock(getRandomBytes(32)); const mergeReserveRowId = mergeReserveInfo.rowId; - checkDbInvariant(!!mergeReserveRowId); + checkDbInvariant( + !!mergeReserveRowId, + `merge reserve for ${exchangeBaseUrl} without rowid`, + ); const contractEncNonce = encodeCrock(getRandomBytes(24)); @@ -1049,6 +1059,11 @@ export async function initiatePeerPullPayment( Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to initiate pull payment from ${exchangeBaseUrl}, can't select denominations for instructed amount (${req.partialContractTerms.amount}`, + ); + } const mergeTimestamp = TalerPreciseTimestamp.now(); @@ -1184,15 +1199,31 @@ export function computePeerPullCreditTransactionActions( ): TransactionAction[] { switch (pullCreditRecord.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.Done: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.PendingWithdrawing: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPullPaymentCreditStatus.SuspendedReady: @@ -1204,7 +1235,11 @@ export function computePeerPullCreditTransactionActions( case PeerPullPaymentCreditStatus.Aborted: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPullPaymentCreditStatus.Failed: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.Expired: diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts index 0355b58ad..e9be15026 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -1000,7 +1000,7 @@ export function computePeerPullDebitTransactionActions( ): TransactionAction[] { switch (pullDebitRecord.status) { case PeerPullDebitRecordStatus.DialogProposed: - return []; + return [TransactionAction.Retry, TransactionAction.Delete]; case PeerPullDebitRecordStatus.PendingDeposit: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullDebitRecordStatus.Done: diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts index 93f1a63a7..5a1bfbdbd 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -61,6 +61,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -407,7 +408,8 @@ export async function preparePeerPushCredit( const exchangeBaseUrl = uri.exchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); @@ -459,6 +461,12 @@ export async function preparePeerPushCredit( undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to prepare push credit from ${exchangeBaseUrl}, can't select denominations for instructed amount (${purseStatus.balance}`, + ); + } + const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { @@ -872,7 +880,10 @@ export async function processPeerPushCredit( `processing peerPushCredit in state ${peerInc.status.toString(16)}`, ); - checkDbInvariant(!!contractTerms); + checkDbInvariant( + !!contractTerms, + `not contract terms for peer push ${peerPushCreditId}`, + ); switch (peerInc.status) { case PeerPushCreditStatus.PendingMergeKycRequired: { @@ -1011,15 +1022,27 @@ export function computePeerPushCreditTransactionActions( ): TransactionAction[] { switch (pushCreditRecord.status) { case PeerPushCreditStatus.DialogProposed: - return [TransactionAction.Delete]; + return [TransactionAction.Retry, TransactionAction.Delete]; case PeerPushCreditStatus.PendingMerge: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushCreditStatus.Done: return [TransactionAction.Delete]; case PeerPushCreditStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushCreditStatus.PendingWithdrawing: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushCreditStatus.SuspendedMerge: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPushCreditStatus.SuspendedMergeKycRequired: diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index 6452407ff..f8e6adb3c 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -406,7 +406,10 @@ async function handlePurseCreationConflict( const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount); const sel = peerPushInitiation.coinSel; - checkDbInvariant(!!sel); + checkDbInvariant( + !!sel, + `no coin selected for peer push initiation ${peerPushInitiation.pursePub}`, + ); const repair: PreviousPayCoins = []; @@ -1218,17 +1221,37 @@ export function computePeerPushDebitTransactionActions( ): TransactionAction[] { switch (ppiRecord.status) { case PeerPushDebitStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushDebitStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushDebitStatus.Aborted: return [TransactionAction.Delete]; case PeerPushDebitStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.AbortingRefreshDeleted: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.AbortingRefreshExpired: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: return [TransactionAction.Resume, TransactionAction.Fail]; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts index 6a09f9a0e..be5731b0b 100644 --- a/packages/taler-wallet-core/src/recoup.ts +++ b/packages/taler-wallet-core/src/recoup.ts @@ -199,8 +199,8 @@ async function recoupRefreshCoin( revokedCoin.exchangeBaseUrl, revokedCoin.denomPubHash, ); - checkDbInvariant(!!oldCoinDenom); - checkDbInvariant(!!revokedCoinDenom); + checkDbInvariant(!!oldCoinDenom, `no denom for coin, hash ${oldCoin.denomPubHash}`); + checkDbInvariant(!!revokedCoinDenom, `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`); revokedCoin.status = CoinStatus.Dormant; if (!revokedCoin.spendAllocation) { // We don't know what happened to this coin diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts index 7800967e6..05c65f6b6 100644 --- a/packages/taler-wallet-core/src/refresh.ts +++ b/packages/taler-wallet-core/src/refresh.ts @@ -29,7 +29,6 @@ import { Amounts, amountToPretty, assertUnreachable, - AsyncFlag, checkDbInvariant, codecForCoinHistoryResponse, codecForExchangeMeltResponse, @@ -68,12 +67,14 @@ import { WalletNotification, } from "@gnu-taler/taler-util"; import { + HttpResponse, readSuccessResponseJsonOrThrow, readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { constructTaskIdentifier, + genericWaitForState, makeCoinsVisible, PendingTaskType, TaskIdStr, @@ -386,7 +387,6 @@ async function getCoinAvailabilityForDenom( denom: DenominationInfo, ageRestriction: number, ): Promise<CoinAvailabilityRecord> { - checkDbInvariant(!!denom); let car = await tx.coinAvailability.get([ denom.exchangeBaseUrl, denom.denomPubHash, @@ -537,7 +537,10 @@ async function destroyRefreshSession( denom, oldCoin.maxAge, ); - checkDbInvariant(car.pendingRefreshOutputCount != null); + checkDbInvariant( + car.pendingRefreshOutputCount != null, + `no pendingRefreshOutputCount for denom ${dph}`, + ); car.pendingRefreshOutputCount = car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count; await tx.coinAvailability.put(car); @@ -693,7 +696,7 @@ async function refreshMelt( switch (resp.status) { case HttpStatusCode.NotFound: { const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltNotFound(ctx, coinIndex, errDetail); + await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); return; } case HttpStatusCode.Gone: { @@ -898,9 +901,18 @@ async function handleRefreshMeltConflict( async function handleRefreshMeltNotFound( ctx: RefreshTransactionContext, coinIndex: number, + resp: HttpResponse, errDetails: TalerErrorDetail, ): Promise<void> { - // FIXME: Validate the exchange's error response + // Make sure that we only act on a 404 that indicates a final problem + // with the coin. + switch (errDetails.code) { + case TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN: + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN: + break; + default: + throwUnexpectedRequestError(resp, errDetails); + } await ctx.wex.db.runReadWriteTx( { storeNames: [ @@ -1242,7 +1254,10 @@ async function refreshReveal( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denomInfo); + checkDbInvariant( + !!denomInfo, + `no denom with hash ${coin.denomPubHash}`, + ); const car = await getCoinAvailabilityForDenom( wex, tx, @@ -1252,6 +1267,7 @@ async function refreshReveal( checkDbInvariant( car.pendingRefreshOutputCount != null && car.pendingRefreshOutputCount > 0, + `no pendingRefreshOutputCount for denom ${coin.denomPubHash} age ${coin.maxAge}`, ); car.pendingRefreshOutputCount--; car.freshCoinCount++; @@ -1559,9 +1575,22 @@ async function applyRefreshToOldCoins( coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAv); - checkDbInvariant(coinAv.freshCoinCount > 0); + checkDbInvariant( + !!coinAv, + `no denom info for ${coin.denomPubHash} age ${coin.maxAge}`, + ); + checkDbInvariant( + coinAv.freshCoinCount > 0, + `no fresh coins for ${coin.denomPubHash}`, + ); coinAv.freshCoinCount--; + if (coin.visible) { + if (!coinAv.visibleCoinCount) { + logger.error("coin availability inconsistent"); + } else { + coinAv.visibleCoinCount--; + } + } await tx.coinAvailability.put(coinAv); break; } @@ -1770,7 +1799,7 @@ export async function forceRefresh( ], }, async (tx) => { - let coinPubs: CoinRefreshRequest[] = []; + const coinPubs: CoinRefreshRequest[] = []; for (const c of req.refreshCoinSpecs) { const coin = await tx.coins.get(c.coinPub); if (!coin) { @@ -1782,7 +1811,7 @@ export async function forceRefresh( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `no denom hash: ${coin.denomPubHash}`); coinPubs.push({ coinPub: c.coinPub, amount: c.amount ?? denom.value, @@ -1818,66 +1847,38 @@ export async function waitRefreshFinal( const ctx = new RefreshTransactionContext(wex, refreshGroupId); wex.taskScheduler.startShepherdTask(ctx.taskId); - // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. - const refreshNotifFlag = new AsyncFlag(); - // Raise purchaseNotifFlag whenever we get a notification - // about our refresh. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - refreshNotifFlag.raise(); - } - }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - refreshNotifFlag.raise(); + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + // Check if refresh is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups"] }, + async (tx) => { + return { + rg: await tx.refreshGroups.get(ctx.refreshGroupId), + }; + }, + ); + const { rg } = res; + if (!rg) { + // Must've been deleted, we consider that final. + return true; + } + switch (rg.operationStatus) { + case RefreshOperationStatus.Failed: + case RefreshOperationStatus.Finished: + // Transaction is final + return true; + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + } + return false; + }, + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, }); - - try { - await internalWaitRefreshFinal(ctx, refreshNotifFlag); - } catch (e) { - unregisterOnCancelled(); - cancelNotif(); - } -} - -async function internalWaitRefreshFinal( - ctx: RefreshTransactionContext, - flag: AsyncFlag, -): Promise<void> { - while (true) { - if (ctx.wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - - // Check if refresh is final - const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["refreshGroups", "operationRetries"] }, - async (tx) => { - return { - rg: await tx.refreshGroups.get(ctx.refreshGroupId), - }; - }, - ); - const { rg } = res; - if (!rg) { - // Must've been deleted, we consider that final. - return; - } - switch (rg.operationStatus) { - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: - // Transaction is final - return; - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.Suspended: - break; - } - - // Wait for the next transition - await flag.wait(); - flag.reset(); - } } diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts index 3b160d97f..470f45aff 100644 --- a/packages/taler-wallet-core/src/shepherd.ts +++ b/packages/taler-wallet-core/src/shepherd.ts @@ -50,12 +50,13 @@ import { parseTaskIdentifier, } from "./common.js"; import { - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, WalletDbAllStoresReadOnlyTransaction, WalletDbReadOnlyTransaction, timestampAbsoluteFromDb, + timestampPreciseToDb, } from "./db.js"; import { computeDepositTransactionStatus, @@ -113,6 +114,8 @@ const logger = new Logger("shepherd.ts"); */ interface ShepherdInfo { cts: CancellationToken.Source; + latch?: Promise<void>; + stopped: boolean; } /** @@ -256,29 +259,36 @@ export class TaskSchedulerImpl implements TaskScheduler { async reload(): Promise<void> { await this.ensureRunning(); const tasksIds = [...this.sheps.keys()]; - logger.info(`reloading sheperd with ${tasksIds.length} tasks`); + logger.info(`reloading shepherd with ${tasksIds.length} tasks`); for (const taskId of tasksIds) { - this.stopShepherdTask(taskId); + await this.stopShepherdTask(taskId); } for (const taskId of tasksIds) { this.startShepherdTask(taskId); } } - private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> { logger.trace(`Starting to shepherd task ${taskId}`); const oldShep = this.sheps.get(taskId); if (oldShep) { - logger.trace(`Already have a shepherd for ${taskId}`); - return; + if (!oldShep.stopped) { + logger.trace(`Already have a shepherd for ${taskId}`); + return; + } + logger.trace( + `Waiting old task to complete the loop in cancel mode ${taskId}`, + ); + await oldShep.latch; } logger.trace(`Creating new shepherd for ${taskId}`); const newShep: ShepherdInfo = { cts: CancellationToken.create(), + stopped: false, }; this.sheps.set(taskId, newShep); try { - await this.internalShepherdTask(taskId, newShep); + newShep.latch = this.internalShepherdTask(taskId, newShep); + await newShep.latch; } finally { logger.trace(`Done shepherding ${taskId}`); this.sheps.delete(taskId); @@ -291,8 +301,8 @@ export class TaskSchedulerImpl implements TaskScheduler { const oldShep = this.sheps.get(taskId); if (oldShep) { logger.trace(`Cancelling old shepherd for ${taskId}`); - oldShep.cts.cancel(); - this.sheps.delete(taskId); + oldShep.cts.cancel(`stopping task ${taskId}`); + oldShep.stopped = true; this.iterCond.trigger(); } } @@ -306,6 +316,7 @@ export class TaskSchedulerImpl implements TaskScheduler { const maybeNotification = await this.ws.db.runAllStoresReadWriteTx( {}, async (tx) => { + logger.trace(`storing task [reset] for ${taskId}`); await tx.operationRetries.delete(taskId); return taskToRetryNotification(this.ws, tx, taskId, undefined); }, @@ -325,7 +336,13 @@ export class TaskSchedulerImpl implements TaskScheduler { try { await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay)); } catch (e) { - logger.info(`waiting for ${taskId} interrupted`); + if (e instanceof CancellationToken.CancellationError) { + logger.info( + `waiting for ${taskId} interrupted: ${e.message} ${j2s(e.reason)}`, + ); + } else { + logger.info(`waiting for ${taskId} interrupted: ${e}`); + } } } @@ -363,13 +380,14 @@ export class TaskSchedulerImpl implements TaskScheduler { try { res = await callOperationHandlerForTaskId(wex, taskId); } catch (e) { + logger.trace(`Shepherd error ${taskId} saving response ${e}`); res = { type: TaskRunResultType.Error, errorDetail: getErrorDetailFromException(e), }; } if (info.cts.token.isCancelled) { - logger.trace("task cancelled, not processing result"); + logger.trace(`task ${taskId} cancelled, not processing result`); return; } if (this.ws.stopped) { @@ -382,7 +400,9 @@ export class TaskSchedulerImpl implements TaskScheduler { }); switch (res.type) { case TaskRunResultType.Error: { - logger.trace(`Shepherd for ${taskId} got error result.`); + logger.trace( + `Shepherd for ${taskId} got error result: ${j2s(res.errorDetail)}`, + ); const retryRecord = await storePendingTaskError( this.ws, taskId, @@ -412,8 +432,13 @@ export class TaskSchedulerImpl implements TaskScheduler { } case TaskRunResultType.ScheduleLater: { logger.trace(`Shepherd for ${taskId} got schedule-later result.`); - await storeTaskProgress(this.ws, taskId); - const delay = AbsoluteTime.remaining(res.runAt); + const retryRecord = await storePendingTaskPending( + this.ws, + taskId, + res.runAt, + ); + const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry); + const delay = AbsoluteTime.remaining(t); logger.trace(`Waiting for ${delay.d_ms} ms`); await this.wait(taskId, info, delay); break; @@ -451,7 +476,7 @@ async function storePendingTaskError( pendingTaskId: string, e: TalerErrorDetail, ): Promise<OperationRetryRecord> { - logger.info(`storing pending task error for ${pendingTaskId}`); + logger.trace(`storing task [pending] with ERROR for ${pendingTaskId}`); const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); if (!retryRecord) { @@ -483,6 +508,7 @@ async function storeTaskProgress( ws: InternalWalletState, pendingTaskId: string, ): Promise<void> { + logger.trace(`storing task [progress] for ${pendingTaskId}`); await ws.db.runReadWriteTx( { storeNames: ["operationRetries"] }, async (tx) => { @@ -494,7 +520,9 @@ async function storeTaskProgress( async function storePendingTaskPending( ws: InternalWalletState, pendingTaskId: string, + schedTime?: AbsoluteTime, ): Promise<OperationRetryRecord> { + logger.trace(`storing task [pending] for ${pendingTaskId}`); const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); let hadError = false; @@ -510,6 +538,11 @@ async function storePendingTaskPending( delete retryRecord.lastError; retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); } + if (schedTime) { + retryRecord.retryInfo.nextRetry = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(schedTime), + ); + } await tx.operationRetries.put(retryRecord); let notification: WalletNotification | undefined = undefined; if (hadError) { @@ -535,6 +568,7 @@ async function storePendingTaskFinished( ws: InternalWalletState, pendingTaskId: string, ): Promise<void> { + logger.trace(`storing task [finished] for ${pendingTaskId}`); await ws.db.runReadWriteTx( { storeNames: ["operationRetries"] }, async (tx) => { @@ -978,8 +1012,8 @@ export async function getActiveTaskIds( }, async (tx) => { const active = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); // Withdrawals diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 9a9fb524f..7782d09ba 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -62,8 +62,8 @@ import { DenomLossEventRecord, DepositElementStatus, DepositGroupRecord, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, PeerPullCreditRecord, PeerPullDebitRecordStatus, @@ -93,7 +93,6 @@ import { computeDenomLossTransactionStatus, DenomLossTransactionContext, ExchangeWireDetails, - fetchFreshExchange, getExchangeWireDetailsInTx, } from "./exchanges.js"; import { @@ -244,11 +243,14 @@ export async function getTransactionById( const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - withdrawalGroupRecord.exchangeBaseUrl, - ); - if (!exchangeDetails) throw Error("not exchange details"); + const exchangeDetails = + withdrawalGroupRecord.exchangeBaseUrl === undefined + ? undefined + : await getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + // if (!exchangeDetails) throw Error("not exchange details"); if ( withdrawalGroupRecord.wgInfo.withdrawalType === @@ -260,7 +262,10 @@ export async function getTransactionById( ort, ); } - + checkDbInvariant( + exchangeDetails !== undefined, + "manual withdrawal without exchange", + ); return buildTransactionForManualWithdraw( withdrawalGroupRecord, exchangeDetails, @@ -405,7 +410,10 @@ export async function getTransactionById( const debit = await tx.peerPushDebit.get(parsedTx.pursePub); if (!debit) throw Error("not found"); const ct = await tx.contractTerms.get(debit.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant( + !!ct, + `no contract terms for p2p push ${parsedTx.pursePub}`, + ); return buildTransactionForPushPaymentDebit( debit, ct.contractTermsRaw, @@ -429,7 +437,10 @@ export async function getTransactionById( const pushInc = await tx.peerPushCredit.get(peerPushCreditId); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant( + !!ct, + `no contract terms for p2p push ${peerPushCreditId}`, + ); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; @@ -441,7 +452,7 @@ export async function getTransactionById( } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); return buildTransactionForPeerPushCredit( pushInc, @@ -469,7 +480,7 @@ export async function getTransactionById( const pushInc = await tx.peerPullCredit.get(pursePub); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pursePub}`); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; @@ -594,6 +605,7 @@ function buildTransactionForPeerPullCredit( const txState = computePeerPullCreditTransactionState(pullCredit); checkDbInvariant(wsr.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wsr.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wsr.exchangeBaseUrl !== undefined, "wg uninitialized"); return { type: TransactionType.PeerPullCredit, txState, @@ -668,6 +680,7 @@ function buildTransactionForPeerPushCredit( } checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized"); const txState = computePeerPushCreditTransactionState(pushInc); return { @@ -720,16 +733,21 @@ function buildTransactionForPeerPushCredit( function buildTransactionForBankIntegratedWithdraw( wg: WithdrawalGroupRecord, - exchangeDetails: ExchangeWireDetails, + exchangeDetails: ExchangeWireDetails | undefined, ort?: OperationRetryRecord, ): TransactionWithdrawal { if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error(""); } + const instructedCurrency = + wg.instructedAmount === undefined + ? undefined + : Amounts.currencyOf(wg.instructedAmount); + const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency; + checkDbInvariant(currency !== undefined, "wg uninitialized (missing currency)"); const txState = computeWithdrawalTransactionStatus(wg); - const zero = Amounts.stringify( - Amounts.zeroOfCurrency(exchangeDetails.currency), - ); + + const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); return { type: TransactionType.Withdrawal, txState, @@ -785,6 +803,7 @@ function buildTransactionForManualWithdraw( checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized"); const exchangePaytoUris = augmentPaytoUrisForWithdrawal( plainPaytoUris, wg.reservePub, @@ -1035,8 +1054,14 @@ function buildTransactionForPurchase( })); const timestamp = purchaseRecord.timestampAccept; - checkDbInvariant(!!timestamp); - checkDbInvariant(!!purchaseRecord.payInfo); + checkDbInvariant( + !!timestamp, + `purchase ${purchaseRecord.orderId} without accepted time`, + ); + checkDbInvariant( + !!purchaseRecord.payInfo, + `purchase ${purchaseRecord.orderId} without payinfo`, + ); const txState = computePayMerchantTransactionState(purchaseRecord); return { @@ -1090,6 +1115,10 @@ export async function getWithdrawalTransactionByUri( if (!withdrawalGroupRecord) { return undefined; } + if (withdrawalGroupRecord.exchangeBaseUrl === undefined) { + // prepared and unconfirmed withdrawals are hidden + return undefined; + } const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); @@ -1176,7 +1205,7 @@ export async function getTransactions( return; } const ct = await tx.contractTerms.get(pi.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw), ); @@ -1250,9 +1279,9 @@ export async function getTransactions( } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPeerPushCredit( pi, @@ -1284,9 +1313,9 @@ export async function getTransactions( } } const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPeerPullCredit( pi, @@ -1772,7 +1801,11 @@ export async function retryTransaction( } } +/** + * Reset the task retry counter for all tasks. + */ export async function retryAll(wex: WalletExecutionContext): Promise<void> { + await wex.taskScheduler.ensureRunning(); const tasks = wex.taskScheduler.getActiveTasks(); for (const task of tasks) { await wex.taskScheduler.resetTaskRetries(task); @@ -1935,8 +1968,8 @@ async function iterRecordsForWithdrawal( let withdrawalGroupRecords: WithdrawalGroupRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); withdrawalGroupRecords = await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange); @@ -1957,8 +1990,8 @@ async function iterRecordsForDeposit( let dgs: DepositGroupRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange); } else { @@ -1978,8 +2011,8 @@ async function iterRecordsForDenomLoss( let dgs: DenomLossEventRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange); } else { @@ -1998,8 +2031,8 @@ async function iterRecordsForRefund( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2014,8 +2047,8 @@ async function iterRecordsForPurchase( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2030,8 +2063,8 @@ async function iterRecordsForPeerPullCredit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2046,8 +2079,8 @@ async function iterRecordsForPeerPullDebit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2062,8 +2095,8 @@ async function iterRecordsForPeerPushDebit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2078,8 +2111,8 @@ async function iterRecordsForPeerPushCredit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts index d33a23cdd..8b4b24351 100644 --- a/packages/taler-wallet-core/src/versions.ts +++ b/packages/taler-wallet-core/src/versions.ts @@ -29,13 +29,6 @@ export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0"; export const WALLET_MERCHANT_PROTOCOL_VERSION = "5:0:1"; /** - * Protocol version spoken with the bank (bank integration API). - * - * Uses libtool's current:revision:age versioning. - */ -export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "1:0:0"; - -/** * Protocol version spoken with the bank (corebank API). * * Uses libtool's current:revision:age versioning. @@ -52,7 +45,7 @@ export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0"; /** * Libtool version of the wallet-core API. */ -export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0"; +export const WALLET_CORE_API_PROTOCOL_VERSION = "7:0:0"; /** * Libtool rules: diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 1bcab801c..aa88331ea 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -123,7 +123,6 @@ import { StartRefundQueryForUriResponse, StartRefundQueryRequest, StoredBackupList, - TalerMerchantApi, TestPayArgs, TestPayResult, TestingGetDenomStatsRequest, @@ -277,6 +276,7 @@ export enum WalletApiOperation { TestingGetDenomStats = "testingGetDenomStats", TestingPing = "testingPing", TestingGetReserveHistory = "testingGetReserveHistory", + TestingResetAllRetries = "testingResetAllRetries", } // group: Initialization @@ -1213,6 +1213,16 @@ export type TestingGetReserveHistoryOp = { }; /** + * Reset all task/transaction retries, + * resulting in immediate re-try of all operations. + */ +export type TestingResetAllRetriesOp = { + op: WalletApiOperation.TestingResetAllRetries; + request: EmptyObject; + response: EmptyObject; +}; + +/** * Get stats about an exchange denomination. */ export type TestingGetDenomStatsOp = { @@ -1356,6 +1366,7 @@ export type WalletOperations = { [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp; [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp; [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp; + [WalletApiOperation.TestingResetAllRetries]: TestingResetAllRetriesOp; [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp; }; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 68da15410..7a69fcb21 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -56,6 +56,7 @@ import { PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, StoredBackupList, + TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, TalerProtocolTimestamp, @@ -107,6 +108,7 @@ import { codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, + codecForHintNetworkAvailabilityRequest, codecForImportDbRequest, codecForInitRequest, codecForInitiatePeerPullPaymentRequest, @@ -265,6 +267,7 @@ import { TaskScheduler, TaskSchedulerImpl, convertTaskToTransactionId, + getActiveTaskIds, listTaskForTransactionId, } from "./shepherd.js"; import { @@ -287,12 +290,12 @@ import { getWithdrawalTransactionByUri, parseTransactionIdentifier, resumeTransaction, + retryAll, retryTransaction, suspendTransaction, } from "./transactions.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, WALLET_CORE_API_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, @@ -476,7 +479,10 @@ async function setCoinSuspended( c.denomPubHash, c.maxAge, ]); - checkDbInvariant(!!coinAvailability); + checkDbInvariant( + !!coinAvailability, + `no denom info for ${c.denomPubHash} age ${c.maxAge}`, + ); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; @@ -721,12 +727,11 @@ async function dispatchRequestInternal( case WalletApiOperation.InitWallet: { const req = codecForInitRequest().decode(payload); - logger.info(`init request: ${j2s(req)}`); - - if (wex.ws.initCalled) { - logger.info("initializing wallet (repeat initialization)"); - } else { - logger.info("initializing wallet (first initialization)"); + if (logger.shouldLogTrace()) { + const initType = wex.ws.initCalled + ? "repeat initialization" + : "first initialization"; + logger.trace(`init request (${initType}): ${j2s(req)}`); } // Write to the DB to make sure that we're failing early in @@ -744,7 +749,6 @@ async function dispatchRequestInternal( innerError: getErrorDetailFromException(e), }); } - wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); if (wex.ws.config.testing.skipDefaults) { @@ -757,8 +761,11 @@ async function dispatchRequestInternal( versionInfo: getVersion(wex), }; - // After initialization, task loop should run. - await wex.taskScheduler.ensureRunning(); + if (req.config?.lazyTaskLoop) { + logger.trace("lazily starting task loop"); + } else { + await wex.taskScheduler.ensureRunning(); + } wex.ws.initCalled = true; return resp; @@ -996,6 +1003,7 @@ async function dispatchRequestInternal( talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, + amount: req.amount, }); } case WalletApiOperation.ConfirmWithdrawal: { @@ -1005,10 +1013,7 @@ async function dispatchRequestInternal( case WalletApiOperation.PrepareBankIntegratedWithdrawal: { const req = codecForPrepareBankIntegratedWithdrawalRequest().decode(payload); - return prepareBankIntegratedWithdrawal(wex, { - talerWithdrawUri: req.talerWithdrawUri, - selectedExchange: req.selectedExchange, - }); + return prepareBankIntegratedWithdrawal(wex, req); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); @@ -1046,6 +1051,10 @@ async function dispatchRequestInternal( const req = codecForPrepareWithdrawExchangeRequest().decode(payload); return handlePrepareWithdrawExchange(wex, req); } + case WalletApiOperation.CheckPayForTemplate: { + const req = codecForCheckPayTemplateRequest().decode(payload); + return await checkPayForTemplate(wex, req); + } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(wex, req.talerPayUri); @@ -1054,10 +1063,6 @@ async function dispatchRequestInternal( const req = codecForPreparePayTemplateRequest().decode(payload); return preparePayForTemplate(wex, req); } - case WalletApiOperation.CheckPayForTemplate: { - const req = codecForCheckPayTemplateRequest().decode(payload); - return checkPayForTemplate(wex, req); - } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); let transactionId; @@ -1085,7 +1090,7 @@ async function dispatchRequestInternal( return {}; } case WalletApiOperation.GetActiveTasks: { - const allTasksId = wex.taskScheduler.getActiveTasks(); + const allTasksId = (await getActiveTaskIds(wex.ws)).taskIds; const tasksInfo = await Promise.all( allTasksId.map(async (id) => { @@ -1234,10 +1239,16 @@ async function dispatchRequestInternal( await loadBackupRecovery(wex, req); return {}; } - // case WalletApiOperation.GetPlanForOperation: { - // const req = codecForGetPlanForOperationRequest().decode(payload); - // return await getPlanForOperation(ws, req); - // } + case WalletApiOperation.HintNetworkAvailability: { + const req = codecForHintNetworkAvailabilityRequest().decode(payload); + if (req.isNetworkAvailable) { + await retryAll(wex); + } else { + // We're not doing anything right now, but we could stop showing + // certain errors! + } + return {}; + } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertDepositAmount(wex, req); @@ -1388,7 +1399,10 @@ async function dispatchRequestInternal( return; } wex.ws.exchangeCache.clear(); - checkDbInvariant(!!existingRec.id); + checkDbInvariant( + !!existingRec.id, + `no global exchange for ${j2s(key)}`, + ); await tx.globalCurrencyExchanges.delete(existingRec.id); }, ); @@ -1421,6 +1435,9 @@ async function dispatchRequestInternal( await waitTasksDone(wex); return {}; } + case WalletApiOperation.TestingResetAllRetries: + await retryAll(wex); + return {}; case WalletApiOperation.RemoveGlobalCurrencyAuditor: { const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); await wex.db.runReadWriteTx( @@ -1434,7 +1451,10 @@ async function dispatchRequestInternal( if (!existingRec) { return; } - checkDbInvariant(!!existingRec.id); + checkDbInvariant( + !!existingRec.id, + `no global currency for ${j2s(key)}`, + ); await tx.globalCurrencyAuditors.delete(existingRec.id); wex.ws.exchangeCache.clear(); }, @@ -1569,9 +1589,9 @@ export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bankIntegrationApiRange: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION, - bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bank: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, devMode: wex.ws.config.testing.devModeActive, }; return result; @@ -1629,10 +1649,10 @@ async function handleCoreApiRequest( if (!ws.initCalled) { throw Error("init must be called first"); } - // Might be lazily initialized! - await ws.taskScheduler.ensureRunning(); } + await ws.ensureWalletDbOpen(); + let wex: WalletExecutionContext; let oc: ObservabilityContext; @@ -1832,7 +1852,7 @@ class WalletDbTriggerSpec implements TriggerSpec { if (info.mode !== "readwrite") { return; } - logger.info( + logger.trace( `in after commit callback for readwrite, modified ${j2s([ ...info.modifiedStores, ])}`, @@ -1924,8 +1944,6 @@ export class InternalWalletState { initWithConfig(newConfig: WalletRunConfig): void { this._config = newConfig; - logger.info(`setting new config to ${j2s(newConfig)}`); - this._http = this.httpFactory(newConfig); if (this.config.testing.devModeActive) { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index 4a7c7873c..8bc4aafd1 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -44,6 +44,8 @@ import { Duration, EddsaPrivateKeyString, ExchangeBatchWithdrawRequest, + ExchangeListItem, + ExchangeTosStatus, ExchangeUpdateStatus, ExchangeWireAccount, ExchangeWithdrawBatchResponse, @@ -114,8 +116,10 @@ import { TransitionResult, TransitionResultType, constructTaskIdentifier, + genericWaitForState, makeCoinAvailable, makeCoinsVisible, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; import { @@ -149,6 +153,7 @@ import { getExchangePaytoUri, getExchangeWireDetailsInTx, listExchanges, + lookupExchangeByUri, markExchangeUsed, } from "./exchanges.js"; import { DbAccess } from "./query.js"; @@ -159,10 +164,7 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "./versions.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** @@ -343,9 +345,11 @@ export class WithdrawTransactionContext implements TransactionContext { "exchanges" as const, "exchangeDetails" as const, ]; - let stores = opts.extraStores + const stores = opts.extraStores ? [...baseStores, ...opts.extraStores] : baseStores; + + let errorThrown: Error | undefined; const transitionInfo = await this.wex.db.runReadWriteTx( { storeNames: stores }, async (tx) => { @@ -358,7 +362,17 @@ export class WithdrawTransactionContext implements TransactionContext { major: TransactionMajorState.None, }; } - const res = await f(wgRec, tx); + let res: TransitionResult<WithdrawalGroupRecord> | undefined; + try { + res = await f(wgRec, tx); + } catch (error) { + if (error instanceof Error) { + errorThrown = error; + } + return undefined; + } + + // const res = await f(wgRec, tx); switch (res.type) { case TransitionResultType.Transition: { await tx.withdrawalGroups.put(res.rec); @@ -383,6 +397,9 @@ export class WithdrawTransactionContext implements TransactionContext { } }, ); + if (errorThrown) { + throw errorThrown; + } notifyTransition(this.wex, this.transactionId, transitionInfo); return transitionInfo; } @@ -715,15 +732,35 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.Done: return [TransactionAction.Delete]; case WithdrawalGroupStatus.PendingRegisteringBank: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingReady: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingQueryingStatus: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingWaitConfirmBank: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.AbortingBank: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case WithdrawalGroupStatus.SuspendedAbortingBank: return [TransactionAction.Resume, TransactionAction.Fail]; case WithdrawalGroupStatus.SuspendedQueryingStatus: @@ -735,9 +772,17 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.SuspendedReady: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingAml: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Resume, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingKyc: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Resume, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.SuspendedAml: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedKyc: @@ -842,7 +887,7 @@ export async function getBankWithdrawalInfo( TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE, { bankProtocolVersion: config.version, - walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + walletProtocolVersion: bankApi.PROTOCOL_VERSION, }, "bank integration protocol version not compatible with wallet", ); @@ -857,13 +902,48 @@ export async function getBankWithdrawalInfo( } const { body: status } = resp; + const maxAmount = + status.max_amount === undefined + ? undefined + : Amounts.parseOrThrow(status.max_amount); + + let amount: AmountJson | undefined; + let editableAmount = false; + if (status.amount !== undefined) { + amount = Amounts.parseOrThrow(status.amount); + } else { + amount = + status.suggested_amount === undefined + ? undefined + : Amounts.parseOrThrow(status.suggested_amount); + editableAmount = true; + } + + let wireFee: AmountJson | undefined; + if (status.card_fees) { + wireFee = Amounts.parseOrThrow(status.card_fees); + } + + let exchange: string | undefined = undefined; + let editableExchange = false; + if (status.required_exchange !== undefined) { + exchange = status.required_exchange; + } else { + exchange = status.suggested_exchange; + editableExchange = true; + } return { operationId: uriResult.withdrawalOperationId, apiBaseUrl: uriResult.bankIntegrationApiBaseUrl, - amount: Amounts.parseOrThrow(status.amount), + currency: config.currency, + amount, + wireFee, confirmTransferUrl: status.confirm_transfer_url, senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, + exchange, + editableAmount, + editableExchange, + maxAmount, wireTypes: status.wire_types, status: status.status, }; @@ -917,6 +997,10 @@ async function processPlanchetGenerate( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; let planchet = await wex.db.runReadOnlyTx( { storeNames: ["planchets"] }, @@ -958,7 +1042,7 @@ async function processPlanchetGenerate( return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash); }, ); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `no denom info for ${denomPubHash}`); const r = await wex.cryptoApi.createPlanchet({ denomPub: denom.denomPub, feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), @@ -1121,6 +1205,10 @@ async function processPlanchetExchangeBatchRequest( logger.info( `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`, ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] }; @@ -1256,6 +1344,10 @@ async function processPlanchetVerifyAndStoreCoin( resp: ExchangeWithdrawResponse, ): Promise<void> { const withdrawalGroup = wgContext.wgRecord; + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; logger.trace(`checking and storing planchet idx=${coinIdx}`); @@ -1505,6 +1597,10 @@ async function processQueryReserve( return TaskRunResult.backoff(); } checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + checkDbInvariant( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); @@ -1740,6 +1836,10 @@ async function redenominateWithdrawal( return; } checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + checkDbInvariant( wg.denomsSel !== undefined, "can't process uninitialized exchange", ); @@ -1882,7 +1982,12 @@ async function processWithdrawalGroupPendingReady( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + logger.trace(`updating exchange beofre processing wg`); await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { @@ -2162,14 +2267,6 @@ export async function getExchangeWithdrawalInfo( logger.trace("selection done"); - if (selectedDenoms.selectedDenoms.length === 0) { - throw Error( - `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify( - instructedAmount, - )}`, - ); - } - const exchangeWireAccounts: string[] = []; for (const account of exchange.wireInfo.accounts) { @@ -2248,38 +2345,52 @@ export async function getWithdrawalDetailsForUri( logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); logger.trace(`got bank info`); - if (info.suggestedExchange) { + if (info.exchange) { try { // If the exchange entry doesn't exist yet, // it'll be created as an ephemeral entry. - await fetchFreshExchange(wex, info.suggestedExchange); + await fetchFreshExchange(wex, info.exchange); } catch (e) { // We still continued if it failed, as other exchanges might be available. // We don't want to fail if the bank-suggested exchange is broken/offline. logger.trace( - `querying bank-suggested exchange (${info.suggestedExchange}) failed`, + `querying bank-suggested exchange (${info.exchange}) failed`, ); } } - const currency = Amounts.currencyOf(info.amount); + const currency = info.currency; - const listExchangesResp = await listExchanges(wex); - const possibleExchanges = listExchangesResp.exchanges.filter((x) => { - return ( - x.currency === currency && - (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || - x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) - ); - }); + let possibleExchanges: ExchangeListItem[]; + if (!info.editableExchange && info.exchange !== undefined) { + const ex: ExchangeListItem = await lookupExchangeByUri(wex, { + exchangeBaseUrl: info.exchange, + }); + possibleExchanges = [ex]; + } else { + const listExchangesResp = await listExchanges(wex); + + possibleExchanges = listExchangesResp.exchanges.filter((x) => { + return ( + x.currency === currency && + (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || + x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) + ); + }); + } return { operationId: info.operationId, confirmTransferUrl: info.confirmTransferUrl, status: info.status, - amount: Amounts.stringify(info.amount), - defaultExchangeBaseUrl: info.suggestedExchange, + currency, + editableAmount: info.editableAmount, + editableExchange: info.editableExchange, + maxAmount: info.maxAmount ? Amounts.stringify(info.maxAmount) : undefined, + amount: info.amount ? Amounts.stringify(info.amount) : undefined, + defaultExchangeBaseUrl: info.exchange, possibleExchanges, + wireFee: info.wireFee ? Amounts.stringify(info.wireFee) : undefined, }; } @@ -2306,7 +2417,11 @@ export async function getFundingPaytoUris( withdrawalGroupId: string, ): Promise<string[]> { const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); - checkDbInvariant(!!withdrawalGroup); + checkDbInvariant(!!withdrawalGroup, `no withdrawal for ${withdrawalGroupId}`); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); checkDbInvariant( withdrawalGroup.instructedAmount !== undefined, "can't get funding uri from uninitialized wg", @@ -2379,6 +2494,7 @@ export function getBankAbortUrl(talerWithdrawUri: string): string { async function registerReserveWithBank( wex: WalletExecutionContext, withdrawalGroupId: string, + isFlexibleAmount: boolean, ): Promise<void> { const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, @@ -2407,7 +2523,11 @@ async function registerReserveWithBank( const reqBody = { reserve_pub: withdrawalGroup.reservePub, selected_exchange: bankInfo.exchangePaytoUri, - }; + } as any; + if (isFlexibleAmount) { + reqBody.amount = withdrawalGroup.instructedAmount; + } + logger.trace(`isFlexibleAmount: ${isFlexibleAmount}`); logger.info(`registering reserve with bank: ${j2s(reqBody)}`); const httpResp = await wex.http.fetch(bankStatusUrl, { method: "POST", @@ -2516,7 +2636,9 @@ async function processBankRegisterReserve( // FIXME: Put confirm transfer URL in the DB! - await registerReserveWithBank(wex, withdrawalGroupId); + const isFlexibleAmount = status.amount == null; + + await registerReserveWithBank(wex, withdrawalGroupId, isFlexibleAmount); return TaskRunResult.progress(); } @@ -2553,6 +2675,7 @@ async function processReserveBankStatus( uriResult.bankIntegrationApiBaseUrl, ); bankStatusUrl.searchParams.set("long_poll_ms", "30000"); + bankStatusUrl.searchParams.set("old_state", "selected"); logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`); const statusResp = await wex.http.fetch(bankStatusUrl.href, { @@ -2655,7 +2778,7 @@ export async function internalPrepareCreateWithdrawalGroup( args: { reserveStatus: WithdrawalGroupStatus; amount?: AmountJson; - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; reserveKeyPair?: EddsaKeypair; @@ -2696,7 +2819,7 @@ export async function internalPrepareCreateWithdrawalGroup( let initialDenomSel: DenomSelectionState | undefined; const denomSelUid = encodeCrock(getRandomBytes(16)); - if (amount !== undefined) { + if (amount !== undefined && exchangeBaseUrl !== undefined) { initialDenomSel = await getInitialDenomsSelection( wex, exchangeBaseUrl, @@ -2727,7 +2850,9 @@ export async function internalPrepareCreateWithdrawalGroup( wgInfo: args.wgInfo, }; - await fetchFreshExchange(wex, exchangeBaseUrl); + if (exchangeBaseUrl !== undefined) { + await fetchFreshExchange(wex, exchangeBaseUrl); + } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Withdrawal, @@ -2737,12 +2862,13 @@ export async function internalPrepareCreateWithdrawalGroup( return { withdrawalGroup, transactionId, - creationInfo: !amount - ? undefined - : { - amount, - canonExchange: exchangeBaseUrl, - }, + creationInfo: + !amount || !exchangeBaseUrl + ? undefined + : { + amount, + canonExchange: exchangeBaseUrl, + }, }; } @@ -2772,8 +2898,8 @@ export async function internalPerformCreateWithdrawalGroup( if (existingWg) { return { withdrawalGroup: existingWg, - exchangeNotif: undefined, transitionInfo: undefined, + exchangeNotif: undefined, }; } await tx.withdrawalGroups.add(withdrawalGroup); @@ -2789,7 +2915,21 @@ export async function internalPerformCreateWithdrawalGroup( exchangeNotif: undefined, }; } - const exchange = await tx.exchanges.get(prep.creationInfo.canonExchange); + return internalPerformExchangeWasUsed( + wex, + tx, + prep.creationInfo.canonExchange, + withdrawalGroup, + ); +} + +export async function internalPerformExchangeWasUsed( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction<["exchanges"]>, + canonExchange: string, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<PerformCreateWithdrawalGroupResult> { + const exchange = await tx.exchanges.get(canonExchange); if (exchange) { exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now()); await tx.exchanges.put(exchange); @@ -2805,11 +2945,7 @@ export async function internalPerformCreateWithdrawalGroup( newTxState, }; - const exchangeUsedRes = await markExchangeUsed( - wex, - tx, - prep.creationInfo.canonExchange, - ); + const exchangeUsedRes = await markExchangeUsed(wex, tx, canonExchange); const ctx = new WithdrawTransactionContext( wex, @@ -2837,7 +2973,7 @@ export async function internalCreateWithdrawalGroup( wex: WalletExecutionContext, args: { reserveStatus: WithdrawalGroupStatus; - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; amount?: AmountJson; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; @@ -2883,7 +3019,6 @@ export async function prepareBankIntegratedWithdrawal( wex: WalletExecutionContext, req: { talerWithdrawUri: string; - selectedExchange?: string; }, ): Promise<PrepareBankIntegratedWithdrawalResponse> { const existingWithdrawalGroup = await wex.db.runReadOnlyTx( @@ -2912,12 +3047,6 @@ export async function prepareBankIntegratedWithdrawal( const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); - const exchangeBaseUrl = - req.selectedExchange ?? withdrawInfo.suggestedExchange; - if (!exchangeBaseUrl) { - return { info }; - } - /** * Withdrawal group without exchange and amount * this is an special case when the user haven't yet @@ -2926,7 +3055,7 @@ export async function prepareBankIntegratedWithdrawal( * same URI */ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - exchangeBaseUrl, + exchangeBaseUrl: undefined, wgInfo: { withdrawalType: WithdrawalRecordType.BankIntegrated, bankInfo: { @@ -2935,6 +3064,7 @@ export async function prepareBankIntegratedWithdrawal( timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, wireTypes: withdrawInfo.wireTypes, + currency: withdrawInfo.currency, }, }, reserveStatus: WithdrawalGroupStatus.DialogProposed, @@ -2957,6 +3087,9 @@ export async function confirmWithdrawal( req: ConfirmWithdrawalRequest, ): Promise<void> { const parsedTx = parseTransactionIdentifier(req.transactionId); + const selectedExchange = req.exchangeBaseUrl; + const instructedAmount = Amounts.parseOrThrow(req.amount); + if (parsedTx?.tag !== TransactionType.Withdrawal) { throw Error("invalid withdrawal transaction ID"); } @@ -2978,38 +3111,44 @@ export async function confirmWithdrawal( throw Error("not a bank integrated withdrawal"); } - const selectedExchange = req.exchangeBaseUrl; const exchange = await fetchFreshExchange(wex, selectedExchange); + requireExchangeTosAcceptedOrThrow(exchange); const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; const confirmUrl = withdrawalGroup.wgInfo.bankInfo.confirmUrl; /** - * The only reasong this to be undefined is because it is an old wallet - * database before adding the wireType field was added + * The only reason this could be undefined is because it is an old wallet + * database before adding the prepareWithdrawal feature */ - let wtypes: string[]; - if (withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined) { + let bankWireTypes: string[]; + let bankCurrency: string; + if ( + withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined || + withdrawalGroup.wgInfo.bankInfo.currency === undefined + ) { const withdrawInfo = await getBankWithdrawalInfo( wex.http, talerWithdrawUri, ); - wtypes = withdrawInfo.wireTypes; + bankWireTypes = withdrawInfo.wireTypes; + bankCurrency = withdrawInfo.currency; } else { - wtypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + bankWireTypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + bankCurrency = withdrawalGroup.wgInfo.bankInfo.currency; } const exchangePaytoUri = await getExchangePaytoUri( wex, selectedExchange, - wtypes, + bankWireTypes, ); const withdrawalAccountList = await fetchWithdrawalAccountInfo( wex, { exchange, - instructedAmount: Amounts.parseOrThrow(req.amount), + instructedAmount, }, wex.cancellationToken, ); @@ -3020,23 +3159,34 @@ export async function confirmWithdrawal( ); const initalDenoms = await getInitialDenomsSelection( wex, - req.exchangeBaseUrl, - Amounts.parseOrThrow(req.amount), + exchange.exchangeBaseUrl, + instructedAmount, req.forcedDenomSel, ); - ctx.transition({}, async (rec) => { + let pending = false; + await ctx.transition({}, async (rec) => { if (!rec) { return TransitionResult.stay(); } switch (rec.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + pending = true; + return TransitionResult.stay(); + } + case WithdrawalGroupStatus.AbortedOtherWallet: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } case WithdrawalGroupStatus.DialogProposed: { - rec.exchangeBaseUrl = req.exchangeBaseUrl; + rec.exchangeBaseUrl = exchange.exchangeBaseUrl; rec.instructedAmount = req.amount; + rec.restrictAge = req.restrictAge; rec.denomsSel = initalDenoms; rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost; rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue; - rec.restrictAge = req.restrictAge; rec.wgInfo = { withdrawalType: WithdrawalRecordType.BankIntegrated, @@ -3047,20 +3197,50 @@ export async function confirmWithdrawal( confirmUrl: confirmUrl, timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, - wireTypes: wtypes, + wireTypes: bankWireTypes, + currency: bankCurrency, }, }; - + pending = true; rec.status = WithdrawalGroupStatus.PendingRegisteringBank; return TransitionResult.transition(rec); } - default: - throw Error("unable to confirm withdrawal in current state"); + default: { + throw Error( + `unable to confirm withdrawal in current state: ${rec.status}`, + ); + } } }); await wex.taskScheduler.resetTaskRetries(ctx.taskId); - wex.taskScheduler.startShepherdTask(ctx.taskId); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + const res = await wex.db.runReadWriteTx( + { + storeNames: ["exchanges"], + }, + async (tx) => { + const r = await internalPerformExchangeWasUsed( + wex, + tx, + exchange.exchangeBaseUrl, + withdrawalGroup, + ); + return r; + }, + ); + if (res.exchangeNotif) { + wex.ws.notify(res.exchangeNotif); + } + + if (pending) { + await waitWithdrawalRegistered(wex, ctx); + } } /** @@ -3080,181 +3260,119 @@ export async function acceptWithdrawalFromUri( selectedExchange: string; forcedDenomSel?: ForcedDenomSel; restrictAge?: number; + amount?: AmountLike; }, ): Promise<AcceptWithdrawalResponse> { const selectedExchange = req.selectedExchange; logger.info( - `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, - ); - const existingWithdrawalGroup = await wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups"] }, - async (tx) => { - return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( - req.talerWithdrawUri, - ); - }, + `preparing withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, ); - if (existingWithdrawalGroup) { - let url: string | undefined; - if ( - existingWithdrawalGroup.wgInfo.withdrawalType === - WithdrawalRecordType.BankIntegrated - ) { - url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + const p = await prepareBankIntegratedWithdrawal(wex, { + talerWithdrawUri: req.talerWithdrawUri, + }); + + let amount: AmountString; + if (p.info.amount == null) { + if (req.amount == null) { + throw Error( + "amount required, as withdrawal operation has flexible amount", + ); } - return { - reservePub: existingWithdrawalGroup.reservePub, - confirmTransferUrl: url, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, - }), - }; + amount = req.amount as AmountString; + } else { + if (req.amount != null && Amounts.cmp(req.amount, p.info.amount) != 0) { + throw Error( + "mismatched amount, amount is fixed by bank but client provided different amount", + ); + } + amount = p.info.amount; } - const exchange = await fetchFreshExchange(wex, selectedExchange); - const withdrawInfo = await getBankWithdrawalInfo( - wex.http, - req.talerWithdrawUri, - ); - const exchangePaytoUri = await getExchangePaytoUri( - wex, - selectedExchange, - withdrawInfo.wireTypes, - ); - - const withdrawalAccountList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount: withdrawInfo.amount, - }, - CancellationToken.CONTINUE, - ); - - const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - amount: withdrawInfo.amount, - exchangeBaseUrl: req.selectedExchange, - wgInfo: { - withdrawalType: WithdrawalRecordType.BankIntegrated, - exchangeCreditAccounts: withdrawalAccountList, - bankInfo: { - exchangePaytoUri, - talerWithdrawUri: req.talerWithdrawUri, - confirmUrl: withdrawInfo.confirmTransferUrl, - timestampBankConfirmed: undefined, - timestampReserveInfoPosted: undefined, - wireTypes: withdrawInfo.wireTypes, - }, - }, + logger.info(`confirming withdrawal with tx ${p.transactionId}`); + await confirmWithdrawal(wex, { + amount: Amounts.stringify(amount), + exchangeBaseUrl: selectedExchange, + transactionId: p.transactionId, restrictAge: req.restrictAge, forcedDenomSel: req.forcedDenomSel, - reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank, }); - const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; - - const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); - - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - - await waitWithdrawalRegistered(wex, ctx); + const newWithdrawralGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); - wex.taskScheduler.startShepherdTask(ctx.taskId); + checkDbInvariant( + newWithdrawralGroup !== undefined, + "withdrawal don't exist after confirm", + ); return { - reservePub: withdrawalGroup.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - transactionId: ctx.transactionId, + reservePub: newWithdrawralGroup.reservePub, + confirmTransferUrl: p.info.confirmTransferUrl, + transactionId: p.transactionId, }; } -async function internalWaitWithdrawalRegistered( +async function waitWithdrawalRegistered( wex: WalletExecutionContext, ctx: WithdrawTransactionContext, - withdrawalNotifFlag: AsyncFlag, ): Promise<void> { - while (true) { - const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups", "operationRetries"] }, - async (tx) => { - return { - withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), - retryRec: await tx.operationRetries.get(ctx.taskId), - }; - }, - ); + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + retryRec: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); - if (!withdrawalRec) { - throw Error("withdrawal not found anymore"); - } + if (!withdrawalRec) { + throw Error("withdrawal not found anymore"); + } - switch (withdrawalRec.status) { - case WithdrawalGroupStatus.FailedBankAborted: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - case WithdrawalGroupStatus.PendingKyc: - case WithdrawalGroupStatus.PendingAml: - case WithdrawalGroupStatus.PendingQueryingStatus: - case WithdrawalGroupStatus.PendingReady: - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - return; - case WithdrawalGroupStatus.PendingRegisteringBank: - break; - default: { - if (retryRec) { - if (retryRec.lastError) { - throw TalerError.fromUncheckedDetail(retryRec.lastError); - } else { - throw Error("withdrawal unexpectedly pending"); + switch (withdrawalRec.status) { + case WithdrawalGroupStatus.FailedBankAborted: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return true; + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: { + if (retryRec) { + if (retryRec.lastError) { + throw TalerError.fromUncheckedDetail(retryRec.lastError); + } else { + throw Error("withdrawal unexpectedly pending"); + } } } } - } - - await withdrawalNotifFlag.wait(); - withdrawalNotifFlag.reset(); - } -} - -async function waitWithdrawalRegistered( - wex: WalletExecutionContext, - ctx: WithdrawTransactionContext, -): Promise<void> { - // FIXME: Doesn't support cancellation yet - // FIXME: We should use Symbol.dispose magic here for cleanup! - - const withdrawalNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - withdrawalNotifFlag.raise(); - } + return false; + }, + filterNotification(notif) { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, }); - - try { - const res = await internalWaitWithdrawalRegistered( - wex, - ctx, - withdrawalNotifFlag, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - cancelNotif(); - } } async function fetchAccount( @@ -3422,7 +3540,7 @@ export async function createManualWithdrawal( ); const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - amount: Amounts.jsonifyAmount(req.amount), + amount: amount, wgInfo: { withdrawalType: WithdrawalRecordType.BankManual, exchangeCreditAccounts: withdrawalAccountsList, @@ -3507,7 +3625,7 @@ async function internalWaitWithdrawalFinal( // Check if refresh is final const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups", "operationRetries"] }, + { storeNames: ["withdrawalGroups"] }, async (tx) => { return { wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), @@ -3550,7 +3668,7 @@ export async function getWithdrawalDetailsForAmount( type: ObservabilityEventType.Message, contents: `Cancelling previous key ${clientCancelKey}`, }); - prevCts.cancel(); + prevCts.cancel(`getting details amount`); } else { wex.oc.observe({ type: ObservabilityEventType.Message, diff --git a/packages/taler-wallet-embedded/package.json b/packages/taler-wallet-embedded/package.json index ee9efafdd..fe64396fb 100644 --- a/packages/taler-wallet-embedded/package.json +++ b/packages/taler-wallet-embedded/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-embedded", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-embedded/src/wallet-qjs-tests.ts b/packages/taler-wallet-embedded/src/wallet-qjs-tests.ts new file mode 100644 index 000000000..ca4eb28c0 --- /dev/null +++ b/packages/taler-wallet-embedded/src/wallet-qjs-tests.ts @@ -0,0 +1,118 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + (C) 2024 Taler Systems SA + + 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/> + */ + +import { userIdentifierDerive } from "@gnu-taler/anastasis-core/lib/crypto.js"; +import { AmountString, j2s } from "@gnu-taler/taler-util"; +import { + WalletApiOperation, + createNativeWalletHost2, +} from "@gnu-taler/taler-wallet-core"; + +export async function testWithGv() { + const w = await createNativeWalletHost2({}); + await w.wallet.client.call(WalletApiOperation.InitWallet, { + config: { + features: { + allowHttp: true, + }, + }, + }); + await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { + amountToSpend: "KUDOS:1" as AmountString, + amountToWithdraw: "KUDOS:3" as AmountString, + corebankApiBaseUrl: "https://bank.demo.taler.net/", + exchangeBaseUrl: "https://exchange.demo.taler.net/", + merchantBaseUrl: "https://backend.demo.taler.net/", + merchantAuthToken: "secret-token:sandbox", + }); + await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); + await w.wallet.client.call(WalletApiOperation.Shutdown, {}); +} + +export async function testWithFdold() { + const w = await createNativeWalletHost2({}); + await w.wallet.client.call(WalletApiOperation.InitWallet, { + config: { + features: { + allowHttp: true, + }, + }, + }); + await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { + amountToSpend: "TESTKUDOS:1" as AmountString, + amountToWithdraw: "TESTKUDOS:3" as AmountString, + corebankApiBaseUrl: "https://bank.taler.fdold.eu/", + exchangeBaseUrl: "https://exchange.taler.fdold.eu/", + merchantBaseUrl: "https://merchant.taler.fdold.eu/", + }); + await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); + await w.wallet.client.call(WalletApiOperation.Shutdown, {}); +} + +export async function testWithLocal(path: string) { + console.log("running local test"); + const w = await createNativeWalletHost2({ + persistentStoragePath: path ?? "walletdb.json", + }); + console.log("created wallet"); + await w.wallet.client.call(WalletApiOperation.InitWallet, { + config: { + features: { + allowHttp: true, + }, + testing: { + skipDefaults: true, + }, + }, + }); + console.log("initialized wallet"); + await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { + amountToSpend: "TESTKUDOS:1" as AmountString, + amountToWithdraw: "TESTKUDOS:3" as AmountString, + corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/", + exchangeBaseUrl: "http://localhost:8081/", + merchantBaseUrl: "http://localhost:8083/", + }); + console.log("started integration test"); + await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); + console.log("done with task loop"); + await w.wallet.client.call(WalletApiOperation.Shutdown, {}); + console.log("DB stats:", j2s(w.getDbStats())); +} + +export async function testArgon2id() { + const userIdVector = { + input_id_data: { + name: "Fleabag", + ssn: "AB123", + }, + input_server_salt: "FZ48EFS7WS3R2ZR4V53A3GFFY4", + output_id: + "YS45R6CGJV84K1NN7T14ZBCPVTZ6H15XJSM1FV0R748MHPV82SM0126EBZKBAAGCR34Q9AFKPEW1HRT2Q9GQ5JRA3642AB571DKZS18", + }; + + if ( + (await userIdentifierDerive( + userIdVector.input_id_data, + userIdVector.input_server_salt, + )) != userIdVector.output_id + ) { + throw Error("argon2id is not working!"); + } + + console.log("argon2id is working!"); +} diff --git a/packages/taler-wallet-embedded/src/wallet-qjs.ts b/packages/taler-wallet-embedded/src/wallet-qjs.ts index cbda401e9..2780a3cab 100644 --- a/packages/taler-wallet-embedded/src/wallet-qjs.ts +++ b/packages/taler-wallet-embedded/src/wallet-qjs.ts @@ -1,6 +1,7 @@ /* This file is part of GNU Taler (C) 2019 GNUnet e.V. + (C) 2024 Taler Systems SA 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 @@ -28,30 +29,27 @@ import { mergeDiscoveryAggregate, reduceAction, } from "@gnu-taler/anastasis-core"; -import { userIdentifierDerive } from "@gnu-taler/anastasis-core/lib/crypto.js"; import { - AmountString, CoreApiMessageEnvelope, CoreApiResponse, CoreApiResponseSuccess, Logger, - PartialWalletRunConfig, WalletNotification, enableNativeLogging, getErrorDetailFromException, - j2s, openPromise, performanceNow, setGlobalLogLevelFromString, } from "@gnu-taler/taler-util"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { qjsOs } from "@gnu-taler/taler-util/qtart"; +import { Wallet, createNativeWalletHost2 } from "@gnu-taler/taler-wallet-core"; import { - DefaultNodeWalletArgs, - Wallet, - WalletApiOperation, - createNativeWalletHost2, -} from "@gnu-taler/taler-wallet-core"; + testArgon2id, + testWithFdold, + testWithGv, + testWithLocal, +} from './wallet-qjs-tests.js'; setGlobalLogLevelFromString("trace"); @@ -68,9 +66,6 @@ function sendNativeMessage(ev: CoreApiMessageEnvelope): void { } class NativeWalletMessageHandler { - walletArgs: DefaultNodeWalletArgs | undefined; - walletConfig: PartialWalletRunConfig | undefined; - maybeWallet: Wallet | undefined; wp = openPromise<Wallet>(); httpLib = createPlatformHttpLib(); @@ -91,23 +86,9 @@ class NativeWalletMessageHandler { }; }; - let initResponse: any = {}; - - const reinit = async () => { - logger.info("in reinit"); - const wR = await createNativeWalletHost2(this.walletArgs); - const w = wR.wallet; - this.maybeWallet = w; - const resp = await w.handleCoreApiRequest("initWallet", "native-init", { - config: this.walletConfig, - }); - initResponse = resp.type == "response" ? resp.result : resp.error; - this.wp.resolve(w); - }; - switch (operation) { case "init": { - this.walletArgs = { + const wR = await createNativeWalletHost2({ notifyHandler: async (notification: WalletNotification) => { sendNativeMessage({ type: "notification", payload: notification }); }, @@ -115,38 +96,29 @@ class NativeWalletMessageHandler { httpLib: this.httpLib, cryptoWorkerType: args.cryptoWorkerType, ...args, - }; - this.walletConfig = args.config ?? {}; - const logLevel = args.logLevel; - if (logLevel) { - setGlobalLogLevelFromString(logLevel); + }); + + if (args.logLevel) { + setGlobalLogLevelFromString(args.logLevel); } - const nativeLogging = args.useNativeLogging ?? false; - if (nativeLogging) { + + if (args.useNativeLogging === true) { enableNativeLogging(); } - await reinit(); + + const resp = await wR.wallet.handleCoreApiRequest("initWallet", "native-init", { + config: args.config ?? {}, + }); + + let initResponse: any = resp.type == "response" ? resp.result : resp.error; + + this.wp.resolve(wR.wallet); + return wrapSuccessResponse({ ...initResponse, }); } - case "startTunnel": { - // this.httpLib.useNfcTunnel = true; - throw Error("not implemented"); - } - case "stopTunnel": { - // this.httpLib.useNfcTunnel = false; - throw Error("not implemented"); - } - case "tunnelResponse": { - // httpLib.handleTunnelResponse(msg.args); - throw Error("not implemented"); - } - case "reset": { - throw Error( - "reset not supported anymore, please use the clearDb wallet-core request", - ); - } + default: { const wallet = await this.wp.promise; return await wallet.handleCoreApiRequest(operation, id, args); @@ -175,17 +147,22 @@ async function handleAnastasisRequest( let req = args ?? {}; switch (operation) { - case "anastasisReduce": - // TODO: do some input validation here + case "anastasisReduce": { let reduceRes = await reduceAction(req.state, req.action, req.args ?? {}); // For now, this will return "success" even if the wrapped Anastasis // response is a ReducerStateError. return wrapSuccessResponse(reduceRes); - case "anastasisStartBackup": + } + + case "anastasisStartBackup": { return wrapSuccessResponse(await getBackupStartState()); - case "anastasisStartRecovery": + } + + case "anastasisStartRecovery": { return wrapSuccessResponse(await getRecoveryStartState()); - case "anastasisDiscoverPolicies": + } + + case "anastasisDiscoverPolicies": { let discoverRes = await discoverPolicies(req.state, req.cursor); let aggregatedPolicies = mergeDiscoveryAggregate( discoverRes.policies ?? [], @@ -199,19 +176,25 @@ async function handleAnastasisRequest( cursor: discoverRes.cursor, }, }); - default: + } + + default: { throw Error("unsupported anastasis operation"); + } } } export function installNativeWalletListener(): void { setGlobalLogLevelFromString("trace"); + const handler = new NativeWalletMessageHandler(); + const onMessage = async (msgStr: any): Promise<void> => { if (typeof msgStr !== "string") { logger.error("expected string as message"); return; } + const msg = JSON.parse(msgStr); const operation = msg.operation; if (typeof operation !== "string") { @@ -220,20 +203,23 @@ export function installNativeWalletListener(): void { ); return; } + const id = msg.id; logger.info(`native listener: got request for ${operation} (${id})`); - const startTimeNs = performanceNow(); - + const startTimeMs = performanceNow(); let respMsg: CoreApiResponse; + try { if (msg.operation.startsWith("anastasis")) { + // Entry point for Anastasis respMsg = await handleAnastasisRequest(operation, id, msg.args ?? {}); } else if (msg.operation === "testing-dangerously-eval") { // Eval code, used only for testing. No client may rely on this. logger.info(`evaluating ${msg.args.jscode}`); const f = new Function(msg.args.jscode); f(); + respMsg = { type: "response", result: {}, @@ -241,6 +227,7 @@ export function installNativeWalletListener(): void { id: msg.id, }; } else { + // Entry point for wallet-core respMsg = await handler.handleMessage(operation, id, msg.args ?? {}); } } catch (e) { @@ -251,10 +238,12 @@ export function installNativeWalletListener(): void { error: getErrorDetailFromException(e), }; } - const endTimeNs = performanceNow(); + + const endTimeMs = performanceNow(); const requestDurationMs = Math.round( - Number((endTimeNs - startTimeNs) / 1000n / 1000n), + Number((endTimeMs - startTimeMs) / 1000n / 1000n), ); + logger.info( `native listener: sending back ${respMsg.type} message for operation ${operation} (${id}) after ${requestDurationMs} ms`, ); @@ -268,102 +257,6 @@ export function installNativeWalletListener(): void { // @ts-ignore globalThis.installNativeWalletListener = installNativeWalletListener; - -export async function testWithGv() { - const w = await createNativeWalletHost2({}); - await w.wallet.client.call(WalletApiOperation.InitWallet, { - config: { - features: { - allowHttp: true, - }, - }, - }); - await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { - amountToSpend: "KUDOS:1" as AmountString, - amountToWithdraw: "KUDOS:3" as AmountString, - corebankApiBaseUrl: "https://bank.demo.taler.net/", - exchangeBaseUrl: "https://exchange.demo.taler.net/", - merchantBaseUrl: "https://backend.demo.taler.net/", - merchantAuthToken: "secret-token:sandbox", - }); - await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); - await w.wallet.client.call(WalletApiOperation.Shutdown, {}); -} - -export async function testWithFdold() { - const w = await createNativeWalletHost2({}); - await w.wallet.client.call(WalletApiOperation.InitWallet, { - config: { - features: { - allowHttp: true, - }, - }, - }); - await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { - amountToSpend: "TESTKUDOS:1" as AmountString, - amountToWithdraw: "TESTKUDOS:3" as AmountString, - corebankApiBaseUrl: "https://bank.taler.fdold.eu/", - exchangeBaseUrl: "https://exchange.taler.fdold.eu/", - merchantBaseUrl: "https://merchant.taler.fdold.eu/", - }); - await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); - await w.wallet.client.call(WalletApiOperation.Shutdown, {}); -} - -export async function testWithLocal(path: string) { - console.log("running local test"); - const w = await createNativeWalletHost2({ - persistentStoragePath: path ?? "walletdb.json", - }); - console.log("created wallet"); - await w.wallet.client.call(WalletApiOperation.InitWallet, { - config: { - features: { - allowHttp: true, - }, - testing: { - skipDefaults: true, - }, - }, - }); - console.log("initialized wallet"); - await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { - amountToSpend: "TESTKUDOS:1" as AmountString, - amountToWithdraw: "TESTKUDOS:3" as AmountString, - corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/", - exchangeBaseUrl: "http://localhost:8081/", - merchantBaseUrl: "http://localhost:8083/", - }); - console.log("started integration test"); - await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); - console.log("done with task loop"); - await w.wallet.client.call(WalletApiOperation.Shutdown, {}); - console.log("DB stats:", j2s(w.getDbStats())); -} - -export async function testArgon2id() { - const userIdVector = { - input_id_data: { - name: "Fleabag", - ssn: "AB123", - }, - input_server_salt: "FZ48EFS7WS3R2ZR4V53A3GFFY4", - output_id: - "YS45R6CGJV84K1NN7T14ZBCPVTZ6H15XJSM1FV0R748MHPV82SM0126EBZKBAAGCR34Q9AFKPEW1HRT2Q9GQ5JRA3642AB571DKZS18", - }; - - if ( - (await userIdentifierDerive( - userIdVector.input_id_data, - userIdVector.input_server_salt, - )) != userIdVector.output_id - ) { - throw Error("argon2id is not working!"); - } - - console.log("argon2id is working!"); -} - // @ts-ignore globalThis.testWithGv = testWithGv; // @ts-ignore diff --git a/packages/taler-wallet-webextension/manifest-common.json b/packages/taler-wallet-webextension/manifest-common.json index 32bd5267f..88f152d50 100644 --- a/packages/taler-wallet-webextension/manifest-common.json +++ b/packages/taler-wallet-webextension/manifest-common.json @@ -2,7 +2,7 @@ "name": "GNU Taler Wallet (git)", "description": "Privacy preserving and transparent payments", "author": "GNU Taler Developers", - "version": "0.10.7", + "version": "0.11.4", "icons": { "16": "static/img/taler-logo-16.png", "19": "static/img/taler-logo-19.png", @@ -14,5 +14,5 @@ "256": "static/img/taler-logo-256.png", "512": "static/img/taler-logo-512.png" }, - "version_name": "0.10.7" + "version_name": "0.11.4" } diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index bf063d76e..90679cfdd 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-webextension", - "version": "0.10.7", + "version": "0.11.4", "description": "GNU Taler Wallet browser extension", "main": "./build/index.js", "types": "./build/index.d.ts", diff --git a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx index 9be9326b2..8e48a2e9f 100644 --- a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx +++ b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx @@ -26,7 +26,7 @@ import { DenomLossEventType, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Avatar } from "../mui/Avatar.js"; import { Pages } from "../NavigationBar.js"; @@ -49,6 +49,8 @@ export function HistoryItem(props: { tx: Transaction }): VNode { */ switch (tx.type) { case TransactionType.Withdrawal: + //withdrawal that has not been confirmed are hidden + if (!tx.exchangeBaseUrl) return <Fragment /> return ( <Layout id={tx.transactionId} diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx index a77a69fa6..f29d0b0f7 100644 --- a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx +++ b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -22,7 +22,7 @@ import { TalerErrorDetail, TaskProgressNotification, WalletNotification, - assertUnreachable + assertUnreachable, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -83,7 +83,9 @@ export function WalletActivity(): VNode { cursor: "pointer", }} > - click here to open + <i18n.Translate> + Click here to open the wallet activity tab. + </i18n.Translate> </div> </div> ); diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx index 547d5ac9a..0d8035136 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx @@ -24,12 +24,21 @@ import { InvoicePaymentDetails, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; +import { AbsoluteTime, Duration } from "@gnu-taler/taler-util"; export function ReadyView( state: State.Ready | State.NoBalanceForCurrency | State.NoEnoughBalance, ): VNode { const { i18n } = useTranslationContext(); const { summary, effective, raw, expiration, uri, status, payStatus } = state; + + const inFiveMinutes = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 5 }), + ); + const willExpireSoon = + expiration && AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; + return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -42,11 +51,13 @@ export function ReadyView( /> } /> - <Part - title={i18n.str`Valid until`} - text={<Time timestamp={expiration} format="dd MMMM yyyy, HH:mm" />} - kind="neutral" - /> + {willExpireSoon && ( + <Part + title={i18n.str`Expires at`} + text={<Time timestamp={expiration} format="HH:mm" />} + kind="neutral" + /> + )} </section> <PaymentButtons amount={effective} diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx index 68d161ab2..b1eee85ec 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -18,6 +18,7 @@ import { AbsoluteTime, Amounts, MerchantContractTerms as ContractTerms, + Duration, PreparePayResultType, TranslatedString, } from "@gnu-taler/taler-util"; @@ -54,6 +55,17 @@ export function BaseView(state: SupportedStates): VNode { : Amounts.zeroOfCurrency(state.amount.currency) : state.amount; + const expiration = !contractTerms.pay_deadline + ? undefined + : AbsoluteTime.fromProtocolTimestamp(contractTerms.pay_deadline); + const inFiveMinutes = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 5 }), + ); + const willExpireSoon = + !expiration || expiration.t_ms === "never" + ? undefined + : AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; return ( <Fragment> <ShowImportantMessage state={state} /> @@ -65,7 +77,12 @@ export function BaseView(state: SupportedStates): VNode { <Fragment> <i18n.Translate>Purchase</i18n.Translate> - <AgeSign size={20} title={i18n.str`This purchase is age restricted.`}>{contractTerms.minimum_age}+</AgeSign> + <AgeSign + size={20} + title={i18n.str`This purchase is age restricted.`} + > + {contractTerms.minimum_age}+ + </AgeSign> </Fragment> ) : ( <i18n.Translate>Purchase</i18n.Translate> @@ -79,17 +96,10 @@ export function BaseView(state: SupportedStates): VNode { text={<MerchantDetails merchant={contractTerms.merchant} />} kind="neutral" /> - {contractTerms.pay_deadline && ( + {willExpireSoon && ( <Part - title={i18n.str`Valid until`} - text={ - <Time - timestamp={AbsoluteTime.fromProtocolTimestamp( - contractTerms.pay_deadline, - )} - format="dd MMMM yyyy, HH:mm" - /> - } + title={i18n.str`Expires at`} + text={<Time timestamp={expiration} format="HH:mm" />} kind="neutral" /> )} diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts index 1a92c4073..ba854a93c 100644 --- a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts @@ -47,12 +47,18 @@ export function useComponentState({ const hook = useAsyncAsHook(async () => { if (!talerTemplateUri) throw Error("ERROR_NO-URI-FOR-PAYMENT-TEMPLATE"); const templateP = await api.wallet.call( - WalletApiOperation.CheckPayForTemplate, { talerPayTemplateUri: talerTemplateUri }, + WalletApiOperation.CheckPayForTemplate, + { talerPayTemplateUri: talerTemplateUri }, ); - const requireMoreInfo = !templateP.templateDetails.template_contract.amount || !templateP.templateDetails.template_contract.summary; + const requireMoreInfo = + !templateP.templateDetails.template_contract.amount || + !templateP.templateDetails.template_contract.summary; let payStatus: PreparePayResult | undefined = undefined; if (!requireMoreInfo) { - payStatus = await api.wallet.call(WalletApiOperation.PreparePayForTemplate, { talerPayTemplateUri: talerTemplateUri }); + payStatus = await api.wallet.call( + WalletApiOperation.PreparePayForTemplate, + { talerPayTemplateUri: talerTemplateUri }, + ); } const balance = await api.wallet.call(WalletApiOperation.GetBalances, {}); return { payStatus, balance, uri: talerTemplateUri, templateP }; @@ -102,20 +108,28 @@ export function useComponentState({ const cfg = hook.response.templateP.templateDetails.template_contract; const def = hook.response.templateP.templateDetails.editable_defaults; - const fixedAmount = cfg.amount !== undefined ? Amounts.parseOrThrow(cfg.amount) : undefined; - const fixedSummary = cfg.summary !== undefined ? cfg.summary : undefined; - - const defaultAmount = def?.amount !== undefined ? Amounts.parseOrThrow(def.amount) : undefined; - const defaultSummary = def?.summary !== undefined ? def.summary : undefined; - - const zero = fixedAmount ? Amounts.zeroOfAmount(fixedAmount) : - cfg.currency !== undefined ? Amounts.zeroOfCurrency(cfg.currency) : - defaultAmount !== undefined ? Amounts.zeroOfAmount(defaultAmount) : - def?.currency !== undefined ? Amounts.zeroOfCurrency(def.currency) : - Amounts.zeroOfCurrency(hook.response.templateP.supportedCurrencies[0]); - - const [amount, setAmount] = useState(defaultAmount ?? zero); - const [summary, setSummary] = useState(defaultSummary ?? ""); + const fixedAmount = + cfg.amount !== undefined ? Amounts.parseOrThrow(cfg.amount) : undefined; + const fixedSummary = cfg.summary; + + const defaultAmount = + def?.amount !== undefined ? Amounts.parseOrThrow(def.amount) : undefined; + const defaultSummary = def?.summary; + + const zero = fixedAmount + ? Amounts.zeroOfAmount(fixedAmount) + : cfg.currency !== undefined + ? Amounts.zeroOfCurrency(cfg.currency) + : defaultAmount !== undefined + ? Amounts.zeroOfAmount(defaultAmount) + : def?.currency !== undefined + ? Amounts.zeroOfCurrency(def.currency) + : Amounts.zeroOfCurrency( + hook.response.templateP.supportedCurrencies[0], + ); + + const [amount, setAmount] = useState(defaultAmount ?? fixedAmount ?? zero); + const [summary, setSummary] = useState(defaultSummary ?? fixedSummary ?? ""); async function createOrder() { try { @@ -140,41 +154,50 @@ export function useComponentState({ } const errors = undefinedIfEmpty({ - amount: fixedAmount !== undefined ? undefined : amount && Amounts.isZero(amount) ? i18n.str`required` : undefined, - summary: fixedSummary !== undefined ? undefined : summary !== undefined && !summary ? i18n.str`required` : undefined, + amount: + fixedAmount !== undefined + ? undefined + : amount && Amounts.isZero(amount) + ? i18n.str`required` + : undefined, + summary: + fixedSummary !== undefined + ? undefined + : summary !== undefined && !summary + ? i18n.str`required` + : undefined, }); return { status: "fill-template", error: undefined, minAge: cfg.minimum_age ?? 0, - amount: - fixedAmount === undefined - ? ({ - onInput: (a) => { - setAmount(a); - }, - value: amount, - error: errors?.amount, - } as AmountFieldHandler) - : undefined, - summary: - fixedSummary === undefined - ? ({ - onInput: (t) => { - setSummary(t); - }, - value: summary, - error: errors?.summary, - } as TextFieldHandler) - : undefined, + amount: { + onInput: + fixedAmount !== undefined + ? undefined + : (a) => { + setAmount(a); + }, + value: amount, + error: errors?.amount, + } as AmountFieldHandler, + summary: { + onInput: + fixedSummary !== undefined + ? undefined + : (t) => { + setSummary(t); + }, + value: summary, + error: errors?.summary, + } as TextFieldHandler, onCreate: { onClick: errors ? undefined : safely("create order for pay template", createOrder), }, }; - } - + }; } function undefinedIfEmpty<T extends object>(obj: T): T | undefined { diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx index ce53c3cf9..4a1cfe3ac 100644 --- a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx @@ -33,24 +33,11 @@ export function ReadyView({ return ( <Fragment> <section style={{ textAlign: "left" }}> - {/* <Part - title={ - <div - style={{ - display: "flex", - alignItems: "center", - }} - > - <i18n.Translate>Merchant</i18n.Translate> - </div> - } - text={<ExchangeDetails exchange={exchangeUrl} />} - kind="neutral" - big - /> */} {!amount ? undefined : ( <p> - <AmountField label={i18n.str`Amount`} handler={amount} /> + <AmountField label={i18n.str`Amount`} + handler={amount} + /> </p> )} {!summary ? undefined : ( @@ -60,6 +47,7 @@ export function ReadyView({ variant="filled" required fullWidth + disabled={summary.onInput === undefined} error={summary.error} value={summary.value} onChange={summary.onInput} @@ -67,12 +55,12 @@ export function ReadyView({ </p> )} </section> - {minAge && ( + {minAge ? ( <section> <AgeSign size={25}>{minAge}+</AgeSign> <i18n.Translate>This purchase is age restricted.</i18n.Translate> </section> - )} + ) : undefined} <section> <Button onClick={onCreate.onClick} variant="contained" color="success"> <i18n.Translate>Review order</i18n.Translate> diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx b/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx index caa1b485a..e82c4fbd2 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx @@ -26,6 +26,7 @@ import { } from "../../wallet/Transaction.js"; import { State } from "./index.js"; import { TermsOfService } from "../../components/TermsOfService/index.js"; +import { AbsoluteTime, Duration } from "@gnu-taler/taler-util"; export function ReadyView({ accept, @@ -36,6 +37,12 @@ export function ReadyView({ raw, }: State.Ready): VNode { const { i18n } = useTranslationContext(); + const inFiveMinutes = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 5 }), + ); + const willExpireSoon = + expiration && AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -49,15 +56,16 @@ export function ReadyView({ /> } /> - - <Part - title={i18n.str`Valid until`} - text={<Time timestamp={expiration} format="dd MMMM yyyy, HH:mm" />} - kind="neutral" - /> + {willExpireSoon && ( + <Part + title={i18n.str`Expires at`} + text={<Time timestamp={expiration} format="HH:mm" />} + kind="neutral" + /> + )} </section> <section> - <TermsOfService key="terms" exchangeUrl={exchangeBaseUrl} > + <TermsOfService key="terms" exchangeUrl={exchangeBaseUrl}> <Button variant="contained" color="success" onClick={accept.onClick}> <i18n.Translate> Receive {<Amount value={effective} />} diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts index d33abffee..418fef505 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -18,7 +18,7 @@ import { AmountJson, AmountString, CurrencySpecification, - ExchangeListItem + ExchangeListItem, } from "@gnu-taler/taler-util"; import { Loading } from "../../components/Loading.js"; import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; @@ -85,7 +85,7 @@ export namespace State { operationState: "confirmed" | "aborted" | "selected"; thisWallet: boolean; redirectToTx: () => void; - confirmTransferUrl?: string, + confirmTransferUrl?: string; error: undefined; } @@ -95,20 +95,26 @@ export namespace State { currentExchange: ExchangeListItem; - chosenAmount: AmountJson; - withdrawalFee: AmountJson; + amount: AmountFieldHandler; + editableAmount: boolean; + + bankFee: AmountJson; toBeReceived: AmountJson; + toBeSent: AmountJson; doWithdrawal: ButtonHandler; doSelectExchange: ButtonHandler; + editableExchange: boolean; chooseCurrencies: string[]; selectedCurrency: string; changeCurrency: (s: string) => void; - conversionInfo: { - spec: CurrencySpecification, - amount: AmountJson, - } | undefined; + conversionInfo: + | { + spec: CurrencySpecification; + amount: AmountJson; + } + | undefined; ageRestriction?: SelectFieldHandler; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts index f592072ff..0541bbf3f 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -54,7 +54,7 @@ export function useComponentStateFromParams({ ? parseWithdrawExchangeUri(maybeTalerUri) : undefined; const exchangeByTalerUri = updatedExchangeByUser ?? uri?.exchangeBaseUrl; - + let ex: ExchangeFullDetails | undefined; if (exchangeByTalerUri) { await api.wallet.call(WalletApiOperation.AddExchange, { @@ -185,9 +185,16 @@ export function useComponentStateFromParams({ cancel, onSuccess, undefined, - chosenAmount, - exchangeList, - exchangeByTalerUri, + { + amount: chosenAmount, + currency: chosenAmount.currency, + maxAmount: Amounts.zeroOfCurrency(chosenAmount.currency), + bankFee: Amounts.zeroOfCurrency(chosenAmount.currency), + editableAmount: true, + editableExchange: true, + exchange: exchangeByTalerUri, + exchangeList: exchangeList, + }, setUpdatedExchangeByUser, ); } @@ -212,33 +219,21 @@ export function useComponentStateFromURI({ const uriInfo = await api.wallet.call( WalletApiOperation.PrepareBankIntegratedWithdrawal, + { talerWithdrawUri }, + ); + const { status } = uriInfo.info; + const txInfo = await api.wallet.call( + WalletApiOperation.GetTransactionById, { - talerWithdrawUri, - selectedExchange: updatedExchangeByUser, + transactionId: uriInfo.transactionId, }, ); - const { - amount, - defaultExchangeBaseUrl, - possibleExchanges, - confirmTransferUrl, - status, - } = uriInfo.info; - const txInfo = - uriInfo.transactionId === undefined - ? undefined - : await api.wallet.call(WalletApiOperation.GetTransactionById, { - transactionId: uriInfo.transactionId, - }); return { talerWithdrawUri, status, transactionId: uriInfo.transactionId, + bankWithdrawalInfo: uriInfo.info, txInfo: txInfo, - confirmTransferUrl, - amount: Amounts.parseOrThrow(amount), - thisExchange: defaultExchangeBaseUrl, - exchanges: possibleExchanges, }; }); @@ -278,9 +273,22 @@ export function useComponentStateFromURI({ const uri = uriInfoHook.response.talerWithdrawUri; const txId = uriInfoHook.response.transactionId; - const chosenAmount = uriInfoHook.response.amount; - const defaultExchange = uriInfoHook.response.thisExchange; - const exchangeList = uriInfoHook.response.exchanges; + const bwi = uriInfoHook.response.bankWithdrawalInfo; + + const amount = + bwi.amount === undefined + ? Amounts.zeroOfCurrency(bwi.currency) + : Amounts.parseOrThrow(bwi.amount); + + const maxAmount = + bwi.maxAmount === undefined + ? Amounts.zeroOfCurrency(bwi.currency) + : Amounts.parseOrThrow(bwi.maxAmount); + + const bankFee = + bwi.wireFee === undefined + ? Amounts.zeroOfCurrency(bwi.currency) + : Amounts.parseOrThrow(bwi.wireFee); async function doManagedWithdraw( exchange: string, @@ -290,9 +298,6 @@ export function useComponentStateFromURI({ transactionId: string; confirmTransferUrl: string | undefined; }> { - if (!txId) { - throw Error("can't confirm transaction"); - } const res = await api.wallet.call(WalletApiOperation.ConfirmWithdrawal, { exchangeBaseUrl: exchange, amount, @@ -305,12 +310,15 @@ export function useComponentStateFromURI({ }; } - if (uriInfoHook.response.txInfo && uriInfoHook.response.status !== "pending") { + if ( + uriInfoHook.response.txInfo && + uriInfoHook.response.status !== "pending" + ) { const info = uriInfoHook.response.txInfo; return { status: "already-completed", operationState: uriInfoHook.response.status, - confirmTransferUrl: uriInfoHook.response.confirmTransferUrl, + confirmTransferUrl: bwi.confirmTransferUrl, thisWallet: info.txState.major === TransactionMajorState.Pending, redirectToTx: () => onSuccess(info.transactionId), error: undefined, @@ -323,14 +331,32 @@ export function useComponentStateFromURI({ cancel, onSuccess, uri, - chosenAmount, - exchangeList, - defaultExchange, + { + amount, + bankFee, + maxAmount, + currency: bwi.currency, + editableAmount: bwi.editableAmount, + editableExchange: bwi.editableExchange, + exchange: bwi.defaultExchangeBaseUrl, + exchangeList: bwi.possibleExchanges, + }, setUpdatedExchangeByUser, ); }, []); } +type WithdrawalInfo = { + currency: string; + amount: AmountJson; + bankFee: AmountJson; + maxAmount: AmountJson; + editableAmount: boolean; + exchange: string | undefined; + editableExchange: boolean; + exchangeList: ExchangeListItem[]; +}; + type ManualOrManagedWithdrawFunction = ( exchange: string, ageRestricted: number | undefined, @@ -342,16 +368,14 @@ function exchangeSelectionState( cancel: () => Promise<void>, onSuccess: (txid: string) => Promise<void>, talerWithdrawUri: string | undefined, - chosenAmount: AmountJson, - exchangeList: ExchangeListItem[], - exchangeSuggestedByTheBank: string | undefined, + wInfo: WithdrawalInfo, onExchangeUpdated: (ex: string) => void, ): RecursiveState<State> { const api = useBackendContext(); const selectedExchange = useSelectedExchange({ - currency: chosenAmount.currency, - defaultExchange: exchangeSuggestedByTheBank, - list: exchangeList, + currency: wInfo.currency, + defaultExchange: wInfo.exchange, + list: wInfo.exchangeList, }); const current = @@ -364,6 +388,10 @@ function exchangeSelectionState( } }, [current]); + const safeAmount = wInfo.amount + ? wInfo.amount + : Amounts.zeroOfCurrency(wInfo.currency); + if (selectedExchange.status !== "ready") { return selectedExchange; } @@ -374,11 +402,12 @@ function exchangeSelectionState( | State.Loading => { const { i18n } = useTranslationContext(); const { pushAlertOnError } = useAlertContext(); + + const [choosenAmount, setChoosenAmount] = useState(safeAmount); const [ageRestricted, setAgeRestricted] = useState(0); - const currentExchange = selectedExchange.selected; const [selectedCurrency, setSelectedCurrency] = useState<string>( - chosenAmount.currency, + wInfo.currency, ); /** * With the exchange and amount, ask the wallet the information @@ -388,8 +417,8 @@ function exchangeSelectionState( const info = await api.wallet.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { - exchangeBaseUrl: currentExchange.exchangeBaseUrl, - amount: Amounts.stringify(chosenAmount), + exchangeBaseUrl: selectedExchange.selected.exchangeBaseUrl, + amount: Amounts.stringify(choosenAmount), restrictAge: ageRestricted, }, ); @@ -401,20 +430,40 @@ function exchangeSelectionState( return { amount: withdrawAmount, + currentExchange: selectedExchange.selected, ageRestrictionOptions: info.ageRestrictionOptions, accounts: info.withdrawalAccountsList, }; - }, []); + }, [choosenAmount, selectedExchange.selected, ageRestricted]); const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); + if (!amountHook) { + return { status: "loading", error: undefined }; + } + if (amountHook.hasError) { + return { + status: "error", + error: alertFromError( + i18n, + i18n.str`Could not load the withdrawal details`, + amountHook, + ), + }; + } + if (!amountHook.response) { + return { status: "loading", error: undefined }; + } + + const currentExchange = amountHook.response.currentExchange; + async function doWithdrawAndCheckError(): Promise<void> { try { setDoingWithdraw(true); const res = await doWithdraw( currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted, - Amounts.stringify(chosenAmount), + Amounts.stringify(choosenAmount), ); if (res.confirmTransferUrl) { document.location.href = res.confirmTransferUrl; @@ -429,32 +478,14 @@ function exchangeSelectionState( setDoingWithdraw(false); } - if (!amountHook) { - return { status: "loading", error: undefined }; - } - if (amountHook.hasError) { - return { - status: "error", - error: alertFromError( - i18n, - i18n.str`Could not load the withdrawal details`, - amountHook, - ), - }; - } - if (!amountHook.response) { - return { status: "loading", error: undefined }; - } - - const withdrawalFee = Amounts.sub( - amountHook.response.amount.raw, - amountHook.response.amount.effective, - ).amount; + const toBeSent = amountHook.response.amount.raw; const toBeReceived = amountHook.response.amount.effective; + const bankFee = wInfo.bankFee; + const ageRestrictionOptions = amountHook.response.ageRestrictionOptions?.reduce( - (p, c) => ({ ...p, [c]: `under ${c}` }), + (p, c) => ({ ...p, [c]: i18n.str`under ${c}` }), {} as Record<string, string>, ); @@ -495,28 +526,50 @@ function exchangeSelectionState( amount: Amounts.parseOrThrow(convAccount.transferAmount!), }; + const amountError = Amounts.isZero(choosenAmount) + ? i18n.str`should be greater than zero` + : Amounts.cmp(choosenAmount, wInfo.maxAmount) === -1 + ? i18n.str`choose a lower value` + : undefined; + return { status: "success", error: undefined, - doSelectExchange: selectedExchange.doSelect, + doSelectExchange: { + onClick: wInfo.editableExchange + ? selectedExchange.doSelect.onClick + : undefined, + }, + editableAmount: wInfo.editableAmount, + editableExchange: wInfo.editableExchange, currentExchange, toBeReceived, + toBeSent, chooseCurrencies, + bankFee, selectedCurrency, changeCurrency: (s) => { setSelectedCurrency(s); }, conversionInfo, - withdrawalFee, - chosenAmount, + amount: { + value: choosenAmount, + onInput: wInfo.editableAmount + ? pushAlertOnError(async (v) => { + setChoosenAmount(v); + }) + : undefined, + error: amountError, + }, talerWithdrawUri, ageRestriction, doWithdrawal: { - onClick: doingWithdraw - ? undefined - : pushAlertOnError(doWithdrawAndCheckError), + onClick: + doingWithdraw || amountError + ? undefined + : pushAlertOnError(doWithdrawAndCheckError), }, cancel, }; - }, []); + }, [selectedExchange.selected]); } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx index 29f39054f..d9b7c380e 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -43,17 +43,25 @@ const ageRestrictionSelectField = { export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 10000000, value: 1, @@ -70,34 +78,41 @@ export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { export const AlreadyAborted = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "aborted" + operationState: "aborted", }); export const AlreadySelected = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "selected" + operationState: "selected", }); export const AlreadyConfirmed = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "confirmed" + operationState: "confirmed", }); - export const WithSomeFee = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 10000000, value: 1, @@ -114,17 +129,25 @@ export const WithSomeFee = tests.createExample(SuccessView, { export const WithoutFee = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 0, + }, + }, + bankFee: { + currency: "EUR", fraction: 0, + value: 1, }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -141,17 +164,25 @@ export const WithoutFee = tests.createExample(SuccessView, { export const EditExchangeUntouched = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -168,17 +199,25 @@ export const EditExchangeUntouched = tests.createExample(SuccessView, { export const EditExchangeModified = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -196,18 +235,26 @@ export const WithAgeRestriction = tests.createExample(SuccessView, { error: undefined, status: "success", ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + doSelectExchange: {}, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -223,11 +270,19 @@ export const WithAgeRestriction = tests.createExample(SuccessView, { export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "NETZBON", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "NETZBON", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "NETZBON", doWithdrawal: { onClick: nullFunction }, @@ -235,7 +290,7 @@ export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, @@ -251,30 +306,38 @@ export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { export const WithAlternateCurrenciesEURO = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "NETZBON", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "NETZBON", + value: 2, + fraction: 10000000, + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "EUR", - changeCurrency: () => { }, + changeCurrency: () => {}, conversionInfo: { spec: { - name: "EUR" + name: "EUR", } as CurrencySpecification, amount: { currency: "EUR", fraction: 10000000, value: 1, - } + }, }, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, @@ -290,30 +353,37 @@ export const WithAlternateCurrenciesEURO = tests.createExample(SuccessView, { export const WithAlternateCurrenciesEURO11 = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "NETZBON", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "NETZBON", + value: 2, + fraction: 10000000, + }, }, chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "EUR", - changeCurrency: () => { }, + changeCurrency: () => {}, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, conversionInfo: { spec: { - name: "EUR" + name: "EUR", } as CurrencySpecification, amount: { currency: "EUR", fraction: 10000000, value: 2, - } + }, }, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index 860cf1099..5a75cb4be 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -26,6 +26,7 @@ import { ExchangeListItem, ExchangeTosStatus, ScopeType, + TransactionIdStr, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { expect } from "chai"; @@ -111,13 +112,18 @@ describe("Withdraw CTA states", () => { WalletApiOperation.PrepareBankIntegratedWithdrawal, undefined, { - transactionId: "123", + transactionId: "123" as TransactionIdStr, info: { status: "pending", operationId: "123", + currency: "ARS", amount: "EUR:2" as AmountString, possibleExchanges: [], - } + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", + }, }, ); @@ -152,14 +158,19 @@ describe("Withdraw CTA states", () => { WalletApiOperation.PrepareBankIntegratedWithdrawal, undefined, { - transactionId: "123", + transactionId: "123" as TransactionIdStr, info: { status: "pending", operationId: "123", + currency: "ARS", amount: "ARS:2" as AmountString, possibleExchanges: exchanges, defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl, - } + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", + }, }, ); handler.addWalletCallResponse( @@ -173,7 +184,7 @@ describe("Withdraw CTA states", () => { scopeInfo: { currency: "ARS", type: ScopeType.Exchange, - url: "http://asd" + url: "http://asd", }, withdrawalAccountsList: [], ageRestrictionOptions: [], @@ -197,8 +208,8 @@ describe("Withdraw CTA states", () => { if (state.status !== "success") return; expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.toBeSent).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.amount.value).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.doWithdrawal.onClick).not.undefined; }, @@ -229,9 +240,14 @@ describe("Withdraw CTA states", () => { { status: "pending", operationId: "123", + currency: "ARS", amount: "ARS:2" as AmountString, possibleExchanges: exchangeWithNewTos, defaultExchangeBaseUrl: exchangeWithNewTos[0].exchangeBaseUrl, + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", }, ); handler.addWalletCallResponse( @@ -244,7 +260,7 @@ describe("Withdraw CTA states", () => { scopeInfo: { currency: "ARS", type: ScopeType.Exchange, - url: "http://asd" + url: "http://asd", }, tosAccepted: false, withdrawalAccountsList: [], @@ -259,9 +275,14 @@ describe("Withdraw CTA states", () => { { status: "pending", operationId: "123", + currency: "ARS", amount: "ARS:2" as AmountString, possibleExchanges: exchanges, defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl, + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", }, ); @@ -281,8 +302,8 @@ describe("Withdraw CTA states", () => { if (state.status !== "success") return; expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.toBeSent).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.amount.value).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.doWithdrawal.onClick).not.undefined; }, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx index cdddd9bbc..b6a356de8 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -19,6 +19,7 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Amount } from "../../components/Amount.js"; import { AmountField } from "../../components/AmountField.js"; +import { EnabledBySettings } from "../../components/EnabledBySettings.js"; import { Part } from "../../components/Part.js"; import { QR } from "../../components/QR.js"; import { SelectList } from "../../components/SelectList.js"; @@ -38,7 +39,7 @@ import { getAmountWithFee, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; -import { EnabledBySettings } from "../../components/EnabledBySettings.js"; +import { Amounts } from "@gnu-taler/taler-util"; export function FinalStateOperation(state: State.AlreadyCompleted): VNode { const { i18n } = useTranslationContext(); @@ -143,8 +144,6 @@ export function FinalStateOperation(state: State.AlreadyCompleted): VNode { export function SuccessView(state: State.Success): VNode { const { i18n } = useTranslationContext(); - // const currentTosVersionIsAccepted = - // state.currentExchange.tosStatus === ExchangeTosStatus.Accepted; return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -174,6 +173,11 @@ export function SuccessView(state: State.Success): VNode { kind="neutral" big /> + {state.editableAmount ? ( + <Fragment> + <AmountField handler={state.amount} label={i18n.str`Amount`} /> + </Fragment> + ) : undefined} {state.chooseCurrencies.length > 0 ? ( <Fragment> <p> @@ -207,9 +211,10 @@ export function SuccessView(state: State.Success): VNode { conversion={state.conversionInfo?.amount} amount={getAmountWithFee( state.toBeReceived, - state.chosenAmount, + state.toBeSent, "credit", )} + bankFee={state.bankFee} /> } /> @@ -227,7 +232,6 @@ export function SuccessView(state: State.Success): VNode { </section> <section> - {/* <div> */} <TermsOfService exchangeUrl={state.currentExchange.exchangeBaseUrl}> <Button variant="contained" @@ -240,20 +244,6 @@ export function SuccessView(state: State.Success): VNode { </i18n.Translate> </Button> </TermsOfService> - {/* </div> - <div style={{ marginTop: 20 }}> - <Button - variant="text" - color="success" - - disabled={!state.doAbort.onClick} - onClick={state.doAbort.onClick} - > - <i18n.Translate> - Cancel - </i18n.Translate> - </Button> - </div> */} </section> {state.talerWithdrawUri ? ( <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} /> diff --git a/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts b/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts index 8d26bf3b6..719aa2f96 100644 --- a/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts +++ b/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts @@ -1,7 +1,21 @@ -import { codecForBoolean } from "@gnu-taler/taler-util"; -import { buildStorageKey, useMemoryStorage } from "@gnu-taler/web-util/browser"; -import { platform } from "../platform/foreground.js"; +/* + This file is part of GNU Taler + (C) 2022 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/> + */ +import { useMemoryStorage } from "@gnu-taler/web-util/browser"; import { useEffect } from "preact/hooks"; +import { platform } from "../platform/foreground.js"; export function useIsOnline(): boolean { const { value, update } = useMemoryStorage("online", true); diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts index e63040f5c..056351e3f 100644 --- a/packages/taler-wallet-webextension/src/platform/chrome.ts +++ b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -732,15 +732,35 @@ function listenNetworkConnectionState( function notifyOnline() { notify("on"); } - notify(window.navigator.onLine ? "on" : "off"); - window.addEventListener("offline", notifyOffline); - window.addEventListener("online", notifyOnline); + function notifyChange() { + if (nav.onLine) { + notifyOnline(); + } else { + notifyOnline(); + } + } + notify(navigator.onLine ? "on" : "off"); + + const nav: any = navigator; + if (typeof nav.connection !== "undefined") { + nav.connection.addEventListener("change", notifyChange); + } + if (typeof window !== "undefined") { + window.addEventListener("offline", notifyOffline); + window.addEventListener("online", notifyOnline); + } return () => { - window.removeEventListener("offline", notifyOffline); - window.removeEventListener("online", notifyOnline); + if (typeof nav.connection !== "undefined") { + nav.connection.removeEventListener("change", notifyChange); + } + if (typeof window !== "undefined") { + window.removeEventListener("offline", notifyOffline); + window.removeEventListener("online", notifyOnline); + } }; } + function runningOnPrivateMode(): boolean { return chrome.extension.inIncognitoContext; } diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts index d6e743147..b53e8f3c4 100644 --- a/packages/taler-wallet-webextension/src/platform/dev.ts +++ b/packages/taler-wallet-webextension/src/platform/dev.ts @@ -35,11 +35,11 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { keepAlive: (cb: VoidFunction) => cb(), findTalerUriInActiveTab: async () => undefined, findTalerUriInClipboard: async () => undefined, - listenNetworkConnectionState, + listenNetworkConnectionState: () => () => undefined, openNewURLFromPopup: () => undefined, triggerWalletEvent: () => undefined, setAlertedIcon: () => undefined, - setNormalIcon : () => undefined, + setNormalIcon: () => undefined, getPermissionsApi: () => ({ containsClipboardPermissions: async () => true, removeClipboardPermissions: async () => false, @@ -200,19 +200,3 @@ interface IframeMessageCommand { export default api; -function listenNetworkConnectionState( - notify: (state: "on" | "off") => void, -): () => void { - function notifyOffline() { - notify("off"); - } - function notifyOnline() { - notify("on"); - } - window.addEventListener("offline", notifyOffline); - window.addEventListener("online", notifyOnline); - return () => { - window.removeEventListener("offline", notifyOffline); - window.removeEventListener("online", notifyOnline); - }; -} diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx index 93770312e..73bd8e96d 100644 --- a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx @@ -180,7 +180,7 @@ export function BalanceView(state: State.Balances): VNode { variant="contained" onClick={state.goToWalletManualWithdraw.onClick} > - <i18n.Translate>Add</i18n.Translate> + <i18n.Translate>Receive</i18n.Translate> </Button> {currencyWithNonZeroAmount.length > 0 && ( <MultiActionButton diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts index 838739ad1..daba6aba4 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts @@ -94,9 +94,9 @@ export namespace State { currentAccount: PaytoUri; totalFee: AmountJson; - totalToDeposit: AmountJson; amount: AmountFieldHandler; + totalToDeposit: AmountFieldHandler; account: SelectFieldHandler; cancelHandler: ButtonHandler; depositHandler: ButtonHandler; diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts index 97b2ab517..b674665cf 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts @@ -15,19 +15,18 @@ */ import { - AmountJson, Amounts, - DepositGroupFees, KnownBankAccountsInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri, + TransactionAmountMode } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { RecursiveState } from "../../utils/index.js"; import { Props, State } from "./index.js"; @@ -83,8 +82,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", - error: alertFromError(i18n, - i18n.str`Could not load balance information`, hook), + error: alertFromError( + i18n, + i18n.str`Could not load balance information`, + hook, + ), }; } const { accounts, balances } = hook.response; @@ -141,21 +143,23 @@ export function useComponentState({ } const firstAccount = accounts[0].uri; const currentAccount = !selectedAccount ? firstAccount : selectedAccount; - - return () => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [amount, setAmount] = useState<AmountJson>( - initialValue ?? ({} as any), + const zero = Amounts.zeroOfCurrency(currency) + return (): State => { + const [instructed, setInstructed] = useState( + {amount: initialValue ?? zero, type: TransactionAmountMode.Raw}, ); - const amountStr = Amounts.stringify(amount); + const amountStr = Amounts.stringify(instructed.amount); const depositPaytoUri = stringifyPaytoUri(currentAccount); - // eslint-disable-next-line react-hooks/rules-of-hooks const hook = useAsyncAsHook(async () => { - const fee = await api.wallet.call(WalletApiOperation.PrepareDeposit, { - amount: amountStr, - depositPaytoUri, - }); + const fee = await api.wallet.call( + WalletApiOperation.ConvertDepositAmount, + { + amount: amountStr, + type: instructed.type, + depositPaytoUri, + }, + ); return { fee }; }, [amountStr, depositPaytoUri]); @@ -183,18 +187,16 @@ export function useComponentState({ const totalFee = fee !== undefined - ? Amounts.sum([fee.fees.wire, fee.fees.coin, fee.fees.refresh]).amount + ? Amounts.sub(fee.effectiveAmount, fee.rawAmount).amount : Amounts.zeroOfCurrency(currency); - const totalToDeposit = - fee !== undefined - ? Amounts.sub(amount, totalFee).amount - : Amounts.zeroOfCurrency(currency); + const totalToDeposit = Amounts.parseOrThrow(fee.rawAmount); + const totalEffective = Amounts.parseOrThrow(fee.effectiveAmount); - const isDirty = amount !== initialValue; + const isDirty = instructed.amount !== initialValue; const amountError = !isDirty ? undefined - : Amounts.cmp(balance, amount) === -1 + : Amounts.cmp(balance, totalEffective) === -1 ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}` : undefined; @@ -207,7 +209,7 @@ export function useComponentState({ if (!currency) return; const depositPaytoUri = stringifyPaytoUri(currentAccount); - const amountStr = Amounts.stringify(amount); + const amountStr = Amounts.stringify(totalEffective); await api.wallet.call(WalletApiOperation.CreateDepositGroup, { amount: amountStr, depositPaytoUri, @@ -220,8 +222,19 @@ export function useComponentState({ error: undefined, currency, amount: { - value: amount, - onInput: pushAlertOnError(async (a) => setAmount(a)), + value: totalEffective, + onInput: pushAlertOnError(async (a) => setInstructed({ + amount: a, + type: TransactionAmountMode.Effective, + })), + error: amountError, + }, + totalToDeposit: { + value: totalToDeposit, + onInput: pushAlertOnError(async (a) => setInstructed({ + amount: a, + type: TransactionAmountMode.Raw, + })), error: amountError, }, onAddAccount: { @@ -244,7 +257,6 @@ export function useComponentState({ onClick: unableToDeposit ? undefined : pushAlertOnError(doSend), }, totalFee, - totalToDeposit, }; }; } @@ -269,7 +281,7 @@ export function createLabelsForBankAccount( ): { [value: string]: string } { const initialList: Record<string, string> = {}; if (!knownBankAccounts.length) return initialList; - return knownBankAccounts.reduce((prev, cur, i) => { + return knownBankAccounts.reduce((prev, cur) => { prev[stringifyPaytoUri(cur.uri)] = cur.alias; return prev; }, initialList); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx index c23f83fdd..0ed62220b 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx @@ -53,7 +53,10 @@ export const WithNoAccountForIBAN = tests.createExample(ReadyView, { onClick: nullFunction, }, totalFee: Amounts.zeroOfCurrency("USD"), - totalToDeposit: Amounts.parseOrThrow("USD:10"), + totalToDeposit: { + onInput:nullFunction, + value: Amounts.parseOrThrow("USD:10"), + }, // onCalculateFee: alwaysReturnFeeToOne, }); @@ -82,7 +85,10 @@ export const WithIBANAccountTypeSelected = tests.createExample(ReadyView, { onClick: nullFunction, }, totalFee: Amounts.zeroOfCurrency("USD"), - totalToDeposit: Amounts.parseOrThrow("USD:10"), + totalToDeposit: { + onInput:nullFunction, + value: Amounts.parseOrThrow("USD:10"), + }, // onCalculateFee: alwaysReturnFeeToOne, }); @@ -111,6 +117,9 @@ export const NewBitcoinAccountTypeSelected = tests.createExample(ReadyView, { onClick: nullFunction, }, totalFee: Amounts.zeroOfCurrency("USD"), - totalToDeposit: Amounts.parseOrThrow("USD:10"), + totalToDeposit: { + onInput:nullFunction, + value: Amounts.parseOrThrow("USD:10"), + }, // onCalculateFee: alwaysReturnFeeToOne, }); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts index 157cb868a..1144095e1 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts @@ -20,17 +20,16 @@ */ import { + AmountResponse, Amounts, AmountString, - DepositGroupFees, parsePaytoUri, - PrepareDepositResponse, ScopeType, - stringifyPaytoUri, + stringifyPaytoUri } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { expect } from "chai"; import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; import { nullFunction } from "../../mui/handlers.js"; import { createWalletApiMock } from "../../test-utils.js"; @@ -38,24 +37,14 @@ import { useComponentState } from "./state.js"; const currency = "EUR"; const amount = `${currency}:0`; -const withoutFee = (): PrepareDepositResponse => ({ - effectiveDepositAmount: `${currency}:5` as AmountString, - totalDepositCost: `${currency}:5` as AmountString, - fees: { - coin: Amounts.stringify(`${currency}:0`), - wire: Amounts.stringify(`${currency}:0`), - refresh: Amounts.stringify(`${currency}:0`), - }, +const withoutFee = (value: number): AmountResponse => ({ + effectiveAmount: `${currency}:${value}` as AmountString, + rawAmount: `${currency}:${value}` as AmountString, }); -const withSomeFee = (): PrepareDepositResponse => ({ - effectiveDepositAmount: `${currency}:5` as AmountString, - totalDepositCost: `${currency}:5` as AmountString, - fees: { - coin: Amounts.stringify(`${currency}:1`), - wire: Amounts.stringify(`${currency}:1`), - refresh: Amounts.stringify(`${currency}:1`), - }, +const withSomeFee = (value: number, fee: number): AmountResponse => ({ + effectiveAmount: `${currency}:${value}` as AmountString, + rawAmount: `${currency}:${value - fee}` as AmountString, }); describe("DepositPage states", () => { @@ -195,9 +184,9 @@ describe("DepositPage states", () => { }, ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); const hookBehavior = await tests.hookBehaveLikeThis( @@ -255,15 +244,15 @@ describe("DepositPage states", () => { }, ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); const accountSelected = stringifyPaytoUri(ibanPayto.uri); @@ -345,19 +334,19 @@ describe("DepositPage states", () => { }, ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withSomeFee(), + withSomeFee(10,3), ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withSomeFee(), + withSomeFee(10,3), ); const accountSelected = stringifyPaytoUri(ibanPayto.uri); @@ -404,7 +393,7 @@ describe("DepositPage states", () => { expect(state.account.value).eq(accountSelected); expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:10")); expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); - expect(state.totalToDeposit).deep.eq( + expect(state.totalToDeposit.value).deep.eq( Amounts.parseOrThrow(`${currency}:7`), ); expect(state.depositHandler.onClick).not.undefined; @@ -416,7 +405,7 @@ describe("DepositPage states", () => { expect(state.account.value).eq(accountSelected); expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:10")); expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); - expect(state.totalToDeposit).deep.eq( + expect(state.totalToDeposit.value).deep.eq( Amounts.parseOrThrow(`${currency}:7`), ); expect(state.depositHandler.onClick).not.undefined; diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx index 908becb04..b3607ebba 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx @@ -26,7 +26,7 @@ import { Grid } from "../../mui/Grid.js"; import { State } from "./index.js"; export function AmountOrCurrencyErrorView( - p: State.AmountOrCurrencyError, + _p: State.AmountOrCurrencyError, ): VNode { const { i18n } = useTranslationContext(); @@ -145,7 +145,7 @@ export function ReadyView(state: State.Ready): VNode { </p> <Grid container spacing={2} columns={1}> <Grid item xs={1}> - <AmountField label={i18n.str`Amount`} handler={state.amount} /> + <AmountField label={i18n.str`Brut amount`} handler={state.amount} /> </Grid> <Grid item xs={1}> <AmountField @@ -156,12 +156,7 @@ export function ReadyView(state: State.Ready): VNode { /> </Grid> <Grid item xs={1}> - <AmountField - label={i18n.str`Total deposit`} - handler={{ - value: state.totalToDeposit, - }} - /> + <AmountField label={i18n.str`Net amount`} handler={state.totalToDeposit} /> </Grid> </Grid> </section> @@ -180,7 +175,7 @@ export function ReadyView(state: State.Ready): VNode { ) : ( <Button variant="contained" onClick={state.depositHandler.onClick}> <i18n.Translate> - Deposit {Amounts.stringifyValue(state.totalToDeposit)}{" "} + Deposit {Amounts.stringifyValue(state.totalToDeposit.value)}{" "} {state.currency} </i18n.Translate> </Button> diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx index 7b6ac8895..8f23c0685 100644 --- a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx @@ -17,13 +17,12 @@ import { AbsoluteTime, Amounts, - CoinDumpJson, CoinStatus, ExchangeTosStatus, LogLevel, NotificationType, ScopeType, - stringifyWithdrawExchange, + stringifyWithdrawExchange } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -52,7 +51,6 @@ import { Grid } from "../mui/Grid.js"; import { Paper } from "../mui/Paper.js"; import { TextField } from "../mui/TextField.js"; -type CoinsInfo = CoinDumpJson["coins"]; type CalculatedCoinfInfo = { // ageKeysCount: number | undefined; denom_value: number; @@ -68,15 +66,7 @@ type SplitedCoinInfo = { usable: CalculatedCoinfInfo[]; }; -export interface Props { - // FIXME: Pending operations don't exist anymore. -} - -function hashObjectId(o: any): string { - return JSON.stringify(o); -} - -export function DeveloperPage({}: Props): VNode { +export function DeveloperPage(): VNode { const { i18n } = useTranslationContext(); const [downloadedDatabase, setDownloadedDatabase] = useState< { time: Date; content: string } | undefined @@ -361,6 +351,7 @@ export function DeveloperPage({}: Props): VNode { <a href={new URL(`/keys`, e.exchangeBaseUrl).href} target="_blank" + rel="noreferrer" > {e.exchangeBaseUrl} </a> diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx index 7b80977f3..b995a44d0 100644 --- a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx @@ -130,7 +130,6 @@ export function ReadyView({ ))} </div> <div style={{ border: "1px solid gray", padding: 8, borderRadius: 5 }}> - --- {uri.value} --- <p> <CustomFieldByAccountType type={accountType.value as AccountType} diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index 1f0293352..ca5bc3756 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -1416,9 +1416,11 @@ export function TransferPickupDetails({ export function WithdrawDetails({ conversion, amount, + bankFee, }: { conversion?: AmountJson; amount: AmountWithFee; + bankFee?: AmountJson; }): VNode { const { i18n } = useTranslationContext(); @@ -1481,6 +1483,16 @@ export function WithdrawDetails({ </tr> </Fragment> )} + {!bankFee ? undefined : ( + <tr> + <td> + <i18n.Translate>Bank fee</i18n.Translate> + </td> + <td> + <Amount value={bankFee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + )} </PurchaseDetailsTable> ); } diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 4394a982f..47b466fcd 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -55,7 +55,7 @@ import { WalletActivityTrack } from "./wxBackend.js"; const logger = new Logger("wxApi"); -export const WALLET_CORE_SUPPORTED_VERSION = "4:0:0" +export const WALLET_CORE_SUPPORTED_VERSION = "5:0:0" export interface ExtendedPermissionsResponse { newValue: boolean; diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts index 5fa255f5d..a0b9f2908 100644 --- a/packages/taler-wallet-webextension/src/wxBackend.ts +++ b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -39,7 +39,7 @@ import { makeErrorDetail, openPromise, setGlobalLogLevelFromString, - setLogLevelFromString + setLogLevelFromString, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { @@ -92,7 +92,7 @@ async function resetDb(): Promise<void> { export type WalletActivityTrack = { id: number; - events: (WalletNotification & {when: AbsoluteTime})[]; + events: (WalletNotification & { when: AbsoluteTime })[]; start: AbsoluteTime; type: NotificationType; end: AbsoluteTime; @@ -107,130 +107,138 @@ function getUniqueId(): number { //FIXME: maybe circular buffer const activity: WalletActivityTrack[] = []; -function addNewWalletActivityNotification(list: WalletActivityTrack[], n: WalletNotification) { - const start = AbsoluteTime.now(); - const ev = {...n, when:start}; - switch (n.type) { +function convertWalletActivityNotification( + knownEvents: WalletActivityTrack[], + event: WalletNotification & { + when: AbsoluteTime; + }, +): WalletActivityTrack | undefined { + switch (event.type) { case NotificationType.BalanceChange: { - const groupId = `${n.type}:${n.hintTransactionId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.hintTransactionId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.BackupOperationError: { const groupId = ""; - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.TransactionStateTransition: { - const groupId = `${n.type}:${n.transactionId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.transactionId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.WithdrawalOperationTransition: { - return; + return undefined; } case NotificationType.ExchangeStateTransition: { - const groupId = `${n.type}:${n.exchangeBaseUrl}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.exchangeBaseUrl}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.Idle: { const groupId = ""; - list.push({ + return({ id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, }); - return; } case NotificationType.TaskObservabilityEvent: { - const groupId = `${n.type}:${n.taskId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.taskId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return({ id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, }); - return; } case NotificationType.RequestObservabilityEvent: { - const groupId = `${n.type}:${n.operation}:${n.requestId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.operation}:${event.requestId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return({ id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, }); - return; } } } +function addNewWalletActivityNotification( + list: WalletActivityTrack[], + n: WalletNotification, +) { + const start = AbsoluteTime.now(); + const ev = { ...n, when: start }; + const activity = convertWalletActivityNotification(list, ev); + if (activity) { + list.unshift(activity); // insert at start + } +} + async function getNotifications({ filter, }: { diff --git a/packages/web-util/package.json b/packages/web-util/package.json index 369b872b6..c6bf20160 100644 --- a/packages/web-util/package.json +++ b/packages/web-util/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/web-util", - "version": "0.10.7", + "version": "0.11.4", "description": "Generic helper functionality for GNU Taler Web Apps", "type": "module", "types": "./lib/index.node.d.ts", diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx index dbb38b474..4351da018 100644 --- a/packages/web-util/src/components/CopyButton.tsx +++ b/packages/web-util/src/components/CopyButton.tsx @@ -21,7 +21,7 @@ export function CopyButton({ class: clazz, children, getContent }: { children?: const [copied, setCopied] = useState(false); function copyText(): void { if (!navigator.clipboard && !window.isSecureContext) { - alert('clipboard is not available on insecure context (http)') + prompt("Clipboard is not available on insecure context (http).", getContent()); } if (navigator.clipboard) { navigator.clipboard.writeText(getContent() || ""); diff --git a/packages/web-util/src/index.build.ts b/packages/web-util/src/index.build.ts index c0c5fc179..2260ecb9a 100644 --- a/packages/web-util/src/index.build.ts +++ b/packages/web-util/src/index.build.ts @@ -305,8 +305,10 @@ export function computeConfig(params: BuildParams): esbuild.BuildOptions { /** * Build sources for prod environment */ -export function build(config: BuildParams) { - return esbuild.build(computeConfig(config)); +export async function build(config: BuildParams) { + const res = await esbuild.build(computeConfig(config)); + fs.writeFileSync(`${config.destination}/version.txt`, `${_package.version}`); + return res; } const LIVE_RELOAD_SCRIPT = |