diff options
Diffstat (limited to 'packages/taler-util/src/bank-api-client.ts')
-rw-r--r-- | packages/taler-util/src/bank-api-client.ts | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts new file mode 100644 index 000000000..51359129d --- /dev/null +++ b/packages/taler-util/src/bank-api-client.ts @@ -0,0 +1,440 @@ +/* + 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/> + */ + +/** + * 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<WithdrawalOperationInfo> => + buildCodecForObject<WithdrawalOperationInfo>() + .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<string, string> { + 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<void> { + 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<string, string> { + if (!this.args.auth) { + return {}; + } + const authHeaderValue = makeBasicAuthHeader( + this.args.auth.username, + this.args.auth.password, + ); + return { + Authorization: authHeaderValue, + }; + } + + async getAccountBalance( + username: string, + ): Promise<BankAccountBalanceResponse> { + 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<void> { + 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<any> { + 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<void> { + 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<BankUser> { + 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<BankUser> { + 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<WithdrawalOperationInfo> { + 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<void> { + 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()); + } +} |