diff options
Diffstat (limited to 'packages')
15 files changed, 1059 insertions, 378 deletions
diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts index 8222bdeab..a1506e90f 100644 --- a/packages/taler-util/src/backupTypes.ts +++ b/packages/taler-util/src/backupTypes.ts @@ -1091,17 +1091,21 @@ export interface BackupExchangeWireFee { * */ export interface BackupExchangeGlobalFees { - start_date: TalerProtocolTimestamp; - end_date: TalerProtocolTimestamp; - kyc_fee: BackupAmountString; - history_fee: BackupAmountString; - account_fee: BackupAmountString; - purse_fee: BackupAmountString; - history_expiration: TalerProtocolDuration; - account_kyc_timeout: TalerProtocolDuration; - purse_account_limit: number; - purse_timeout: TalerProtocolDuration; - master_sig: string; + startDate: TalerProtocolTimestamp; + endDate: TalerProtocolTimestamp; + + kycFee: BackupAmountString; + historyFee: BackupAmountString; + accountFee: BackupAmountString; + purseFee: BackupAmountString; + + historyTimeout: TalerProtocolDuration; + kycTimeout: TalerProtocolDuration; + purseTimeout: TalerProtocolDuration; + + purseLimit: number; + + signature: string; } /** * Structure of one exchange signing key in the /keys response. diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index bb5d4b3a4..7495e02d6 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -36,6 +36,7 @@ import { AbsoluteTime, codecForAbsoluteTime, codecForTimestamp, + TalerProtocolDuration, TalerProtocolTimestamp, } from "./time.js"; import { @@ -673,6 +674,23 @@ export interface WireInfo { accounts: ExchangeAccount[]; } +export interface ExchangeGlobalFees { + startDate: TalerProtocolTimestamp; + endDate: TalerProtocolTimestamp; + + kycFee: AmountJson; + historyFee: AmountJson; + accountFee: AmountJson; + purseFee: AmountJson; + + historyTimeout: TalerProtocolDuration; + kycTimeout: TalerProtocolDuration; + purseTimeout: TalerProtocolDuration; + + purseLimit: number; + + signature: string; +} const codecForExchangeAccount = (): Codec<ExchangeAccount> => buildCodecForObject<ExchangeAccount>() .property("payto_uri", codecForString()) @@ -752,28 +770,31 @@ export interface DenominationInfo { exchangeBaseUrl: string; } -export type Operation = "deposit" | "withdraw" | "refresh" | "refund"; -export type OperationMap<T> = { [op in Operation]: T }; +export type DenomOperation = "deposit" | "withdraw" | "refresh" | "refund"; +export type DenomOperationMap<T> = { [op in DenomOperation]: T }; export interface FeeDescription { - value: AmountJson; + group: string; from: AbsoluteTime; until: AbsoluteTime; fee?: AmountJson; } export interface FeeDescriptionPair { - value: AmountJson; + group: string; from: AbsoluteTime; until: AbsoluteTime; left?: AmountJson; right?: AmountJson; } -export interface TimePoint { +export interface TimePoint<T> { + id: string; + group: string; + fee: AmountJson; type: "start" | "end"; moment: AbsoluteTime; - denom: DenominationInfo; + denom: T; } export interface ExchangeFullDetails { @@ -783,7 +804,9 @@ export interface ExchangeFullDetails { tos: ExchangeTos; auditors: ExchangeAuditor[]; wireInfo: WireInfo; - feesDescription: OperationMap<FeeDescription[]>; + denomFees: DenomOperationMap<FeeDescription[]>; + transferFees: Record<string, FeeDescription[]>; + globalFees: FeeDescription[]; } export interface ExchangeListItem { @@ -816,7 +839,7 @@ const codecForExchangeTos = (): Codec<ExchangeTos> => export const codecForFeeDescriptionPair = (): Codec<FeeDescriptionPair> => buildCodecForObject<FeeDescriptionPair>() - .property("value", codecForAmountJson()) + .property("group", codecForString()) .property("from", codecForAbsoluteTime) .property("until", codecForAbsoluteTime) .property("left", codecOptional(codecForAmountJson())) @@ -825,21 +848,21 @@ export const codecForFeeDescriptionPair = (): Codec<FeeDescriptionPair> => export const codecForFeeDescription = (): Codec<FeeDescription> => buildCodecForObject<FeeDescription>() - .property("value", codecForAmountJson()) + .property("group", codecForString()) .property("from", codecForAbsoluteTime) .property("until", codecForAbsoluteTime) .property("fee", codecOptional(codecForAmountJson())) .build("FeeDescription"); export const codecForFeesByOperations = (): Codec< - OperationMap<FeeDescription[]> + DenomOperationMap<FeeDescription[]> > => - buildCodecForObject<OperationMap<FeeDescription[]>>() + buildCodecForObject<DenomOperationMap<FeeDescription[]>>() .property("deposit", codecForList(codecForFeeDescription())) .property("withdraw", codecForList(codecForFeeDescription())) .property("refresh", codecForList(codecForFeeDescription())) .property("refund", codecForList(codecForFeeDescription())) - .build("FeesByOperations"); + .build("DenomOperationMap"); export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> => buildCodecForObject<ExchangeFullDetails>() @@ -849,7 +872,12 @@ export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> => .property("tos", codecForExchangeTos()) .property("auditors", codecForList(codecForExchangeAuditor())) .property("wireInfo", codecForWireInfo()) - .property("feesDescription", codecForFeesByOperations()) + .property("denomFees", codecForFeesByOperations()) + .property( + "transferFees", + codecForMap(codecForList(codecForFeeDescription())), + ) + .property("globalFees", codecForList(codecForFeeDescription())) .build("ExchangeFullDetails"); export const codecForExchangeListItem = (): Codec<ExchangeListItem> => diff --git a/packages/taler-wallet-core/src/crypto/workers/rpcClient.ts b/packages/taler-wallet-core/src/crypto/workers/rpcClient.ts index f3a4fff1c..21d88fffa 100644 --- a/packages/taler-wallet-core/src/crypto/workers/rpcClient.ts +++ b/packages/taler-wallet-core/src/crypto/workers/rpcClient.ts @@ -53,7 +53,6 @@ export class CryptoRpcClient { this.proc.unref(); this.proc.stdout.on("data", (x) => { - // console.log("got chunk", x.toString("utf-8")); if (x instanceof Buffer) { const nlIndex = x.indexOf("\n"); if (nlIndex >= 0) { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index e266275c1..125e777b8 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -46,6 +46,7 @@ import { WireInfo, DenominationInfo, GlobalFees, + ExchangeGlobalFees, } from "@gnu-taler/taler-util"; import { RetryInfo, RetryTags } from "./util/retries.js"; import { Event, IDBDatabase } from "@gnu-taler/idb-bridge"; @@ -428,7 +429,7 @@ export interface ExchangeDetailsRecord { /** * Fees for exchange services */ - globalFees: GlobalFees[]; + globalFees: ExchangeGlobalFees[]; /** * Signing keys we got from the exchange, can also contain * older signing keys that are not returned by /keys anymore. diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index f611a2380..a3c4c8d99 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -345,7 +345,19 @@ export async function exportBackup( stamp_expire: x.stamp_expire, stamp_start: x.stamp_start, })), - global_fees: ex.globalFees, + global_fees: ex.globalFees.map((x) => ({ + accountFee: Amounts.stringify(x.accountFee), + historyFee: Amounts.stringify(x.historyFee), + kycFee: Amounts.stringify(x.kycFee), + purseFee: Amounts.stringify(x.purseFee), + kycTimeout: x.kycTimeout, + endDate: x.endDate, + historyTimeout: x.historyTimeout, + signature: x.signature, + purseLimit: x.purseLimit, + purseTimeout: x.purseTimeout, + startDate: x.startDate, + })), tos_accepted_etag: ex.termsOfServiceAcceptedEtag, tos_accepted_timestamp: ex.termsOfServiceAcceptedTimestamp, denominations: diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index ee8cb6f6c..e631845f6 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -405,7 +405,20 @@ export async function importBackup( masterPublicKey: backupExchangeDetails.master_public_key, protocolVersion: backupExchangeDetails.protocol_version, reserveClosingDelay: backupExchangeDetails.reserve_closing_delay, - globalFees: backupExchangeDetails.global_fees, + globalFees: backupExchangeDetails.global_fees.map((x) => ({ + accountFee: Amounts.parseOrThrow(x.accountFee), + historyFee: Amounts.parseOrThrow(x.historyFee), + kycFee: Amounts.parseOrThrow(x.kycFee), + purseFee: Amounts.parseOrThrow(x.purseFee), + kycTimeout: x.kycTimeout, + endDate: x.endDate, + historyTimeout: x.historyTimeout, + signature: x.signature, + purseLimit: x.purseLimit, + purseTimeout: x.purseTimeout, + startDate: x.startDate, + })), + signingKeys: backupExchangeDetails.signing_keys.map((x) => ({ key: x.key, master_sig: x.master_sig, diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index a26c14fcc..3da16e303 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -30,6 +30,7 @@ import { encodeCrock, ExchangeAuditor, ExchangeDenomination, + ExchangeGlobalFees, ExchangeSignKeyJson, ExchangeWireJson, GlobalFees, @@ -274,7 +275,8 @@ async function validateGlobalFees( ws: InternalWalletState, fees: GlobalFees[], masterPub: string, -): Promise<GlobalFees[]> { +): Promise<ExchangeGlobalFees[]> { + const egf: ExchangeGlobalFees[] = []; for (const gf of fees) { logger.trace("validating exchange global fees"); let isValid = false; @@ -291,9 +293,22 @@ async function validateGlobalFees( if (!isValid) { throw Error("exchange global fees signature invalid: " + gf.master_sig); } + egf.push({ + accountFee: Amounts.parseOrThrow(gf.account_fee), + historyFee: Amounts.parseOrThrow(gf.history_fee), + purseFee: Amounts.parseOrThrow(gf.purse_fee), + kycFee: Amounts.parseOrThrow(gf.kyc_fee), + startDate: gf.start_date, + endDate: gf.end_date, + signature: gf.master_sig, + historyTimeout: gf.history_expiration, + kycTimeout: gf.account_kyc_timeout, + purseLimit: gf.purse_account_limit, + purseTimeout: gf.purse_timeout, + }); } - return fees; + return egf; } export interface ExchangeInfo { diff --git a/packages/taler-wallet-core/src/util/denominations.test.ts b/packages/taler-wallet-core/src/util/denominations.test.ts index 31c561e88..9c93331a3 100644 --- a/packages/taler-wallet-core/src/util/denominations.test.ts +++ b/packages/taler-wallet-core/src/util/denominations.test.ts @@ -28,8 +28,9 @@ import { } from "@gnu-taler/taler-util"; // import { expect } from "chai"; import { - createDenominationPairTimeline, - createDenominationTimeline, + createPairTimeline, + createTimeline, + selectBestForOverlappingDenominations, } from "./denominations.js"; import test, { ExecutionContext } from "ava"; @@ -42,8 +43,14 @@ const VALUES = Array.from({ length: 10 }).map((undef, t) => const TIMESTAMPS = Array.from({ length: 20 }).map((undef, t_s) => ({ t_s })); const ABS_TIME = TIMESTAMPS.map((m) => AbsoluteTime.fromTimestamp(m)); -function normalize(list: DenominationInfo[]): DenominationInfo[] { - return list.map((e, idx) => ({ ...e, denomPubHash: `id${idx}` })); +function normalize( + list: DenominationInfo[], +): (DenominationInfo & { group: string })[] { + return list.map((e, idx) => ({ + ...e, + denomPubHash: `id${idx}`, + group: Amounts.stringifyValue(e.value), + })); } //Avoiding to make an error-prone/time-consuming refactor @@ -61,7 +68,7 @@ function expect(t: ExecutionContext, thing: any): any { // describe("single value example", (t) => { test("should have one row with start and exp", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -70,13 +77,17 @@ test("should have one row with start and exp", (t) => { feeDeposit: VALUES[1], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[1], @@ -85,7 +96,7 @@ test("should have one row with start and exp", (t) => { }); test("should have two rows with the second denom in the middle if second is better", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -100,19 +111,23 @@ test("should have two rows with the second denom in the middle if second is bett feeDeposit: VALUES[2], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[1], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[3], until: ABS_TIME[4], fee: VALUES[2], @@ -121,7 +136,7 @@ test("should have two rows with the second denom in the middle if second is bett }); test("should have two rows with the first denom in the middle if second is worse", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -136,19 +151,23 @@ test("should have two rows with the first denom in the middle if second is worse feeDeposit: VALUES[1], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[2], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[4], fee: VALUES[1], @@ -157,7 +176,7 @@ test("should have two rows with the first denom in the middle if second is worse }); test("should add a gap when there no fee", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -172,24 +191,28 @@ test("should add a gap when there no fee", (t) => { feeDeposit: VALUES[1], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[2], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[3], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[3], until: ABS_TIME[4], fee: VALUES[1], @@ -198,7 +221,7 @@ test("should add a gap when there no fee", (t) => { }); test("should have three rows when first denom is between second and second is worse", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -213,24 +236,28 @@ test("should have three rows when first denom is between second and second is wo feeDeposit: VALUES[2], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[2], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[3], fee: VALUES[1], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[3], until: ABS_TIME[4], fee: VALUES[2], @@ -239,7 +266,7 @@ test("should have three rows when first denom is between second and second is wo }); test("should have one row when first denom is between second and second is better", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -254,13 +281,17 @@ test("should have one row when first denom is between second and second is bette feeDeposit: VALUES[1], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[4], fee: VALUES[1], @@ -269,7 +300,7 @@ test("should have one row when first denom is between second and second is bette }); test("should only add the best1", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -290,19 +321,23 @@ test("should only add the best1", (t) => { feeDeposit: VALUES[2], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[2], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[4], fee: VALUES[1], @@ -311,7 +346,7 @@ test("should only add the best1", (t) => { }); test("should only add the best2", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -338,25 +373,29 @@ test("should only add the best2", (t) => { feeDeposit: VALUES[3], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[2], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[5], fee: VALUES[1], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[5], until: ABS_TIME[6], fee: VALUES[3], @@ -365,7 +404,7 @@ test("should only add the best2", (t) => { }); test("should only add the best3", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -386,13 +425,17 @@ test("should only add the best3", (t) => { feeDeposit: VALUES[2], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[5], fee: VALUES[1], @@ -406,7 +449,7 @@ test("should only add the best3", (t) => { //TODO: test the same start but different value test("should not merge when there is different value", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -421,19 +464,23 @@ test("should not merge when there is different value", (t) => { feeDeposit: VALUES[2], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[1], }, { - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), from: ABS_TIME[2], until: ABS_TIME[4], fee: VALUES[2], @@ -442,7 +489,7 @@ test("should not merge when there is different value", (t) => { }); test("should not merge when there is different value (with duplicates)", (t) => { - const timeline = createDenominationTimeline( + const timeline = createTimeline( normalize([ { value: VALUES[1], @@ -469,19 +516,23 @@ test("should not merge when there is different value (with duplicates)", (t) => feeDeposit: VALUES[2], } as Partial<DenominationInfo> as DenominationInfo, ]), + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ); expect(t, timeline).deep.equal([ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[1], }, { - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), from: ABS_TIME[2], until: ABS_TIME[4], fee: VALUES[2], @@ -519,7 +570,7 @@ test("should return empty", (t) => { const left = [] as FeeDescription[]; const right = [] as FeeDescription[]; - const pairs = createDenominationPairTimeline(left, right); + const pairs = createPairTimeline(left, right); expect(t, pairs).deep.equals([]); }); @@ -527,7 +578,7 @@ test("should return empty", (t) => { test("should return first element", (t) => { const left = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[1], @@ -537,24 +588,24 @@ test("should return first element", (t) => { const right = [] as FeeDescription[]; { - const pairs = createDenominationPairTimeline(left, right); + const pairs = createPairTimeline(left, right); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: undefined, }, ] as FeeDescriptionPair[]); } { - const pairs = createDenominationPairTimeline(right, left); + const pairs = createPairTimeline(right, left); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), right: VALUES[1], left: undefined, }, @@ -565,7 +616,7 @@ test("should return first element", (t) => { test("should add both to the same row", (t) => { const left = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[1], @@ -574,7 +625,7 @@ test("should add both to the same row", (t) => { const right = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[2], @@ -582,24 +633,24 @@ test("should add both to the same row", (t) => { ] as FeeDescription[]; { - const pairs = createDenominationPairTimeline(left, right); + const pairs = createPairTimeline(left, right); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: VALUES[2], }, ] as FeeDescriptionPair[]); } { - const pairs = createDenominationPairTimeline(right, left); + const pairs = createPairTimeline(right, left); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[2], right: VALUES[1], }, @@ -610,7 +661,7 @@ test("should add both to the same row", (t) => { test("should repeat the first and change the second", (t) => { const left = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[5], fee: VALUES[1], @@ -619,18 +670,18 @@ test("should repeat the first and change the second", (t) => { const right = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[2], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[3], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[3], until: ABS_TIME[4], fee: VALUES[3], @@ -638,33 +689,33 @@ test("should repeat the first and change the second", (t) => { ] as FeeDescription[]; { - const pairs = createDenominationPairTimeline(left, right); + const pairs = createPairTimeline(left, right); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[2], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: VALUES[2], }, { from: ABS_TIME[2], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: undefined, }, { from: ABS_TIME[3], until: ABS_TIME[4], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: VALUES[3], }, { from: ABS_TIME[4], until: ABS_TIME[5], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: undefined, }, @@ -679,7 +730,7 @@ test("should repeat the first and change the second", (t) => { test("should separate denominations of different value", (t) => { const left = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[1], @@ -688,7 +739,7 @@ test("should separate denominations of different value", (t) => { const right = [ { - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[2], @@ -696,38 +747,38 @@ test("should separate denominations of different value", (t) => { ] as FeeDescription[]; { - const pairs = createDenominationPairTimeline(left, right); + const pairs = createPairTimeline(left, right); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: undefined, }, { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), left: undefined, right: VALUES[2], }, ] as FeeDescriptionPair[]); } { - const pairs = createDenominationPairTimeline(right, left); + const pairs = createPairTimeline(right, left); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: undefined, right: VALUES[1], }, { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), left: VALUES[2], right: undefined, }, @@ -738,13 +789,13 @@ test("should separate denominations of different value", (t) => { test("should separate denominations of different value2", (t) => { const left = [ { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[1], until: ABS_TIME[2], fee: VALUES[1], }, { - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), from: ABS_TIME[2], until: ABS_TIME[4], fee: VALUES[2], @@ -753,7 +804,7 @@ test("should separate denominations of different value2", (t) => { const right = [ { - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), from: ABS_TIME[1], until: ABS_TIME[3], fee: VALUES[2], @@ -761,26 +812,26 @@ test("should separate denominations of different value2", (t) => { ] as FeeDescription[]; { - const pairs = createDenominationPairTimeline(left, right); + const pairs = createPairTimeline(left, right); expect(t, pairs).deep.equals([ { from: ABS_TIME[1], until: ABS_TIME[2], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[1], right: undefined, }, { from: ABS_TIME[2], until: ABS_TIME[4], - value: VALUES[1], + group: Amounts.stringifyValue(VALUES[1]), left: VALUES[2], right: undefined, }, { from: ABS_TIME[1], until: ABS_TIME[3], - value: VALUES[2], + group: Amounts.stringifyValue(VALUES[2]), left: undefined, right: VALUES[2], }, diff --git a/packages/taler-wallet-core/src/util/denominations.ts b/packages/taler-wallet-core/src/util/denominations.ts index 4efb902c8..9cd931acd 100644 --- a/packages/taler-wallet-core/src/util/denominations.ts +++ b/packages/taler-wallet-core/src/util/denominations.ts @@ -23,6 +23,7 @@ import { FeeDescriptionPair, TalerProtocolTimestamp, TimePoint, + WireFee, } from "@gnu-taler/taler-util"; /** @@ -33,9 +34,9 @@ import { * @param list denominations of same value * @returns */ -function selectBestForOverlappingDenominations( - list: DenominationInfo[], -): DenominationInfo | undefined { +export function selectBestForOverlappingDenominations< + T extends DenominationInfo, +>(list: T[]): T | undefined { let minDeposit: DenominationInfo | undefined = undefined; //TODO: improve denomination selection, this is a trivial implementation list.forEach((e) => { @@ -50,6 +51,23 @@ function selectBestForOverlappingDenominations( return minDeposit; } +export function selectMinimumFee<T extends { fee: AmountJson }>( + list: T[], +): T | undefined { + let minFee: T | undefined = undefined; + //TODO: improve denomination selection, this is a trivial implementation + list.forEach((e) => { + if (minFee === undefined) { + minFee = e; + return; + } + if (Amounts.cmp(minFee.fee, e.fee) > -1) { + minFee = e; + } + }); + return minFee; +} + type PropsWithReturnType<T extends object, F> = Exclude< { [K in keyof T]: T[K] extends F ? K : never; @@ -58,17 +76,19 @@ type PropsWithReturnType<T extends object, F> = Exclude< >; /** - * Takes two list and create one with one timeline. - * For any element in the position "p" on the left or right "list", then - * list[p].until should be equal to list[p+1].from + * Takes two timelines and create one to compare them. + * + * For both lists the next condition should be true: + * for any element in the position "idx" then + * list[idx].until === list[idx+1].from * - * @see {createDenominationTimeline} + * @see {createTimeline} * * @param left list denominations @type {FeeDescription} * @param right list denominations @type {FeeDescription} * @returns list of pairs for the same time */ -export function createDenominationPairTimeline( +export function createPairTimeline( left: FeeDescription[], right: FeeDescription[], ): FeeDescriptionPair[] { @@ -81,23 +101,15 @@ export function createDenominationPairTimeline( let ri = 0; while (li < left.length && ri < right.length) { - const currentValue = - Amounts.cmp(left[li].value, right[ri].value) < 0 - ? left[li].value - : right[ri].value; + const currentGroup = + left[li].group < right[ri].group ? left[li].group : right[ri].group; let ll = 0; //left length (until next value) - while ( - li + ll < left.length && - Amounts.cmp(left[li + ll].value, currentValue) === 0 - ) { + while (li + ll < left.length && left[li + ll].group === currentGroup) { ll++; } let rl = 0; //right length (until next value) - while ( - ri + rl < right.length && - Amounts.cmp(right[ri + rl].value, currentValue) === 0 - ) { + while (ri + rl < right.length && right[ri + rl].group === currentGroup) { rl++; } const leftIsEmpty = ll === 0; @@ -120,7 +132,7 @@ export function createDenominationPairTimeline( right.splice(ri, 0, { from: leftStarts, until: ends, - value: left[li].value, + group: left[li].group, }); rl++; @@ -132,7 +144,7 @@ export function createDenominationPairTimeline( left.splice(li, 0, { from: rightStarts, until: ends, - value: right[ri].value, + group: right[ri].group, }); ll++; @@ -148,7 +160,7 @@ export function createDenominationPairTimeline( right.splice(ri + rl, 0, { from: rightEnds, until: leftEnds, - value: left[0].value, + group: left[0].group, }); rl++; } @@ -156,7 +168,7 @@ export function createDenominationPairTimeline( left.splice(li + ll, 0, { from: leftEnds, until: rightEnds, - value: right[0].value, + group: right[0].group, }); ll++; } @@ -165,7 +177,7 @@ export function createDenominationPairTimeline( while ( li < left.length && ri < right.length && - Amounts.cmp(left[li].value, right[ri].value) === 0 + left[li].group === right[ri].group ) { if ( AbsoluteTime.cmp(left[li].from, timeCut) !== 0 && @@ -186,7 +198,7 @@ export function createDenominationPairTimeline( right: right[ri].fee, from: timeCut, until: AbsoluteTime.never(), - value: currentValue, + group: currentGroup, }); if (left[li].until.t_ms === right[ri].until.t_ms) { @@ -204,7 +216,7 @@ export function createDenominationPairTimeline( if ( li < left.length && - Amounts.cmp(left[li].value, pairList[pairList.length - 1].value) !== 0 + left[li].group !== pairList[pairList.length - 1].group ) { //value changed, should break //this if will catch when both (left and right) change at the same time @@ -217,7 +229,7 @@ export function createDenominationPairTimeline( if (li < left.length) { let timeCut = pairList.length > 0 && - Amounts.cmp(pairList[pairList.length - 1].value, left[li].value) === 0 + pairList[pairList.length - 1].group === left[li].group ? pairList[pairList.length - 1].until : left[li].from; while (li < left.length) { @@ -226,7 +238,7 @@ export function createDenominationPairTimeline( right: undefined, from: timeCut, until: left[li].until, - value: left[li].value, + group: left[li].group, }); timeCut = left[li].until; li++; @@ -235,7 +247,7 @@ export function createDenominationPairTimeline( if (ri < right.length) { let timeCut = pairList.length > 0 && - Amounts.cmp(pairList[pairList.length - 1].value, right[ri].value) === 0 + pairList[pairList.length - 1].group === right[ri].group ? pairList[pairList.length - 1].until : right[ri].from; while (ri < right.length) { @@ -244,7 +256,7 @@ export function createDenominationPairTimeline( left: undefined, from: timeCut, until: right[ri].until, - value: right[ri].value, + group: right[ri].group, }); timeCut = right[ri].until; ri++; @@ -254,42 +266,70 @@ export function createDenominationPairTimeline( } /** - * Create a usage timeline with the denominations given. + * Create a usage timeline with the entity given. * - * If there are multiple denominations that can be used, the list will - * contain the one that minimize the fee cost. @see selectBestForOverlappingDenominations + * If there are multiple entities that can be used in the same period, + * the list will contain the one that minimize the fee cost. + * @see selectBestForOverlappingDenominations * - * @param list list of denominations - * @param periodProp property of element of the list that will be used as end of the usage period + * @param list list of entities + * @param idProp property used for identification + * @param periodStartProp property of element of the list that will be used as start of the usage period + * @param periodEndProp property of element of the list that will be used as end of the usage period * @param feeProp property of the element of the list that will be used as fee reference + * @param groupProp property of the element of the list that will be used for grouping * @returns list of @type {FeeDescription} sorted by usage period */ -export function createDenominationTimeline( - list: DenominationInfo[], - periodProp: PropsWithReturnType<DenominationInfo, TalerProtocolTimestamp>, - feeProp: PropsWithReturnType<DenominationInfo, AmountJson>, +export function createTimeline<Type extends object>( + list: Type[], + idProp: PropsWithReturnType<Type, string>, + periodStartProp: PropsWithReturnType<Type, TalerProtocolTimestamp>, + periodEndProp: PropsWithReturnType<Type, TalerProtocolTimestamp>, + feeProp: PropsWithReturnType<Type, AmountJson>, + groupProp: PropsWithReturnType<Type, string> | undefined, + selectBestForOverlapping: (l: Type[]) => Type | undefined, ): FeeDescription[] { - const points = list + /** + * First we create a list with with point in the timeline sorted + * by time and categorized by starting or ending. + */ + const sortedPointsInTime = list .reduce((ps, denom) => { //exclude denoms with bad configuration - if (denom.stampStart.t_s >= denom[periodProp].t_s) { - throw Error(`denom ${denom.denomPubHash} has start after the end`); - // return ps; + const id = denom[idProp] as string; + const stampStart = denom[periodStartProp] as TalerProtocolTimestamp; + const stampEnd = denom[periodEndProp] as TalerProtocolTimestamp; + const fee = denom[feeProp] as AmountJson; + const group = !groupProp ? "" : (denom[groupProp] as string); + + if (!id) { + throw Error( + `denomination without hash ${JSON.stringify(denom, undefined, 2)}`, + ); + } + if (stampStart.t_s >= stampEnd.t_s) { + throw Error(`denom ${id} has start after the end`); } ps.push({ type: "start", - moment: AbsoluteTime.fromTimestamp(denom.stampStart), + fee, + group, + id, + moment: AbsoluteTime.fromTimestamp(stampStart), denom, }); ps.push({ type: "end", - moment: AbsoluteTime.fromTimestamp(denom[periodProp]), + fee, + group, + id, + moment: AbsoluteTime.fromTimestamp(stampEnd), denom, }); return ps; - }, [] as TimePoint[]) + }, [] as TimePoint<Type>[]) .sort((a, b) => { - const v = Amounts.cmp(a.denom.value, b.denom.value); + const v = a.group == b.group ? 0 : a.group > b.group ? 1 : -1; if (v != 0) return v; const t = AbsoluteTime.cmp(a.moment, b.moment); if (t != 0) return t; @@ -297,21 +337,15 @@ export function createDenominationTimeline( return a.type === "start" ? 1 : -1; }); - const activeAtTheSameTime: DenominationInfo[] = []; - return points.reduce((result, cursor, idx) => { - const hash = cursor.denom.denomPubHash; - if (!hash) - throw Error( - `denomination without hash ${JSON.stringify( - cursor.denom, - undefined, - 2, - )}`, - ); - + const activeAtTheSameTime: Type[] = []; + return sortedPointsInTime.reduce((result, cursor, idx) => { + /** + * Now that we have move one step forward, we should + * update the previous element ending period with the + * current start time. + */ let prev = result.length > 0 ? result[result.length - 1] : undefined; - const prevHasSameValue = - prev && Amounts.cmp(prev.value, cursor.denom.value) === 0; + const prevHasSameValue = prev && prev.group == cursor.group; if (prev) { if (prevHasSameValue) { prev.until = cursor.moment; @@ -326,11 +360,15 @@ export function createDenominationTimeline( } } - //update the activeAtTheSameTime list + /** + * With the current moment in the iteration we + * should keep updated which entities are current + * active in this period of time. + */ if (cursor.type === "end") { - const loc = activeAtTheSameTime.findIndex((v) => v.denomPubHash === hash); + const loc = activeAtTheSameTime.findIndex((v) => v[idProp] === cursor.id); if (loc === -1) { - throw Error(`denomination ${hash} has an end but no start`); + throw Error(`denomination ${cursor.id} has an end but no start`); } activeAtTheSameTime.splice(loc, 1); } else if (cursor.type === "start") { @@ -340,12 +378,16 @@ export function createDenominationTimeline( throw new Error(`not TimePoint defined for type: ${exhaustiveCheck}`); } - if (idx == points.length - 1) { - //this is the last element in the list, prevent adding - //a gap in the end + if (idx == sortedPointsInTime.length - 1) { + /** + * This is the last element in the list, if we continue + * a gap will normally be added which is not necessary. + * Also, the last element should be ending and the list of active + * element should be empty + */ if (cursor.type !== "end") { throw Error( - `denomination ${hash} starts after ending or doesn't have an ending`, + `denomination ${cursor.id} starts after ending or doesn't have an ending`, ); } if (activeAtTheSameTime.length > 0) { @@ -356,26 +398,36 @@ export function createDenominationTimeline( return result; } - const current = selectBestForOverlappingDenominations(activeAtTheSameTime); + const current = selectBestForOverlapping(activeAtTheSameTime); if (current) { + /** + * We have a candidate to add in the list, check that we are + * not adding a duplicate. + * Next element in the list will defined the ending. + */ + const currentFee = current[feeProp] as AmountJson; if ( prev === undefined || //is the first !prev.fee || //is a gap - Amounts.cmp(prev.fee, current[feeProp]) !== 0 // prev has the same fee + Amounts.cmp(prev.fee, currentFee) !== 0 // prev has different fee ) { result.push({ - value: cursor.denom.value, + group: cursor.group, from: cursor.moment, until: AbsoluteTime.never(), //not yet known - fee: current[feeProp], + fee: currentFee, }); } else { prev.until = cursor.moment; } } else { + /** + * No active element in this period of time, so we add a gap (no fee) + * Next element in the list will defined the ending. + */ result.push({ - value: cursor.denom.value, + group: cursor.group, from: cursor.moment, until: AbsoluteTime.never(), //not yet known }); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 07dd1fcda..357dd586a 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -72,6 +72,7 @@ import { CoinDumpJson, CoreApiResponse, DenominationInfo, + DenomOperationMap, Duration, durationFromSpec, durationMin, @@ -86,11 +87,9 @@ import { Logger, ManualWithdrawalDetails, NotificationType, - OperationMap, parsePaytoUri, RefreshReason, TalerErrorCode, - TalerErrorDetail, URL, WalletCoreVersion, WalletNotification, @@ -103,7 +102,6 @@ import { import { clearDatabase } from "./db-utils.js"; import { AuditorTrustRecord, - CoinRecord, CoinSourceType, CoinStatus, DenominationRecord, @@ -111,11 +109,7 @@ import { importDb, WalletStoresV1, } from "./db.js"; -import { - getErrorDetailFromException, - makeErrorDetail, - TalerError, -} from "./errors.js"; +import { getErrorDetailFromException, TalerError } from "./errors.js"; import { ActiveLongpollInfo, ExchangeOperations, @@ -142,11 +136,7 @@ import { } from "./operations/backup/index.js"; import { setWalletDeviceId } from "./operations/backup/state.js"; import { getBalances } from "./operations/balance.js"; -import { - runOperationWithErrorReporting, - storeOperationError, - storeOperationPending, -} from "./operations/common.js"; +import { runOperationWithErrorReporting } from "./operations/common.js"; import { createDepositGroup, getFeeForDeposit, @@ -216,23 +206,23 @@ import { } from "./operations/withdraw.js"; import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; -import { createDenominationTimeline } from "./util/denominations.js"; +import { + createTimeline, + selectBestForOverlappingDenominations, + selectMinimumFee, +} from "./util/denominations.js"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, } from "./util/http.js"; -import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; +import { checkDbInvariant } from "./util/invariants.js"; import { AsyncCondition, OpenedPromise, openPromise, } from "./util/promiseUtils.js"; import { DbAccess, GetReadWriteAccess } from "./util/query.js"; -import { - OperationAttemptResult, - OperationAttemptResultType, - RetryInfo, -} from "./util/retries.js"; +import { OperationAttemptResult } from "./util/retries.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, @@ -702,6 +692,7 @@ async function getExchangeDetailedInfo( paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), auditors: exchangeDetails.auditors, wireInfo: exchangeDetails.wireInfo, + globalFees: exchangeDetails.globalFees, }, denominations, }; @@ -711,32 +702,111 @@ async function getExchangeDetailedInfo( throw Error(`exchange with base url "${exchangeBaseurl}" not found`); } - const feesDescription: OperationMap<FeeDescription[]> = { - deposit: createDenominationTimeline( - exchange.denominations, + const denoms = exchange.denominations.map((d) => ({ + ...d, + group: Amounts.stringifyValue(d.value), + })); + const denomFees: DenomOperationMap<FeeDescription[]> = { + deposit: createTimeline( + denoms, + "denomPubHash", + "stampStart", "stampExpireDeposit", "feeDeposit", + "group", + selectBestForOverlappingDenominations, ), - refresh: createDenominationTimeline( - exchange.denominations, + refresh: createTimeline( + denoms, + "denomPubHash", + "stampStart", "stampExpireWithdraw", "feeRefresh", + "group", + selectBestForOverlappingDenominations, ), - refund: createDenominationTimeline( - exchange.denominations, + refund: createTimeline( + denoms, + "denomPubHash", + "stampStart", "stampExpireWithdraw", "feeRefund", + "group", + selectBestForOverlappingDenominations, ), - withdraw: createDenominationTimeline( - exchange.denominations, + withdraw: createTimeline( + denoms, + "denomPubHash", + "stampStart", "stampExpireWithdraw", "feeWithdraw", + "group", + selectBestForOverlappingDenominations, ), }; + const transferFees = Object.entries( + exchange.info.wireInfo.feesForType, + ).reduce((prev, [wireType, infoForType]) => { + const feesByGroup = [ + ...infoForType.map((w) => ({ + ...w, + fee: w.closingFee, + group: "closing", + })), + ...infoForType.map((w) => ({ ...w, fee: w.wadFee, group: "wad" })), + ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), + ]; + prev[wireType] = createTimeline( + feesByGroup, + "sig", + "startStamp", + "endStamp", + "fee", + "group", + selectMinimumFee, + ); + return prev; + }, {} as Record<string, FeeDescription[]>); + + const globalFeesByGroup = [ + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.accountFee, + group: "account", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.historyFee, + group: "history", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.kycFee, + group: "kyc", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.purseFee, + group: "purse", + })), + ]; + + const globalFees = createTimeline( + globalFeesByGroup, + "signature", + "startDate", + "endDate", + "fee", + "group", + selectMinimumFee, + ); + return { ...exchange.info, - feesDescription, + denomFees, + transferFees, + globalFees, }; } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index 5c62671fe..7ccf7f606 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -46,12 +46,14 @@ const exchanges: ExchangeFullDetails[] = [ denomination_keys: [], }, ], - feesDescription: { + denomFees: { deposit: [], refresh: [], refund: [], withdraw: [], }, + globalFees: [], + transferFees: {}, wireInfo: { accounts: [], feesForType: {}, diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts index 4b28904fb..9603b3d2c 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts @@ -15,15 +15,15 @@ */ import { - FeeDescription, - FeeDescriptionPair, - AbsoluteTime, + DenomOperationMap, ExchangeFullDetails, - OperationMap, - ExchangeListItem, + ExchangeListItem, FeeDescriptionPair } from "@gnu-taler/taler-util"; import { Loading } from "../../components/Loading.js"; import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { + State as SelectExchangeState +} from "../../hooks/useSelectedExchange.js"; import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; import { compose, StateViewMap } from "../../utils/index.js"; import * as wxApi from "../../wxApi.js"; @@ -32,7 +32,7 @@ import { ComparingView, ErrorLoadingView, NoExchangesView, - ReadyView, + ReadyView } from "./views.js"; export interface Props { @@ -41,9 +41,6 @@ export interface Props { onCancel: () => Promise<void>; onSelection: (exchange: string) => Promise<void>; } -import { - State as SelectExchangeState -} from "../../hooks/useSelectedExchange.js"; export type State = | State.Loading @@ -71,13 +68,12 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; - timeline: OperationMap<FeeDescription[]>; onClose: ButtonHandler; } export interface Comparing extends BaseInfo { status: "comparing"; - pairTimeline: OperationMap<FeeDescriptionPair[]>; + pairTimeline: DenomOperationMap<FeeDescriptionPair[]>; onReset: ButtonHandler; onSelect: ButtonHandler; } diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts index 0279f6514..954e52239 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts @@ -14,8 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { FeeDescription, OperationMap } from "@gnu-taler/taler-util"; -import { createDenominationPairTimeline } from "@gnu-taler/taler-wallet-core"; +import { DenomOperationMap, FeeDescription } from "@gnu-taler/taler-util"; +import { createPairTimeline } from "@gnu-taler/taler-wallet-core"; import { useState } from "preact/hooks"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import * as wxApi from "../../wxApi.js"; @@ -94,27 +94,26 @@ export function useComponentState( onClick: onCancel, }, selected, - timeline: selected.feesDescription, }; } - const pairTimeline: OperationMap<FeeDescription[]> = { - deposit: createDenominationPairTimeline( - selected.feesDescription.deposit, - original.feesDescription.deposit, + const pairTimeline: DenomOperationMap<FeeDescription[]> = { + deposit: createPairTimeline( + selected.denomFees.deposit, + original.denomFees.deposit, ), - refresh: createDenominationPairTimeline( - selected.feesDescription.refresh, - original.feesDescription.refresh, + refresh: createPairTimeline( + selected.denomFees.refresh, + original.denomFees.refresh, ), - refund: createDenominationPairTimeline( - selected.feesDescription.refund, - original.feesDescription.refund, - ), - withdraw: createDenominationPairTimeline( - selected.feesDescription.withdraw, - original.feesDescription.withdraw, + refund: createPairTimeline( + selected.denomFees.refund, + original.denomFees.refund, ), + withdraw: createPairTimeline( + selected.denomFees.withdraw, + original.denomFees.withdraw, + ) }; return { diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx index 43a147e28..38b63e615 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx @@ -28,71 +28,72 @@ export default { export const Bitcoin1 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { "0": "https://exchange.taler.ar" }, + value: "0", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); export const Bitcoin2 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { + "https://exchange.taler.ar": "https://exchange.taler.ar", + "https://exchange-btc.taler.ar": "https://exchange-btc.taler.ar", + }, + value: "https://exchange.taler.ar", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); + export const Kudos1 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { + "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar", + }, + value: "https://exchange-kudos.taler.ar", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); export const Kudos2 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { + "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar", + "https://exchange-kudos2.taler.ar": "https://exchange-kudos2.taler.ar", + }, + value: "https://exchange-kudos.taler.ar", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); export const ComparingBitcoin = createExample(ComparingView, { exchanges: { @@ -102,6 +103,9 @@ export const ComparingBitcoin = createExample(ComparingView, { selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + transferFees: {}, + globalFees: [], } as any, onReset: {}, onSelect: {}, @@ -121,6 +125,9 @@ export const ComparingKudos = createExample(ComparingView, { selected: { currency: "KUDOS", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + transferFees: {}, + globalFees: [], } as any, onReset: {}, onSelect: {}, @@ -132,3 +139,400 @@ export const ComparingKudos = createExample(ComparingView, { withdraw: [], }, }); + +function timelineExample() { + return { + deposit: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + refresh: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + refund: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + withdraw: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + wad: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + wire: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + closing: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + }; +} diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx index 47554bfcd..6b753e215 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx @@ -31,9 +31,7 @@ import { useTranslationContext } from "../../context/translation.js"; import { Button } from "../../mui/Button.js"; import arrowDown from "../../svg/chevron-down.svg"; import { State } from "./index.js"; -import { - State as SelectExchangeState -} from "../../hooks/useSelectedExchange.js"; +import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; const ButtonGroup = styled.div` & > button { @@ -59,7 +57,7 @@ const FeeDescriptionTable = styled.table` } td.value { text-align: right; - width: 1%; + width: 15%; white-space: nowrap; } td.icon { @@ -109,26 +107,28 @@ export function ErrorLoadingView({ error }: State.LoadingUriError): VNode { return ( <LoadingError - title={<i18n.Translate>Could not load tip status</i18n.Translate>} + title={<i18n.Translate>Could not load exchange fees</i18n.Translate>} error={error} /> ); } - - -export function NoExchangesView({currency}: SelectExchangeState.NoExchange): VNode { +export function NoExchangesView({ + currency, +}: SelectExchangeState.NoExchange): VNode { const { i18n } = useTranslationContext(); if (!currency) { return ( <div> <i18n.Translate>could not find any exchange</i18n.Translate> </div> - ); + ); } return ( <div> - <i18n.Translate>could not find any exchange for the currency {currency}</i18n.Translate> + <i18n.Translate> + could not find any exchange for the currency {currency} + </i18n.Translate> </div> ); } @@ -356,7 +356,6 @@ export function ReadyView({ exchanges, selected, onClose, - timeline, }: State.Ready): VNode { const { i18n } = useTranslationContext(); @@ -365,7 +364,10 @@ export function ReadyView({ <h2> <i18n.Translate>Service fee description</i18n.Translate> </h2> - + <p> + All fee indicated below are in the same and only currency the exchange + works. + </p> <section> <div style={{ @@ -375,21 +377,27 @@ export function ReadyView({ justifyContent: "space-between", }} > - <p> - <Input> - <SelectList - label={ - <i18n.Translate> - Select {selected.currency} exchange - </i18n.Translate> - } - list={exchanges.list} - name="lang" - value={exchanges.value} - onChange={exchanges.onChange} - /> - </Input> - </p> + {Object.keys(exchanges.list).length === 1 ? ( + <Fragment> + <p>Exchange: {selected.exchangeBaseUrl}</p> + </Fragment> + ) : ( + <p> + <Input> + <SelectList + label={ + <i18n.Translate> + Select {selected.currency} exchange + </i18n.Translate> + } + list={exchanges.list} + name="lang" + value={exchanges.value} + onChange={exchanges.onChange} + /> + </Input> + </p> + )} <Button variant="outlined" onClick={onClose.onClick}> <i18n.Translate>Close</i18n.Translate> </Button> @@ -411,17 +419,26 @@ export function ReadyView({ <table> <tr> <td> - <i18n.Translate>currency</i18n.Translate> + <i18n.Translate>Currency</i18n.Translate> + </td> + <td> + <b>{selected.currency}</b> </td> - <td>{selected.currency}</td> </tr> </table> </section> <section> <h2> - <i18n.Translate>Operations</i18n.Translate> + <i18n.Translate>Coin operations</i18n.Translate> </h2> <p> + <i18n.Translate> + Every operation in this section may be different by denomination + value and is valid for a period of time. The exchange will charge + the indicated amount every time a coin is used in such operation. + </i18n.Translate> + </p> + <p> <i18n.Translate>Deposits</i18n.Translate> </p> <FeeDescriptionTable> @@ -440,7 +457,10 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.deposit} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.deposit} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> </FeeDescriptionTable> <p> @@ -462,7 +482,10 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.withdraw} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.withdraw} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> </FeeDescriptionTable> <p> @@ -484,7 +507,10 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.refund} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.refund} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> </FeeDescriptionTable>{" "} <p> @@ -506,53 +532,81 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.refresh} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.refresh} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> - </FeeDescriptionTable>{" "} + </FeeDescriptionTable> </section> <section> - <table> + <h2> + <i18n.Translate>Transfer operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + {Object.entries(selected.transferFees).map(([type, fees], idx) => { + return ( + <Fragment key={idx}> + <p>{type}</p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Operation</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue list={fees} /> + </tbody> + </FeeDescriptionTable> + </Fragment> + ); + })} + </section> + <section> + <h2> + <i18n.Translate>Wallet operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + <FeeDescriptionTable> <thead> <tr> - <td> - <i18n.Translate>Wallet operations</i18n.Translate> - </td> - <td> + <th> </th> + <th> + <i18n.Translate>Feature</i18n.Translate> + </th> + <th class="fee"> <i18n.Translate>Fee</i18n.Translate> - </td> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> </tr> </thead> <tbody> - <tr> - <td>history(i) </td> - <td>0.1</td> - </tr> - <tr> - <td>kyc (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>account (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>purse (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>wire SEPA (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>closing SEPA(i) </td> - <td>0.1</td> - </tr> - <tr> - <td>wad SEPA (i) </td> - <td>0.1</td> - </tr> + <RenderFeeDescriptionByValue list={selected.globalFees} /> </tbody> - </table> + </FeeDescriptionTable> </section> <section> <ButtonGroup> @@ -579,7 +633,7 @@ function FeeDescriptionRowsGroup({ <tr key={idx} class="value" - data-hasMore={!hasMoreInfo} + data-hasMore={hasMoreInfo} data-main={main} data-hidden={!main && !expanded} onClick={() => setExpand((p) => !p)} @@ -594,9 +648,7 @@ function FeeDescriptionRowsGroup({ /> ) : undefined} </td> - <td class="value"> - {main ? <Amount value={info.value} hideCurrency /> : ""} - </td> + <td class="value">{main ? info.group : ""}</td> {info.fee ? ( <td class="fee">{<Amount value={info.fee} hideCurrency />}</td> ) : undefined} @@ -621,7 +673,7 @@ function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode { <tr key={idx} class="value" - data-hasMore={!hasMoreInfo} + data-hasMore={hasMoreInfo} data-main={main} data-hidden={!main && !expanded} onClick={() => setExpand((p) => !p)} @@ -636,9 +688,7 @@ function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode { /> ) : undefined} </td> - <td class="value"> - {main ? <Amount value={info.value} hideCurrency /> : ""} - </td> + <td class="value">{main ? info.group : ""}</td> {info.left ? ( <td class="fee">{<Amount value={info.left} hideCurrency />}</td> ) : ( @@ -673,7 +723,7 @@ function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode { const next = idx >= list.length - 1 ? undefined : list[idx + 1]; const nextIsMoreInfo = - next !== undefined && Amounts.cmp(next.value, info.value) === 0; + next !== undefined && next.group === info.group; prev.rows.push(info); @@ -681,7 +731,7 @@ function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode { return prev; } - prev.rows = []; + // prev.rows = []; prev.views.push(<FeePairRowsGroup infos={prev.rows} />); return prev; }, @@ -701,36 +751,21 @@ function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode { * @returns */ function RenderFeeDescriptionByValue({ - first, + list, + sorting, }: { - first: FeeDescription[]; + list: FeeDescription[]; + sorting?: (a: string, b: string) => number; }): VNode { - return ( - <Fragment> - { - first.reduce( - (prev, info, idx) => { - const next = idx >= first.length - 1 ? undefined : first[idx + 1]; - - const nextIsMoreInfo = - next !== undefined && Amounts.cmp(next.value, info.value) === 0; - - prev.rows.push(info); - - if (nextIsMoreInfo) { - return prev; - } - - prev.rows = []; - prev.views.push(<FeeDescriptionRowsGroup infos={prev.rows} />); - return prev; - }, - { rows: [], views: [] } as { - rows: FeeDescription[]; - views: h.JSX.Element[]; - }, - ).views - } - </Fragment> - ); + const grouped = list.reduce((prev, cur) => { + if (!prev[cur.group]) { + prev[cur.group] = []; + } + prev[cur.group].push(cur); + return prev; + }, {} as Record<string, FeeDescription[]>); + const p = Object.keys(grouped) + .sort(sorting) + .map((i, idx) => <FeeDescriptionRowsGroup key={idx} infos={grouped[i]} />); + return <Fragment>{p}</Fragment>; } |