/* 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 */ /** * Client for the Taler (demo-)bank. */ /** * Imports. */ import { AmountString, base64FromArrayBuffer, buildCodecForObject, Codec, codecForAny, codecForString, encodeCrock, getRandomBytes, HttpStatusCode, j2s, Logger, opEmptySuccess, opKnownHttpFailure, opUnknownFailure, stringToBytes, TalerError, TalerErrorCode, } from "@gnu-taler/taler-util"; import { checkSuccessResponseOrThrow, createPlatformHttpLib, HttpRequestLibrary, readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; const logger = new Logger("bank-api-client.ts"); export enum CreditDebitIndicator { Credit = "credit", Debit = "debit", } export interface BankAccountBalanceResponse { balance: { amount: AmountString; credit_debit_indicator: CreditDebitIndicator; }; } export interface BankUser { username: string; password: string; accountPaytoUri: string; } export interface WithdrawalOperationInfo { withdrawal_id: string; taler_withdraw_uri: string; } /** * Helper function to generate the "Authorization" HTTP header. */ function makeBasicAuthHeader(username: string, password: string): string { const auth = `${username}:${password}`; const authEncoded: string = base64FromArrayBuffer(stringToBytes(auth)); return `Basic ${authEncoded}`; } const codecForWithdrawalOperationInfo = (): Codec => buildCodecForObject() .property("withdrawal_id", codecForString()) .property("taler_withdraw_uri", codecForString()) .build("WithdrawalOperationInfo"); export interface BankAccessApiClientArgs { auth?: { username: string; password: string }; httpClient?: HttpRequestLibrary; } export interface BankAccessApiCreateTransactionRequest { amount: AmountString; paytoUri: string; } export class WireGatewayApiClientArgs { auth?: { username: string; password: string; }; httpClient?: HttpRequestLibrary; } /** * This API look like it belongs to harness * but it will be nice to have in utils to be used by others */ export class WireGatewayApiClient { httpLib; constructor( private baseUrl: string, private args: WireGatewayApiClientArgs = {}, ) { this.httpLib = args.httpClient ?? createPlatformHttpLib(); } private makeAuthHeader(): Record { const auth = this.args.auth; if (auth) { return { Authorization: makeBasicAuthHeader(auth.username, auth.password), }; } return {}; } async adminAddIncoming(params: { amount: string; reservePub: string; debitAccountPayto: string; }): Promise { let url = new URL(`admin/add-incoming`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: { amount: params.amount, reserve_pub: params.reservePub, debit_account: params.debitAccountPayto, }, headers: this.makeAuthHeader(), }); logger.info(`add-incoming response status: ${resp.status}`); await checkSuccessResponseOrThrow(resp); } } export interface ChallengeContactData { // E-Mail address email?: string; // Phone number. phone?: string; } export interface AccountBalance { amount: AmountString; credit_debit_indicator: "credit" | "debit"; } export interface RegisterAccountRequest { // Username username: string; // Password. password: string; // Legal name of the account owner name: string; // Defaults to false. is_public?: boolean; // Is this a taler exchange account? // If true: // - incoming transactions to the account that do not // have a valid reserve public key are automatically // - the account provides the taler-wire-gateway-api endpoints // Defaults to false. is_taler_exchange?: boolean; // Addresses where to send the TAN for transactions. // Currently only used for cashouts. // If missing, cashouts will fail. // In the future, might be used for other transactions // as well. challenge_contact_data?: ChallengeContactData; // 'payto' address pointing a bank account // external to the libeufin-bank. // Payments will be sent to this bank account // when the user wants to convert the local currency // back to fiat currency outside libeufin-bank. cashout_payto_uri?: string; // Internal payto URI of this bank account. // Used mostly for testing. payto_uri?: string; } export interface AccountData { // Legal name of the account owner. name: string; // Available balance on the account. balance: AccountBalance; // payto://-URI of the account. payto_uri: string; // Number indicating the max debit allowed for the requesting user. debit_threshold: AmountString; contact_data?: ChallengeContactData; // 'payto' address pointing the bank account // where to send cashouts. This field is optional // because not all the accounts are required to participate // in the merchants' circuit. One example is the exchange: // that never cashouts. Registering these accounts can // be done via the access API. cashout_payto_uri?: string; } export interface ConfirmWithdrawalArgs { withdrawalOperationId: string; } /** * Client for the Taler corebank API. */ export class TalerCorebankApiClient { httpLib: HttpRequestLibrary; constructor( private baseUrl: string, private args: BankAccessApiClientArgs = {}, ) { this.httpLib = args.httpClient ?? createPlatformHttpLib(); } setAuth(auth: { username: string; password: string }) { this.args.auth = auth; } private makeAuthHeader(): Record { if (!this.args.auth) { return {}; } const authHeaderValue = makeBasicAuthHeader( this.args.auth.username, this.args.auth.password, ); return { Authorization: authHeaderValue, }; } async getAccountBalance( username: string, ): Promise { const url = new URL(`accounts/${username}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { headers: this.makeAuthHeader(), }); return readSuccessResponseJsonOrThrow(resp, codecForAny()); } async getTransactions(username: string): Promise { const reqUrl = new URL(`accounts/${username}/transactions`, this.baseUrl); const resp = await this.httpLib.fetch(reqUrl.href, { method: "GET", headers: { ...this.makeAuthHeader(), }, }); const res = await readSuccessResponseJsonOrThrow(resp, codecForAny()); logger.info(`result: ${j2s(res)}`); } async createTransaction( username: string, req: BankAccessApiCreateTransactionRequest, ): Promise { const reqUrl = new URL(`accounts/${username}/transactions`, this.baseUrl); const resp = await this.httpLib.fetch(reqUrl.href, { method: "POST", body: req, headers: this.makeAuthHeader(), }); return await readSuccessResponseJsonOrThrow(resp, codecForAny()); } async registerAccountExtended(req: RegisterAccountRequest): Promise { const url = new URL("accounts", this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: req, headers: this.makeAuthHeader(), }); if ( resp.status !== 200 && resp.status !== 201 && resp.status !== 202 && resp.status !== 204 ) { logger.error(`unexpected status ${resp.status} from POST ${url.href}`); logger.error(`${j2s(await resp.json())}`); throw TalerError.fromDetail( TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, { httpStatusCode: resp.status, }, ); } } /** * Register a new account and return information about it. * * This is a helper, as it does both the registration and the * account info query. */ async registerAccount(username: string, password: string): Promise { const url = new URL("accounts", this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: { username, password, name: username, }, headers: this.makeAuthHeader(), }); if ( resp.status !== 200 && resp.status !== 201 && resp.status !== 202 && resp.status !== 204 ) { logger.error(`unexpected status ${resp.status} from POST ${url.href}`); logger.error(`${j2s(await resp.json())}`); throw TalerError.fromDetail( TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, { httpStatusCode: resp.status, }, ); } // FIXME: Corebank should directly return this info! const infoUrl = new URL(`accounts/${username}`, this.baseUrl); const infoResp = await this.httpLib.fetch(infoUrl.href, { headers: { Authorization: makeBasicAuthHeader(username, password), }, }); // FIXME: Validate! const acctInfo: AccountData = await readSuccessResponseJsonOrThrow( infoResp, codecForAny(), ); return { password, username, accountPaytoUri: acctInfo.payto_uri, }; } async createRandomBankUser(): Promise { const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase(); const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase(); return await this.registerAccount(username, password); } async createWithdrawalOperation( user: string, amount: string, ): Promise { const url = new URL(`accounts/${user}/withdrawals`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: { amount, }, headers: this.makeAuthHeader(), }); return readSuccessResponseJsonOrThrow( resp, codecForWithdrawalOperationInfo(), ); } async confirmWithdrawalOperation( username: string, wopi: ConfirmWithdrawalArgs, ) { const url = new URL( `accounts/${username}/withdrawals/${wopi.withdrawalOperationId}/confirm`, this.baseUrl, ); logger.info(`confirming withdrawal operation via ${url.href}`); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: {}, headers: this.makeAuthHeader(), }); switch (resp.status) { case HttpStatusCode.Ok: case HttpStatusCode.NoContent: return opEmptySuccess(resp); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); default: return opUnknownFailure(resp, await readTalerErrorResponse(resp)); } } async abortWithdrawalOperation(wopi: WithdrawalOperationInfo): Promise { const url = new URL( `withdrawals/${wopi.withdrawal_id}/abort`, this.baseUrl, ); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: {}, headers: this.makeAuthHeader(), }); await readSuccessResponseJsonOrThrow(resp, codecForAny()); } }