diff options
author | Sebastian <sebasjm@gmail.com> | 2021-12-23 15:17:36 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2021-12-23 15:17:36 -0300 |
commit | 2e71117f59e0ae6106930e705ae6a54a9839281b (patch) | |
tree | a39856486a2801f56c65de245c871ce596f8ab16 | |
parent | b8200de6f6c5ab9be3ff9f556c8acda013e574c3 (diff) |
deposit from wallet webex: wip
18 files changed, 758 insertions, 119 deletions
diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index ced30e4db..4158dde9e 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -54,6 +54,7 @@ import { } from "./talerTypes.js"; import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js"; import { BackupRecovery } from "./backupTypes.js"; +import { PaytoUri } from "./payto.js"; /** * Response for the create reserve request to the wallet. @@ -525,6 +526,10 @@ export interface ExchangesListRespose { exchanges: ExchangeListItem[]; } +export interface KnownBankAccounts { + accounts: PaytoUri[]; +} + export interface ExchangeTos { acceptedVersion?: string; currentVersion?: string; @@ -737,12 +742,19 @@ export const codecForApplyRefundRequest = (): Codec<ApplyRefundRequest> => export interface GetWithdrawalDetailsForUriRequest { talerWithdrawUri: string; } - export const codecForGetWithdrawalDetailsForUri = (): Codec<GetWithdrawalDetailsForUriRequest> => buildCodecForObject<GetWithdrawalDetailsForUriRequest>() .property("talerWithdrawUri", codecForString()) .build("GetWithdrawalDetailsForUriRequest"); +export interface ListKnownBankAccountsRequest { + currency?: string; +} +export const codecForListKnownBankAccounts = (): Codec<ListKnownBankAccountsRequest> => + buildCodecForObject<ListKnownBankAccountsRequest>() + .property("currency", codecOptional(codecForString())) + .build("ListKnownBankAccountsRequest"); + export interface GetExchangeWithdrawalInfo { exchangeBaseUrl: string; amount: AmountJson; @@ -965,11 +977,23 @@ export const codecForAbortPayWithRefundRequest = (): Codec<AbortPayWithRefundReq .property("proposalId", codecForString()) .build("AbortPayWithRefundRequest"); +export interface GetFeeForDepositRequest { + depositPaytoUri: string; + amount: AmountString; +} + export interface CreateDepositGroupRequest { depositPaytoUri: string; - amount: string; + amount: AmountString; } + +export const codecForGetFeeForDeposit = (): Codec<GetFeeForDepositRequest> => + buildCodecForObject<GetFeeForDepositRequest>() + .property("amount", codecForAmountString()) + .property("depositPaytoUri", codecForString()) + .build("GetFeeForDepositRequest"); + export const codecForCreateDepositGroupRequest = (): Codec<CreateDepositGroupRequest> => buildCodecForObject<CreateDepositGroupRequest>() .property("amount", codecForAmountString()) diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts index 04bc2d9bc..b5987582a 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts @@ -162,7 +162,7 @@ async function myEddsaSign( export class CryptoImplementation { static enableTracing = false; - constructor(private primitiveWorker?: PrimitiveWorker) {} + constructor(private primitiveWorker?: PrimitiveWorker) { } /** * Create a pre-coin of the given denomination to be withdrawn from then given @@ -369,7 +369,7 @@ export class CryptoImplementation { sig: string, masterPub: string, ): boolean { - if (versionCurrent === 10) { + if (versionCurrent === 10 || versionCurrent === 11) { const paytoHash = hash(stringToBytes(paytoUri + "\0")); const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS) .put(paytoHash) diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index f90172a45..6d28c23e5 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -15,6 +15,7 @@ */ import { + AmountJson, Amounts, buildCodecForObject, canonicalJson, @@ -28,6 +29,7 @@ import { decodeCrock, DenomKeyType, durationFromSpec, + GetFeeForDepositRequest, getTimestampNow, Logger, NotificationType, @@ -35,6 +37,7 @@ import { TalerErrorDetails, Timestamp, timestampAddDuration, + timestampIsBetween, timestampTruncateToSecond, TrackDepositGroupRequest, TrackDepositGroupResponse, @@ -49,7 +52,7 @@ import { } from "@gnu-taler/taler-util"; import { DepositGroupRecord } from "../db.js"; import { guardOperationException } from "../errors.js"; -import { selectPayCoins } from "../util/coinSelection.js"; +import { PayCoinSelection, selectPayCoins } from "../util/coinSelection.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import { getExchangeDetails } from "./exchanges.js"; @@ -58,11 +61,11 @@ import { extractContractData, generateDepositPermissions, getCandidatePayCoins, - getEffectiveDepositAmount, getTotalPaymentCost, hashWire, hashWireLegacy, } from "./pay.js"; +import { getTotalRefreshCost } from "./refresh.js"; /** * Logger. @@ -342,6 +345,100 @@ export async function trackDepositGroup( }; } +export async function getFeeForDeposit( + ws: InternalWalletState, + req: GetFeeForDepositRequest, +): Promise<DepositFee> { + const p = parsePaytoUri(req.depositPaytoUri); + if (!p) { + throw Error("invalid payto URI"); + } + + const amount = Amounts.parseOrThrow(req.amount); + + const exchangeInfos: { url: string; master_pub: string }[] = []; + + await ws.db + .mktx((x) => ({ + exchanges: x.exchanges, + exchangeDetails: x.exchangeDetails, + })) + .runReadOnly(async (tx) => { + const allExchanges = await tx.exchanges.iter().toArray(); + for (const e of allExchanges) { + const details = await getExchangeDetails(tx, e.baseUrl); + if (!details) { + continue; + } + exchangeInfos.push({ + master_pub: details.masterPublicKey, + url: e.baseUrl, + }); + } + }); + + const timestamp = getTimestampNow(); + const timestampRound = timestampTruncateToSecond(timestamp); + // const noncePair = await ws.cryptoApi.createEddsaKeypair(); + // const merchantPair = await ws.cryptoApi.createEddsaKeypair(); + // const wireSalt = encodeCrock(getRandomBytes(16)); + // const wireHash = hashWire(req.depositPaytoUri, wireSalt); + // const wireHashLegacy = hashWireLegacy(req.depositPaytoUri, wireSalt); + const contractTerms: ContractTerms = { + auditors: [], + exchanges: exchangeInfos, + amount: req.amount, + max_fee: Amounts.stringify(amount), + max_wire_fee: Amounts.stringify(amount), + wire_method: p.targetType, + timestamp: timestampRound, + merchant_base_url: "", + summary: "", + nonce: "", + wire_transfer_deadline: timestampRound, + order_id: "", + h_wire: "", + pay_deadline: timestampAddDuration( + timestampRound, + durationFromSpec({ hours: 1 }), + ), + merchant: { + name: "", + }, + merchant_pub: "", + refund_deadline: { t_ms: 0 }, + }; + + const contractData = extractContractData( + contractTerms, + "", + "", + ); + + const candidates = await getCandidatePayCoins(ws, contractData); + + const payCoinSel = selectPayCoins({ + candidates, + contractTermsAmount: contractData.amount, + depositFeeLimit: contractData.maxDepositFee, + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: contractData.maxWireFee, + prevPayCoins: [], + }); + + if (!payCoinSel) { + throw Error("insufficient funds"); + } + + return await getTotalFeeForDepositAmount( + ws, + p.targetType, + amount, + payCoinSel, + ); + +} + export async function createDepositGroup( ws: InternalWalletState, req: CreateDepositGroupRequest, @@ -495,3 +592,152 @@ export async function createDepositGroup( return { depositGroupId }; } + +/** + * Get the amount that will be deposited on the merchant's bank + * account, not considering aggregation. + */ +export async function getEffectiveDepositAmount( + ws: InternalWalletState, + wireType: string, + pcs: PayCoinSelection, +): Promise<AmountJson> { + const amt: AmountJson[] = []; + const fees: AmountJson[] = []; + const exchangeSet: Set<string> = new Set(); + + await ws.db + .mktx((x) => ({ + coins: x.coins, + denominations: x.denominations, + exchanges: x.exchanges, + exchangeDetails: x.exchangeDetails, + })) + .runReadOnly(async (tx) => { + for (let i = 0; i < pcs.coinPubs.length; i++) { + const coin = await tx.coins.get(pcs.coinPubs[i]); + if (!coin) { + throw Error("can't calculate deposit amount, coin not found"); + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error("can't find denomination to calculate deposit amount"); + } + amt.push(pcs.coinContributions[i]); + fees.push(denom.feeDeposit); + exchangeSet.add(coin.exchangeBaseUrl); + } + + for (const exchangeUrl of exchangeSet.values()) { + const exchangeDetails = await getExchangeDetails(tx, exchangeUrl); + if (!exchangeDetails) { + continue; + } + + // FIXME/NOTE: the line below _likely_ throws exception + // about "find method not found on undefined" when the wireType + // is not supported by the Exchange. + const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => { + return timestampIsBetween( + getTimestampNow(), + x.startStamp, + x.endStamp, + ); + })?.wireFee; + if (fee) { + fees.push(fee); + } + } + }); + return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount; +} + +export interface DepositFee { + coin: AmountJson; + wire: AmountJson; + refresh: AmountJson; +} + +/** + * Get the fee amount that will be charged when trying to deposit the + * specified amount using the selected coins and the wire method. + */ +export async function getTotalFeeForDepositAmount( + ws: InternalWalletState, + wireType: string, + total: AmountJson, + pcs: PayCoinSelection, +): Promise<DepositFee> { + const wireFee: AmountJson[] = []; + const coinFee: AmountJson[] = []; + const refreshFee: AmountJson[] = []; + const exchangeSet: Set<string> = new Set(); + + // let acc: AmountJson = Amounts.getZero(total.currency); + + await ws.db + .mktx((x) => ({ + coins: x.coins, + denominations: x.denominations, + exchanges: x.exchanges, + exchangeDetails: x.exchangeDetails, + })) + .runReadOnly(async (tx) => { + for (let i = 0; i < pcs.coinPubs.length; i++) { + const coin = await tx.coins.get(pcs.coinPubs[i]); + if (!coin) { + throw Error("can't calculate deposit amount, coin not found"); + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error("can't find denomination to calculate deposit amount"); + } + // const cc = pcs.coinContributions[i] + // acc = Amounts.add(acc, cc).amount + coinFee.push(denom.feeDeposit); + exchangeSet.add(coin.exchangeBaseUrl); + + const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .filter((x) => + Amounts.isSameCurrency(x.value, pcs.coinContributions[i]), + ); + const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i]) + .amount; + const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft); + refreshFee.push(refreshCost); + } + + for (const exchangeUrl of exchangeSet.values()) { + const exchangeDetails = await getExchangeDetails(tx, exchangeUrl); + if (!exchangeDetails) { + continue; + } + // FIXME/NOTE: the line below _likely_ throws exception + // about "find method not found on undefined" when the wireType + // is not supported by the Exchange. + const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => { + return timestampIsBetween( + getTimestampNow(), + x.startStamp, + x.endStamp, + ); + })?.wireFee; + if (fee) { + wireFee.push(fee); + } + } + }); + + return { + coin: coinFee.length === 0 ? Amounts.getZero(total.currency) : Amounts.sum(coinFee).amount, + wire: wireFee.length === 0 ? Amounts.getZero(total.currency) : Amounts.sum(wireFee).amount, + refresh: refreshFee.length === 0 ? Amounts.getZero(total.currency) : Amounts.sum(refreshFee).amount + }; +} diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 63ccc6531..89930120d 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -177,66 +177,6 @@ export async function getTotalPaymentCost( }); } -/** - * Get the amount that will be deposited on the merchant's bank - * account, not considering aggregation. - */ -export async function getEffectiveDepositAmount( - ws: InternalWalletState, - wireType: string, - pcs: PayCoinSelection, -): Promise<AmountJson> { - const amt: AmountJson[] = []; - const fees: AmountJson[] = []; - const exchangeSet: Set<string> = new Set(); - - await ws.db - .mktx((x) => ({ - coins: x.coins, - denominations: x.denominations, - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - })) - .runReadOnly(async (tx) => { - for (let i = 0; i < pcs.coinPubs.length; i++) { - const coin = await tx.coins.get(pcs.coinPubs[i]); - if (!coin) { - throw Error("can't calculate deposit amount, coin not found"); - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error("can't find denomination to calculate deposit amount"); - } - amt.push(pcs.coinContributions[i]); - fees.push(denom.feeDeposit); - exchangeSet.add(coin.exchangeBaseUrl); - } - for (const exchangeUrl of exchangeSet.values()) { - const exchangeDetails = await getExchangeDetails(tx, exchangeUrl); - if (!exchangeDetails) { - continue; - } - // FIXME/NOTE: the line below _likely_ throws exception - // about "find method not found on undefined" when the wireType - // is not supported by the Exchange. - const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => { - return timestampIsBetween( - getTimestampNow(), - x.startStamp, - x.endStamp, - ); - })?.wireFee; - if (fee) { - fees.push(fee); - } - } - }); - return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount; -} - function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean { if (coin.suspended) { return false; @@ -585,8 +525,7 @@ async function incrementPurchasePayRetry( pr.payRetryInfo.retryCounter++; updateRetryInfoTimeout(pr.payRetryInfo); logger.trace( - `retrying pay in ${ - getDurationRemaining(pr.payRetryInfo.nextRetry).d_ms + `retrying pay in ${getDurationRemaining(pr.payRetryInfo.nextRetry).d_ms } ms`, ); pr.lastPayError = err; diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 445c0539a..0555b0ced 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -83,6 +83,7 @@ export enum WalletApiOperation { AddExchange = "addExchange", GetTransactions = "getTransactions", ListExchanges = "listExchanges", + ListKnownBankAccounts = "listKnownBankAccounts", GetWithdrawalDetailsForUri = "getWithdrawalDetailsForUri", GetWithdrawalDetailsForAmount = "getWithdrawalDetailsForAmount", AcceptManualWithdrawal = "acceptManualWithdrawal", @@ -279,11 +280,11 @@ export type WalletOperations = { export type RequestType< Op extends WalletApiOperation & keyof WalletOperations -> = WalletOperations[Op] extends { request: infer T } ? T : never; + > = WalletOperations[Op] extends { request: infer T } ? T : never; export type ResponseType< Op extends WalletApiOperation & keyof WalletOperations -> = WalletOperations[Op] extends { response: infer T } ? T : never; + > = WalletOperations[Op] extends { response: infer T } ? T : never; export interface WalletCoreApiClient { call<Op extends WalletApiOperation & keyof WalletOperations>( diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index ed0046c59..2f94d5e82 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -41,6 +41,10 @@ import { codecForWithdrawFakebankRequest, URL, parsePaytoUri, + KnownBankAccounts, + PaytoUri, + codecForGetFeeForDeposit, + codecForListKnownBankAccounts, } from "@gnu-taler/taler-util"; import { addBackupProvider, @@ -58,6 +62,7 @@ import { exportBackup } from "./operations/backup/export.js"; import { getBalances } from "./operations/balance.js"; import { createDepositGroup, + getFeeForDeposit, processDepositGroup, trackDepositGroup, } from "./operations/deposits.js"; @@ -495,6 +500,30 @@ async function getExchangeTos( }; } +async function listKnownBankAccounts( + ws: InternalWalletState, + currency?: string, +): Promise<KnownBankAccounts> { + const accounts: PaytoUri[] = [] + await ws.db + .mktx((x) => ({ + reserves: x.reserves, + })) + .runReadOnly(async (tx) => { + const reservesRecords = await tx.reserves.iter().toArray() + for (const r of reservesRecords) { + if (currency && currency !== r.currency) { + continue + } + const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined + if (payto) { + accounts.push(payto) + } + } + }) + return { accounts } +} + async function getExchanges( ws: InternalWalletState, ): Promise<ExchangesListRespose> { @@ -728,6 +757,10 @@ async function dispatchRequestInternal( case "listExchanges": { return await getExchanges(ws); } + case "listKnownBankAccounts": { + const req = codecForListKnownBankAccounts().decode(payload); + return await listKnownBankAccounts(ws, req.currency); + } case "getWithdrawalDetailsForUri": { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); @@ -881,6 +914,10 @@ async function dispatchRequestInternal( const resp = await getBackupInfo(ws); return resp; } + case "getFeeForDeposit": { + const req = codecForGetFeeForDeposit().decode(payload); + return await getFeeForDeposit(ws, req); + } case "createDepositGroup": { const req = codecForCreateDepositGroupRequest().decode(payload); return await createDepositGroup(ws, req); @@ -1004,7 +1041,7 @@ export async function handleCoreApiRequest( try { logger.error("Caught unexpected exception:"); logger.error(e.stack); - } catch (e) {} + } catch (e) { } return { type: "error", operation, diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx index 8dc73efdb..e7108679c 100644 --- a/packages/taler-wallet-webextension/src/NavigationBar.tsx +++ b/packages/taler-wallet-webextension/src/NavigationBar.tsx @@ -34,6 +34,7 @@ export enum Pages { welcome = "/welcome", balance = "/balance", manual_withdraw = "/manual-withdraw", + deposit = "/deposit/:currency", settings = "/settings", dev = "/dev", cta = "/cta", diff --git a/packages/taler-wallet-webextension/src/components/BalanceTable.tsx b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx index e1c19cc23..cf396e129 100644 --- a/packages/taler-wallet-webextension/src/components/BalanceTable.tsx +++ b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx @@ -16,9 +16,18 @@ import { amountFractionalBase, Amounts, Balance } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; -import { TableWithRoundRows as TableWithRoundedRows } from "./styled/index"; +import { + ButtonPrimary, + TableWithRoundRows as TableWithRoundedRows, +} from "./styled/index"; -export function BalanceTable({ balances }: { balances: Balance[] }): VNode { +export function BalanceTable({ + balances, + goToWalletDeposit, +}: { + balances: Balance[]; + goToWalletDeposit: (currency: string) => void; +}): VNode { const currencyFormatter = new Intl.NumberFormat("en-US"); return ( <TableWithRoundedRows> @@ -40,6 +49,11 @@ export function BalanceTable({ balances }: { balances: Balance[] }): VNode { > {v} </td> + <td> + <ButtonPrimary onClick={() => goToWalletDeposit(av.currency)}> + Deposit + </ButtonPrimary> + </td> </tr> ); })} diff --git a/packages/taler-wallet-webextension/src/components/styled/index.tsx b/packages/taler-wallet-webextension/src/components/styled/index.tsx index a5c9f2837..216a1fabc 100644 --- a/packages/taler-wallet-webextension/src/components/styled/index.tsx +++ b/packages/taler-wallet-webextension/src/components/styled/index.tsx @@ -716,6 +716,10 @@ export const InputWithLabel = styled.div<{ invalid?: boolean }>` } `; +export const ErrorText = styled.div` + color: red; +`; + export const ErrorBox = styled.div` border: 2px solid #f5c6cb; border-radius: 0.25em; diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx index 33164783d..40499b87c 100644 --- a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx @@ -21,18 +21,21 @@ import { ButtonPrimary, ErrorBox } from "../components/styled/index"; import { HookResponse, useAsyncAsHook } from "../hooks/useAsyncAsHook"; import { PageLink } from "../renderHtml"; import * as wxApi from "../wxApi"; - +interface Props { + goToWalletDeposit: (currency: string) => void; + goToWalletManualWithdraw: () => void; +} export function BalancePage({ goToWalletManualWithdraw, -}: { - goToWalletManualWithdraw: () => void; -}): VNode { + goToWalletDeposit, +}: Props): VNode { const state = useAsyncAsHook(wxApi.getBalance); return ( <BalanceView balance={state} Linker={PageLink} goToWalletManualWithdraw={goToWalletManualWithdraw} + goToWalletDeposit={goToWalletDeposit} /> ); } @@ -40,12 +43,14 @@ export interface BalanceViewProps { balance: HookResponse<BalancesResponse>; Linker: typeof PageLink; goToWalletManualWithdraw: () => void; + goToWalletDeposit: (currency: string) => void; } export function BalanceView({ balance, Linker, goToWalletManualWithdraw, + goToWalletDeposit, }: BalanceViewProps): VNode { if (!balance) { return <div>Loading...</div>; @@ -71,7 +76,8 @@ export function BalanceView({ <Linker pageName="/welcome">help</Linker> getting started? </i18n.Translate> </p> - <footer style={{ justifyContent: "space-around" }}> + <footer style={{ justifyContent: "space-between" }}> + <div /> <ButtonPrimary onClick={goToWalletManualWithdraw}> Withdraw </ButtonPrimary> @@ -83,9 +89,13 @@ export function BalanceView({ return ( <Fragment> <section> - <BalanceTable balances={balance.response.balances} /> + <BalanceTable + balances={balance.response.balances} + goToWalletDeposit={goToWalletDeposit} + /> </section> - <footer style={{ justifyContent: "space-around" }}> + <footer style={{ justifyContent: "space-between" }}> + <div /> <ButtonPrimary onClick={goToWalletManualWithdraw}> Withdraw </ButtonPrimary> diff --git a/packages/taler-wallet-webextension/src/popup/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/popup/DeveloperPage.tsx index b32555248..b689004cc 100644 --- a/packages/taler-wallet-webextension/src/popup/DeveloperPage.tsx +++ b/packages/taler-wallet-webextension/src/popup/DeveloperPage.tsx @@ -43,14 +43,17 @@ export function DeveloperPage(): VNode { ? [] : operationsResponse.response.pendingOperations; - return <View status={status} - timedOut={timedOut} - operations={operations} - onDownloadDatabase={async () => { - const db = await wxApi.exportDB() - return JSON.stringify(db) - }} - />; + return ( + <View + status={status} + timedOut={timedOut} + operations={operations} + onDownloadDatabase={async () => { + const db = await wxApi.exportDB(); + return JSON.stringify(db); + }} + /> + ); } export interface Props { @@ -64,14 +67,21 @@ function hashObjectId(o: any): string { return JSON.stringify(o); } -export function View({ status, timedOut, operations, onDownloadDatabase }: Props): VNode { - const [downloadedDatabase, setDownloadedDatabase] = useState<{time: Date; content: string}|undefined>(undefined) +export function View({ + status, + timedOut, + operations, + onDownloadDatabase, +}: Props): VNode { + const [downloadedDatabase, setDownloadedDatabase] = useState< + { time: Date; content: string } | undefined + >(undefined); async function onExportDatabase(): Promise<void> { - const content = await onDownloadDatabase() + const content = await onDownloadDatabase(); setDownloadedDatabase({ time: new Date(), - content - }) + content, + }); } return ( <div> @@ -83,9 +93,27 @@ export function View({ status, timedOut, operations, onDownloadDatabase }: Props <button onClick={confirmReset}>reset</button> <br /> <button onClick={onExportDatabase}>export database</button> - {downloadedDatabase && <div> - Database exported at <Time timestamp={{t_ms: downloadedDatabase.time.getTime()}} format="yyyy/MM/dd HH:mm:ss" /> <a href={`data:text/plain;charset=utf-8;base64,${btoa(downloadedDatabase.content)}`} download={`taler-wallet-database-${format(downloadedDatabase.time, 'yyyy/MM/dd_HH:mm')}.json`}>click here</a> to download - </div>} + {downloadedDatabase && ( + <div> + Database exported at + <Time + timestamp={{ t_ms: downloadedDatabase.time.getTime() }} + format="yyyy/MM/dd HH:mm:ss" + /> + <a + href={`data:text/plain;charset=utf-8;base64,${toBase64( + downloadedDatabase.content, + )}`} + download={`taler-wallet-database-${format( + downloadedDatabase.time, + "yyyy/MM/dd_HH:mm", + )}.json`} + > + click here + </a> + to download + </div> + )} <br /> <Diagnostics diagnostics={status} timedOut={timedOut} /> {operations && operations.length > 0 && ( @@ -109,6 +137,14 @@ export function View({ status, timedOut, operations, onDownloadDatabase }: Props ); } +function toBase64(str: string): string { + return btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { + return String.fromCharCode(parseInt(p1, 16)); + }), + ); +} + export function reload(): void { try { // eslint-disable-next-line no-undef diff --git a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx index 568913753..ac1872fb1 100644 --- a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx +++ b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx @@ -84,6 +84,9 @@ function Application() { goToWalletManualWithdraw={() => goToWalletPage(Pages.manual_withdraw) } + goToWalletDeposit={(currency: string) => + goToWalletPage(Pages.deposit.replace(":currency", currency)) + } /> <Route path={Pages.settings} component={SettingsPage} /> <Route @@ -107,6 +110,7 @@ function Application() { /> <Route path={Pages.history} component={HistoryPage} /> + <Route path={Pages.backup} component={BackupPage} diff --git a/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx b/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx index 0a8910646..52edbbe51 100644 --- a/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx @@ -24,7 +24,9 @@ import * as wxApi from "../wxApi"; export function BalancePage({ goToWalletManualWithdraw, + goToWalletDeposit, }: { + goToWalletDeposit: (currency: string) => void; goToWalletManualWithdraw: () => void; }): VNode { const state = useAsyncAsHook(wxApi.getBalance); @@ -33,6 +35,7 @@ export function BalancePage({ balance={state} Linker={PageLink} goToWalletManualWithdraw={goToWalletManualWithdraw} + goToWalletDeposit={goToWalletDeposit} /> ); } @@ -41,12 +44,14 @@ export interface BalanceViewProps { balance: HookResponse<BalancesResponse>; Linker: typeof PageLink; goToWalletManualWithdraw: () => void; + goToWalletDeposit: (currency: string) => void; } export function BalanceView({ balance, Linker, goToWalletManualWithdraw, + goToWalletDeposit, }: BalanceViewProps): VNode { if (!balance) { return <div>Loading...</div>; @@ -65,28 +70,35 @@ export function BalanceView({ } if (balance.response.balances.length === 0) { return ( - <p> - <Centered style={{ marginTop: 100 }}> - <i18n.Translate> - You have no balance to show. Need some{" "} - <Linker pageName="/welcome">help</Linker> getting started? - </i18n.Translate> - <div> - <ButtonPrimary onClick={goToWalletManualWithdraw}> - Withdraw - </ButtonPrimary> - </div> - </Centered> - </p> + <Fragment> + <p> + <Centered style={{ marginTop: 100 }}> + <i18n.Translate> + You have no balance to show. Need some{" "} + <Linker pageName="/welcome">help</Linker> getting started? + </i18n.Translate> + </Centered> + </p> + <footer style={{ justifyContent: "space-between" }}> + <div /> + <ButtonPrimary onClick={goToWalletManualWithdraw}> + Withdraw + </ButtonPrimary> + </footer> + </Fragment> ); } return ( <Fragment> <section> - <BalanceTable balances={balance.response.balances} /> + <BalanceTable + balances={balance.response.balances} + goToWalletDeposit={goToWalletDeposit} + /> </section> - <footer style={{ justifyContent: "space-around" }}> + <footer style={{ justifyContent: "space-between" }}> + <div /> <ButtonPrimary onClick={goToWalletManualWithdraw}> Withdraw </ButtonPrimary> diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx new file mode 100644 index 000000000..346b85d4f --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx @@ -0,0 +1,52 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AmountJson, Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; +import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; +import { createExample } from "../test-utils"; +import { View as TestedComponent } from "./DepositPage"; + +export default { + title: "wallet/deposit", + component: TestedComponent, + argTypes: {}, +}; + +async function alwaysReturnFeeToOne(): Promise<DepositFee> { + const fee = { + currency: "EUR", + value: 1, + fraction: 0, + }; + return { coin: fee, refresh: fee, wire: fee }; +} + +export const WithEmptyAccountList = createExample(TestedComponent, { + knownBankAccounts: [], + balance: Amounts.parseOrThrow("USD:10"), + onCalculateFee: alwaysReturnFeeToOne, +}); + +export const WithSomeBankAccounts = createExample(TestedComponent, { + knownBankAccounts: [parsePaytoUri("payto://iban/ES8877998399652238")!], + balance: Amounts.parseOrThrow("EUR:10"), + onCalculateFee: alwaysReturnFeeToOne, +}); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx new file mode 100644 index 000000000..d4759c537 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx @@ -0,0 +1,234 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +import { + AmountJson, + Amounts, + AmountString, + PaytoUri, +} from "@gnu-taler/taler-util"; +import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { Part } from "../components/Part"; +import { SelectList } from "../components/SelectList"; +import { + ButtonPrimary, + ErrorText, + Input, + InputWithLabel, +} from "../components/styled"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook"; +import * as wxApi from "../wxApi"; + +interface Props { + currency: string; +} +export function DepositPage({ currency }: Props): VNode { + const [success, setSuccess] = useState(false); + + const state = useAsyncAsHook(async () => { + const balance = await wxApi.getBalance(); + const bs = balance.balances.filter((b) => b.available.startsWith(currency)); + const currencyBalance = + bs.length === 0 + ? Amounts.getZero(currency) + : Amounts.parseOrThrow(bs[0].available); + const knownAccounts = await wxApi.listKnownBankAccounts(currency); + return { accounts: knownAccounts.accounts, currencyBalance }; + }); + + const accounts = + state === undefined ? [] : state.hasError ? [] : state.response.accounts; + + const currencyBalance = + state === undefined + ? Amounts.getZero(currency) + : state.hasError + ? Amounts.getZero(currency) + : state.response.currencyBalance; + + async function doSend(account: string, amount: AmountString): Promise<void> { + await wxApi.createDepositGroup(account, amount); + setSuccess(true); + } + + async function getFeeForAmount( + account: string, + amount: AmountString, + ): Promise<DepositFee> { + return await wxApi.getFeeForDeposit(account, amount); + } + + if (accounts.length === 0) return <div>loading..</div>; + if (success) return <div>deposit created</div>; + return ( + <View + knownBankAccounts={accounts} + balance={currencyBalance} + onSend={doSend} + onCalculateFee={getFeeForAmount} + /> + ); +} + +interface ViewProps { + knownBankAccounts: Array<PaytoUri>; + balance: AmountJson; + onSend: (account: string, amount: AmountString) => Promise<void>; + onCalculateFee: ( + account: string, + amount: AmountString, + ) => Promise<DepositFee>; +} + +export function View({ + knownBankAccounts, + balance, + onSend, + onCalculateFee, +}: ViewProps): VNode { + const accountMap = createLabelsForBankAccount(knownBankAccounts); + const [accountIdx, setAccountIdx] = useState(0); + const [amount, setAmount] = useState<number | undefined>(undefined); + const [fee, setFee] = useState<DepositFee | undefined>(undefined); + const currency = balance.currency; + const amountStr: AmountString = `${currency}:${amount}`; + + const account = knownBankAccounts[accountIdx]; + const accountURI = `payto://${account.targetType}/${account.targetPath}`; + useEffect(() => { + if (amount === undefined) return; + onCalculateFee(accountURI, amountStr).then((result) => { + setFee(result); + }); + }, [amount]); + + if (!balance) { + return <div>no balance</div>; + } + if (!knownBankAccounts || !knownBankAccounts.length) { + return <div>there is no known bank account to send money to</div>; + } + const parsedAmount = + amount === undefined ? undefined : Amounts.parse(amountStr); + const isDirty = amount !== 0; + const error = !isDirty + ? undefined + : !parsedAmount + ? "Invalid amount" + : Amounts.cmp(balance, parsedAmount) === -1 + ? `To much, your current balance is ${balance.value}` + : undefined; + + return ( + <Fragment> + <h2>Send {currency} to your account</h2> + <section> + <Input> + <SelectList + label="Bank account IBAN number" + list={accountMap} + name="account" + value={String(accountIdx)} + onChange={(s) => setAccountIdx(parseInt(s, 10))} + /> + </Input> + <InputWithLabel invalid={!!error}> + <label>Amount to send</label> + <div> + <span>{currency}</span> + <input + type="number" + value={amount} + onInput={(e) => { + const num = parseFloat(e.currentTarget.value); + console.log(num); + if (!Number.isNaN(num)) { + setAmount(num); + } else { + setAmount(undefined); + setFee(undefined); + } + }} + /> + </div> + {error && <ErrorText>{error}</ErrorText>} + </InputWithLabel> + {!error && fee && ( + <div style={{ textAlign: "center" }}> + <Part + title="Exchange fee" + text={Amounts.stringify(Amounts.sum([fee.wire, fee.coin]).amount)} + kind="negative" + /> + <Part + title="Change cost" + text={Amounts.stringify(fee.refresh)} + kind="negative" + /> + {parsedAmount && ( + <Part + title="Total received" + text={Amounts.stringify( + Amounts.sub( + parsedAmount, + Amounts.sum([fee.wire, fee.coin]).amount, + ).amount, + )} + kind="positive" + /> + )} + </div> + )} + </section> + <footer> + <div /> + <ButtonPrimary + disabled={!parsedAmount} + onClick={() => onSend(accountURI, amountStr)} + > + Send + </ButtonPrimary> + </footer> + </Fragment> + ); +} + +function createLabelsForBankAccount(knownBankAccounts: Array<PaytoUri>): { + [label: number]: string; +} { + if (!knownBankAccounts) return {}; + return knownBankAccounts.reduce((prev, cur, i) => { + let label = cur.targetPath; + if (cur.isKnown) { + switch (cur.targetType) { + case "x-taler-bank": { + label = cur.account; + break; + } + case "iban": { + label = cur.iban; + break; + } + } + } + return { + ...prev, + [i]: label, + }; + }, {} as { [label: number]: string }); +} diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index 22947d0c4..8172e02a2 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -369,8 +369,8 @@ export function TransactionView({ if (transaction.type === TransactionType.Deposit) { const fee = Amounts.sub( - Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), + Amounts.parseOrThrow(transaction.amountRaw), ).amount; return ( <TransactionTemplate> @@ -379,15 +379,15 @@ export function TransactionView({ <br /> <Part big - title="Total deposit" + title="Total send" text={amountToString(transaction.amountEffective)} - kind="negative" + kind="neutral" /> <Part big - title="Purchase amount" + title="Deposit amount" text={amountToString(transaction.amountRaw)} - kind="neutral" + kind="positive" /> <Part big title="Fee" text={amountToString(fee)} kind="negative" /> </TransactionTemplate> diff --git a/packages/taler-wallet-webextension/src/walletEntryPoint.tsx b/packages/taler-wallet-webextension/src/walletEntryPoint.tsx index 714e3fe5a..a38add3ca 100644 --- a/packages/taler-wallet-webextension/src/walletEntryPoint.tsx +++ b/packages/taler-wallet-webextension/src/walletEntryPoint.tsx @@ -45,6 +45,7 @@ import { WalletBox } from "./components/styled"; import { ProviderDetailPage } from "./wallet/ProviderDetailPage"; import { ProviderAddPage } from "./wallet/ProviderAddPage"; import { ExchangeAddPage } from "./wallet/ExchangeAddPage"; +import { DepositPage } from "./wallet/DepositPage"; function main(): void { try { @@ -105,6 +106,9 @@ function Application(): VNode { path={Pages.balance} component={withLogoAndNavBar(BalancePage)} goToWalletManualWithdraw={() => route(Pages.manual_withdraw)} + goToWalletDeposit={(currency: string) => + route(Pages.deposit.replace(":currency", currency)) + } /> <Route path={Pages.settings} @@ -146,6 +150,10 @@ function Application(): VNode { /> <Route + path={Pages.deposit} + component={withLogoAndNavBar(DepositPage)} + /> + <Route path={Pages.reset_required} component={() => <div>no yet implemented</div>} /> diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 4d8b932d3..64a506c13 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -24,10 +24,11 @@ import { AcceptExchangeTosRequest, AcceptManualWithdrawalResult, AcceptTipRequest, AcceptWithdrawalResponse, - AddExchangeRequest, ApplyRefundResponse, BalancesResponse, ConfirmPayResult, - CoreApiResponse, DeleteTransactionRequest, ExchangesListRespose, + AddExchangeRequest, AmountJson, AmountString, ApplyRefundResponse, BalancesResponse, ConfirmPayResult, + CoreApiResponse, CreateDepositGroupRequest, CreateDepositGroupResponse, DeleteTransactionRequest, ExchangesListRespose, GetExchangeTosResult, GetExchangeWithdrawalInfo, - GetWithdrawalDetailsForUriRequest, NotificationType, PreparePayResult, PrepareTipRequest, + GetFeeForDepositRequest, + GetWithdrawalDetailsForUriRequest, KnownBankAccounts, NotificationType, PreparePayResult, PrepareTipRequest, PrepareTipResult, RetryTransactionRequest, SetWalletDeviceIdRequest, TransactionsResponse, WalletDiagnostics, WithdrawUriInfoResponse } from "@gnu-taler/taler-util"; @@ -36,6 +37,7 @@ import { PendingOperationsResponse, RemoveBackupProviderRequest } from "@gnu-taler/taler-wallet-core"; +import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw"; import { MessageFromBackend } from "./wxBackend.js"; @@ -119,6 +121,18 @@ export function resetDb(): Promise<void> { return callBackend("reset-db", {}); } +export function getFeeForDeposit(depositPaytoUri: string, amount: AmountString): Promise<DepositFee> { + return callBackend("getFeeForDeposit", { + depositPaytoUri, amount + } as GetFeeForDepositRequest); +} + +export function createDepositGroup(depositPaytoUri: string, amount: AmountString): Promise<CreateDepositGroupResponse> { + return callBackend("createDepositGroup", { + depositPaytoUri, amount + } as CreateDepositGroupRequest); +} + /** * Get balances for all currencies/exchanges. */ @@ -170,6 +184,9 @@ export function listKnownCurrencies(): Promise<ListOfKnownCurrencies> { export function listExchanges(): Promise<ExchangesListRespose> { return callBackend("listExchanges", {}); } +export function listKnownBankAccounts(currency?: string): Promise<KnownBankAccounts> { + return callBackend("listKnownBankAccounts", { currency }); +} /** * Get information about the current state of wallet backups. |