This commit is contained in:
Sebastian 2022-11-24 23:16:01 -03:00
parent 88618df7b8
commit e05ba843a0
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
29 changed files with 1182 additions and 70 deletions

View File

@ -216,6 +216,23 @@ export function parsePayUri(s: string): PayUriResult | undefined {
};
}
export function constructPayUri(
merchantBaseUrl: string,
orderId: string,
sessionId: string,
claimToken?: string,
noncePriv?: string,
): string {
const base = canonicalizeBaseUrl(merchantBaseUrl);
const url = new URL(base);
const isHttp = base.startsWith("http://");
let result = isHttp ? `taler+http://pay/` : `taler://pay/`;
result += `${url.hostname}${url.pathname}${orderId}/${sessionId}?`;
if (claimToken) result += `c=${claimToken}`;
if (noncePriv) result += `n=${noncePriv}`;
return result;
}
export function parsePayPushUri(s: string): PayPushUriResult | undefined {
const pi = parseProtoInfo(s, talerActionPayPush);
if (!pi) {

View File

@ -40,6 +40,7 @@ import {
codecForAny,
codecForBoolean,
codecForConstString,
codecForEither,
codecForList,
codecForMap,
codecForNumber,
@ -384,6 +385,7 @@ export enum PreparePayResultType {
PaymentPossible = "payment-possible",
InsufficientBalance = "insufficient-balance",
AlreadyConfirmed = "already-confirmed",
Lost = "lost",
}
export const codecForPreparePayResultPaymentPossible =
@ -394,6 +396,7 @@ export const codecForPreparePayResultPaymentPossible =
.property("contractTerms", codecForMerchantContractTerms())
.property("proposalId", codecForString())
.property("contractTermsHash", codecForString())
.property("talerUri", codecForString())
.property("noncePriv", codecForString())
.property(
"status",
@ -406,6 +409,7 @@ export const codecForPreparePayResultInsufficientBalance =
buildCodecForObject<PreparePayResultInsufficientBalance>()
.property("amountRaw", codecForAmountString())
.property("contractTerms", codecForAny())
.property("talerUri", codecForString())
.property("proposalId", codecForString())
.property("noncePriv", codecForString())
.property(
@ -424,11 +428,18 @@ export const codecForPreparePayResultAlreadyConfirmed =
.property("amountEffective", codecForAmountString())
.property("amountRaw", codecForAmountString())
.property("paid", codecForBoolean())
.property("talerUri", codecOptional(codecForString()))
.property("contractTerms", codecForAny())
.property("contractTermsHash", codecForString())
.property("proposalId", codecForString())
.build("PreparePayResultAlreadyConfirmed");
export const codecForPreparePayResultPaymentLost =
(): Codec<PreparePayResultPaymentLost> =>
buildCodecForObject<PreparePayResultPaymentLost>()
.property("status", codecForConstString(PreparePayResultType.Lost))
.build("PreparePayResultLost");
export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
buildCodecForUnion<PreparePayResult>()
.discriminateOn("status")
@ -444,6 +455,10 @@ export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
PreparePayResultType.PaymentPossible,
codecForPreparePayResultPaymentPossible(),
)
.alternative(
PreparePayResultType.Lost,
codecForPreparePayResultPaymentLost(),
)
.build("PreparePayResult");
/**
@ -452,7 +467,8 @@ export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
export type PreparePayResult =
| PreparePayResultInsufficientBalance
| PreparePayResultAlreadyConfirmed
| PreparePayResultPaymentPossible;
| PreparePayResultPaymentPossible
| PreparePayResultPaymentLost;
/**
* Payment is possible.
@ -465,6 +481,7 @@ export interface PreparePayResultPaymentPossible {
amountRaw: string;
amountEffective: string;
noncePriv: string;
talerUri: string;
}
export interface PreparePayResultInsufficientBalance {
@ -473,6 +490,7 @@ export interface PreparePayResultInsufficientBalance {
contractTerms: MerchantContractTerms;
amountRaw: string;
noncePriv: string;
talerUri: string;
}
export interface PreparePayResultAlreadyConfirmed {
@ -483,6 +501,11 @@ export interface PreparePayResultAlreadyConfirmed {
amountEffective: string;
contractTermsHash: string;
proposalId: string;
talerUri?: string;
}
export interface PreparePayResultPaymentLost {
status: PreparePayResultType.Lost;
}
export interface BankWithdrawDetails {
@ -1677,6 +1700,170 @@ export interface WithdrawFakebankRequest {
bank: string;
}
export enum AttentionPriority {
High = "high",
Medium = "medium",
Low = "low",
}
export interface UserAttentionByIdRequest {
entityId: string;
type: AttentionType;
}
export const codecForUserAttentionByIdRequest =
(): Codec<UserAttentionByIdRequest> =>
buildCodecForObject<UserAttentionByIdRequest>()
.property("type", codecForAny())
.property("entityId", codecForString())
.build("UserAttentionByIdRequest");
export const codecForUserAttentionsRequest = (): Codec<UserAttentionsRequest> =>
buildCodecForObject<UserAttentionsRequest>()
.property(
"priority",
codecOptional(
codecForEither(
codecForConstString(AttentionPriority.Low),
codecForConstString(AttentionPriority.Medium),
codecForConstString(AttentionPriority.High),
),
),
)
.build("UserAttentionsRequest");
export interface UserAttentionsRequest {
priority?: AttentionPriority;
}
export type AttentionInfo =
| AttentionKycWithdrawal
| AttentionBackupUnpaid
| AttentionBackupExpiresSoon
| AttentionMerchantRefund
| AttentionExchangeTosChanged
| AttentionExchangeKeyExpired
| AttentionExchangeDenominationExpired
| AttentionAuditorTosChanged
| AttentionAuditorKeyExpires
| AttentionAuditorDenominationExpires
| AttentionPullPaymentPaid
| AttentionPushPaymentReceived;
export enum AttentionType {
KycWithdrawal = "kyc-withdrawal",
BackupUnpaid = "backup-unpaid",
BackupExpiresSoon = "backup-expires-soon",
MerchantRefund = "merchant-refund",
ExchangeTosChanged = "exchange-tos-changed",
ExchangeKeyExpired = "exchange-key-expired",
ExchangeKeyExpiresSoon = "exchange-key-expires-soon",
ExchangeDenominationsExpired = "exchange-denominations-expired",
ExchangeDenominationsExpiresSoon = "exchange-denominations-expires-soon",
AuditorTosChanged = "auditor-tos-changed",
AuditorKeyExpires = "auditor-key-expires",
AuditorDenominationsExpires = "auditor-denominations-expires",
PullPaymentPaid = "pull-payment-paid",
PushPaymentReceived = "push-payment-withdrawn",
}
export const UserAttentionPriority: {
[type in AttentionType]: AttentionPriority;
} = {
"kyc-withdrawal": AttentionPriority.Medium,
"backup-unpaid": AttentionPriority.High,
"backup-expires-soon": AttentionPriority.Medium,
"merchant-refund": AttentionPriority.Medium,
"exchange-tos-changed": AttentionPriority.Medium,
"exchange-key-expired": AttentionPriority.High,
"exchange-key-expires-soon": AttentionPriority.Medium,
"exchange-denominations-expired": AttentionPriority.High,
"exchange-denominations-expires-soon": AttentionPriority.Medium,
"auditor-tos-changed": AttentionPriority.Medium,
"auditor-key-expires": AttentionPriority.Medium,
"auditor-denominations-expires": AttentionPriority.Medium,
"pull-payment-paid": AttentionPriority.High,
"push-payment-withdrawn": AttentionPriority.High,
};
interface AttentionBackupExpiresSoon {
type: AttentionType.BackupExpiresSoon;
provider_base_url: string;
}
interface AttentionBackupUnpaid {
type: AttentionType.BackupUnpaid;
provider_base_url: string;
talerUri: string;
}
interface AttentionMerchantRefund {
type: AttentionType.MerchantRefund;
transactionId: string;
}
interface AttentionKycWithdrawal {
type: AttentionType.KycWithdrawal;
transactionId: string;
}
interface AttentionExchangeTosChanged {
type: AttentionType.ExchangeTosChanged;
exchange_base_url: string;
}
interface AttentionExchangeKeyExpired {
type: AttentionType.ExchangeKeyExpired;
exchange_base_url: string;
}
interface AttentionExchangeDenominationExpired {
type: AttentionType.ExchangeDenominationsExpired;
exchange_base_url: string;
}
interface AttentionAuditorTosChanged {
type: AttentionType.AuditorTosChanged;
auditor_base_url: string;
}
interface AttentionAuditorKeyExpires {
type: AttentionType.AuditorKeyExpires;
auditor_base_url: string;
}
interface AttentionAuditorDenominationExpires {
type: AttentionType.AuditorDenominationsExpires;
auditor_base_url: string;
}
interface AttentionPullPaymentPaid {
type: AttentionType.PullPaymentPaid;
transactionId: string;
}
interface AttentionPushPaymentReceived {
type: AttentionType.PushPaymentReceived;
transactionId: string;
}
export type UserAttentionUnreadList = Array<{
info: AttentionInfo;
when: AbsoluteTime;
read: boolean;
}>;
export interface UserAttentionsResponse {
pending: UserAttentionUnreadList;
}
export interface UserAttentionsCountResponse {
total: number;
}
export const codecForWithdrawFakebankRequest =
(): Codec<WithdrawFakebankRequest> =>
buildCodecForObject<WithdrawFakebankRequest>()

View File

@ -48,6 +48,9 @@ import {
WireInfo,
HashCodeString,
Amounts,
AttentionPriority,
AttentionInfo,
AbsoluteTime,
} from "@gnu-taler/taler-util";
import {
describeContents,
@ -1540,6 +1543,8 @@ export interface BackupProviderRecord {
*/
currentPaymentProposalId?: string;
shouldRetryFreshProposal: boolean;
/**
* Proposals that were used to pay (or attempt to pay) the provider.
*
@ -1841,6 +1846,21 @@ export interface ContractTermsRecord {
contractTermsRaw: any;
}
export interface UserAttentionRecord {
info: AttentionInfo;
entityId: string;
/**
* When the notification was created.
*/
createdMs: number;
/**
* When the user mark this notification as read.
*/
read: TalerProtocolTimestamp | undefined;
}
/**
* Schema definition for the IndexedDB
* wallet database.
@ -2137,6 +2157,13 @@ export const WalletStoresV1 = {
}),
{},
),
userAttention: describeStore(
"userAttention",
describeContents<UserAttentionRecord>({
keyPath: ["entityId", "info.type"],
}),
{},
),
};
/**

View File

@ -0,0 +1,145 @@
/*
This file is part of GNU Taler
(C) 2019 GNUnet e.V.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import {
AbsoluteTime,
AttentionInfo,
Logger,
TalerProtocolTimestamp,
UserAttentionByIdRequest,
UserAttentionPriority,
UserAttentionsCountResponse,
UserAttentionsRequest,
UserAttentionsResponse,
UserAttentionUnreadList,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
const logger = new Logger("operations/attention.ts");
export async function getUserAttentionsUnreadCount(
ws: InternalWalletState,
req: UserAttentionsRequest,
): Promise<UserAttentionsCountResponse> {
const total = await ws.db
.mktx((x) => [x.userAttention])
.runReadOnly(async (tx) => {
let count = 0;
await tx.userAttention.iter().forEach((x) => {
if (
req.priority !== undefined &&
UserAttentionPriority[x.info.type] !== req.priority
)
return;
if (x.read !== undefined) return;
count++;
});
return count;
});
return { total };
}
export async function getUserAttentions(
ws: InternalWalletState,
req: UserAttentionsRequest,
): Promise<UserAttentionsResponse> {
return await ws.db
.mktx((x) => [x.userAttention])
.runReadOnly(async (tx) => {
const pending: UserAttentionUnreadList = [];
await tx.userAttention.iter().forEach((x) => {
if (
req.priority !== undefined &&
UserAttentionPriority[x.info.type] !== req.priority
)
return;
pending.push({
info: x.info,
when: {
t_ms: x.createdMs,
},
read: x.read !== undefined,
});
});
return { pending };
});
}
export async function markAttentionRequestAsRead(
ws: InternalWalletState,
req: UserAttentionByIdRequest,
): Promise<void> {
await ws.db
.mktx((x) => [x.userAttention])
.runReadWrite(async (tx) => {
const ua = await tx.userAttention.get([req.entityId, req.type]);
if (!ua) throw Error("attention request not found");
tx.userAttention.put({
...ua,
read: TalerProtocolTimestamp.now(),
});
});
}
/**
* the wallet need the user attention to complete a task
* internal API
*
* @param ws
* @param info
*/
export async function addAttentionRequest(
ws: InternalWalletState,
info: AttentionInfo,
entityId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.userAttention])
.runReadWrite(async (tx) => {
await tx.userAttention.put({
info,
entityId,
createdMs: AbsoluteTime.now().t_ms as number,
read: undefined,
});
});
}
/**
* user completed the task, attention request is not needed
* internal API
*
* @param ws
* @param created
*/
export async function removeAttentionRequest(
ws: InternalWalletState,
req: UserAttentionByIdRequest,
): Promise<void> {
await ws.db
.mktx((x) => [x.userAttention])
.runReadWrite(async (tx) => {
const ua = await tx.userAttention.get([req.entityId, req.type]);
if (!ua) throw Error("attention request not found");
await tx.userAttention.delete([req.entityId, req.type]);
});
}

View File

@ -27,6 +27,7 @@
import {
AbsoluteTime,
AmountString,
AttentionType,
BackupRecovery,
buildCodecForObject,
buildCodecForUnion,
@ -57,13 +58,17 @@ import {
kdf,
Logger,
notEmpty,
PaymentStatus,
PreparePayResult,
PreparePayResultType,
RecoveryLoadRequest,
RecoveryMergeStrategy,
ReserveTransactionType,
rsaBlind,
secretbox,
secretbox_open,
stringToBytes,
TalerErrorCode,
TalerErrorDetail,
TalerProtocolTimestamp,
URL,
@ -80,6 +85,7 @@ import {
ConfigRecordKey,
WalletBackupConfState,
} from "../../db.js";
import { TalerError } from "../../errors.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
import {
@ -96,6 +102,7 @@ import {
RetryTags,
scheduleRetryInTx,
} from "../../util/retries.js";
import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
import {
checkPaymentByProposalId,
confirmPay,
@ -198,6 +205,7 @@ async function computeBackupCryptoData(
);
}
for (const purch of backupContent.purchases) {
if (!purch.contract_terms_raw) continue;
const { h: contractTermsHash } = await cryptoApi.hashString({
str: canonicalJson(purch.contract_terms_raw),
});
@ -251,7 +259,7 @@ function getNextBackupTimestamp(): TalerProtocolTimestamp {
async function runBackupCycleForProvider(
ws: InternalWalletState,
args: BackupForProviderArgs,
): Promise<OperationAttemptResult<unknown, { talerUri: string }>> {
): Promise<OperationAttemptResult<unknown, { talerUri?: string }>> {
const provider = await ws.db
.mktx((x) => [x.backupProviders])
.runReadOnly(async (tx) => {
@ -292,6 +300,10 @@ async function runBackupCycleForProvider(
provider.baseUrl,
);
if (provider.shouldRetryFreshProposal) {
accountBackupUrl.searchParams.set("fresh", "yes");
}
const resp = await ws.http.fetch(accountBackupUrl.href, {
method: "POST",
body: encBackup,
@ -324,6 +336,12 @@ async function runBackupCycleForProvider(
};
await tx.backupProviders.put(prov);
});
removeAttentionRequest(ws, {
entityId: provider.baseUrl,
type: AttentionType.BackupUnpaid,
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
@ -340,8 +358,21 @@ async function runBackupCycleForProvider(
//We can't delay downloading the proposal since we need the id
//FIXME: check download errors
let res: PreparePayResult | undefined = undefined;
try {
res = await preparePayForUri(ws, talerUri);
} catch (e) {
const error = TalerError.fromException(e);
if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
throw error;
}
}
const res = await preparePayForUri(ws, talerUri);
if (
res === undefined ||
res.status === PreparePayResultType.AlreadyConfirmed
) {
//claimed
await ws.db
.mktx((x) => [x.backupProviders, x.operationRetries])
@ -353,7 +384,7 @@ async function runBackupCycleForProvider(
}
const opId = RetryTags.forBackup(prov);
await scheduleRetryInTx(ws, tx, opId);
prov.currentPaymentProposalId = res.proposalId;
prov.shouldRetryFreshProposal = true;
prov.state = {
tag: BackupProviderStateTag.Retrying,
};
@ -367,6 +398,47 @@ async function runBackupCycleForProvider(
},
};
}
const result = res;
if (result.status === PreparePayResultType.Lost) {
throw Error("invalid state, could not get proposal for backup");
}
await ws.db
.mktx((x) => [x.backupProviders, x.operationRetries])
.runReadWrite(async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
logger.warn("backup provider not found anymore");
return;
}
const opId = RetryTags.forBackup(prov);
await scheduleRetryInTx(ws, tx, opId);
prov.currentPaymentProposalId = result.proposalId;
prov.shouldRetryFreshProposal = false;
prov.state = {
tag: BackupProviderStateTag.Retrying,
};
await tx.backupProviders.put(prov);
});
addAttentionRequest(
ws,
{
type: AttentionType.BackupUnpaid,
provider_base_url: provider.baseUrl,
talerUri,
},
provider.baseUrl,
);
return {
type: OperationAttemptResultType.Pending,
result: {
talerUri,
},
};
}
if (resp.status === HttpStatusCode.NoContent) {
await ws.db
@ -384,6 +456,12 @@ async function runBackupCycleForProvider(
};
await tx.backupProviders.put(prov);
});
removeAttentionRequest(ws, {
entityId: provider.baseUrl,
type: AttentionType.BackupUnpaid,
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
@ -564,7 +642,7 @@ interface AddBackupProviderOk {
}
interface AddBackupProviderPaymentRequired {
status: "payment-required";
talerUri: string;
talerUri?: string;
}
interface AddBackupProviderError {
status: "error";
@ -580,7 +658,7 @@ export const codecForAddBackupProviderPaymenrRequired =
(): Codec<AddBackupProviderPaymentRequired> =>
buildCodecForObject<AddBackupProviderPaymentRequired>()
.property("status", codecForConstString("payment-required"))
.property("talerUri", codecForString())
.property("talerUri", codecOptional(codecForString()))
.build("AddBackupProviderPaymentRequired");
export const codecForAddBackupProviderError =
@ -655,6 +733,7 @@ export async function addBackupProvider(
storageLimitInMegabytes: terms.storage_limit_in_megabytes,
supportedProtocolVersion: terms.version,
},
shouldRetryFreshProposal: false,
paymentProposalIds: [],
baseUrl: canonUrl,
uids: [encodeCrock(getRandomBytes(32))],
@ -779,10 +858,12 @@ export interface ProviderPaymentUnpaid {
export interface ProviderPaymentInsufficientBalance {
type: ProviderPaymentType.InsufficientBalance;
amount: AmountString;
}
export interface ProviderPaymentPending {
type: ProviderPaymentType.Pending;
talerUri?: string;
}
export interface ProviderPaymentPaid {
@ -810,32 +891,40 @@ async function getProviderPaymentInfo(
ws,
provider.currentPaymentProposalId,
);
if (status.status === PreparePayResultType.InsufficientBalance) {
switch (status.status) {
case PreparePayResultType.InsufficientBalance:
return {
type: ProviderPaymentType.InsufficientBalance,
amount: status.amountRaw,
};
}
if (status.status === PreparePayResultType.PaymentPossible) {
case PreparePayResultType.PaymentPossible:
return {
type: ProviderPaymentType.Pending,
talerUri: status.talerUri,
};
}
if (status.status === PreparePayResultType.AlreadyConfirmed) {
case PreparePayResultType.Lost:
return {
type: ProviderPaymentType.Unpaid,
};
case PreparePayResultType.AlreadyConfirmed:
if (status.paid) {
return {
type: ProviderPaymentType.Paid,
paidUntil: AbsoluteTime.addDuration(
AbsoluteTime.fromTimestamp(status.contractTerms.timestamp),
durationFromSpec({ years: 1 }),
durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
),
};
} else {
return {
type: ProviderPaymentType.Pending,
talerUri: status.talerUri,
};
}
default:
assertUnreachable(status);
}
throw Error("not reached");
}
/**
@ -936,6 +1025,7 @@ async function backupRecoveryTheirs(
baseUrl: prov.url,
name: prov.name,
paymentProposalIds: [],
shouldRetryFreshProposal: false,
state: {
tag: BackupProviderStateTag.Ready,
nextBackupTimestamp: TalerProtocolTimestamp.now(),

View File

@ -72,6 +72,7 @@ import {
TalerProtocolTimestamp,
TransactionType,
URL,
constructPayUri,
} from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
@ -1290,7 +1291,10 @@ export async function checkPaymentByProposalId(
return tx.purchases.get(proposalId);
});
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
// throw Error(`could not get proposal ${proposalId}`);
return {
status: PreparePayResultType.Lost,
};
}
if (proposal.purchaseStatus === PurchaseStatus.RepurchaseDetected) {
const existingProposalId = proposal.repurchaseProposalId;
@ -1316,6 +1320,14 @@ export async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
const talerUri = constructPayUri(
proposal.merchantBaseUrl,
proposal.orderId,
proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
proposal.claimToken,
proposal.noncePriv,
);
// First check if we already paid for it.
const purchase = await ws.db
.mktx((x) => [x.purchases])
@ -1345,6 +1357,7 @@ export async function checkPaymentByProposalId(
proposalId: proposal.proposalId,
noncePriv: proposal.noncePriv,
amountRaw: Amounts.stringify(d.contractData.amount),
talerUri,
};
}
@ -1360,6 +1373,7 @@ export async function checkPaymentByProposalId(
amountEffective: Amounts.stringify(totalCost),
amountRaw: Amounts.stringify(res.paymentAmount),
contractTermsHash: d.contractData.contractTermsHash,
talerUri,
};
}
@ -1396,6 +1410,7 @@ export async function checkPaymentByProposalId(
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
proposalId,
talerUri,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
const download = await expectProposalDownload(ws, purchase);
@ -1407,6 +1422,7 @@ export async function checkPaymentByProposalId(
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
proposalId,
talerUri,
};
} else {
const paid =
@ -1423,6 +1439,7 @@ export async function checkPaymentByProposalId(
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
...(paid ? { nextUrl: download.contractData.orderId } : {}),
proposalId,
talerUri,
};
}
}
@ -1468,7 +1485,7 @@ export async function preparePayForUri(
);
}
let proposalId = await startDownloadProposal(
const proposalId = await startDownloadProposal(
ws,
uriResult.merchantBaseUrl,
uriResult.orderId,
@ -1930,6 +1947,28 @@ export async function processPurchasePay(
);
}
if (resp.status === HttpStatusCode.Gone) {
const errDetails = await readUnexpectedResponseDetails(resp);
logger.warn("unexpected 410 response for /pay");
logger.warn(j2s(errDetails));
await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const purch = await tx.purchases.get(proposalId);
if (!purch) {
return;
}
// FIXME: Should be some "PayPermanentlyFailed" and error info should be stored
purch.purchaseStatus = PurchaseStatus.PaymentAbortFinished;
await tx.purchases.put(purch);
});
throw makePendingOperationFailedError(
errDetails,
TransactionType.Payment,
proposalId,
);
}
if (resp.status === HttpStatusCode.Conflict) {
const err = await readTalerErrorResponse(resp);
if (

View File

@ -15,5 +15,5 @@
*/
export function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
throw new Error(`Didn't expect to get here ${x}`);
}

View File

@ -71,6 +71,9 @@ import {
KnownBankAccounts,
ListKnownBankAccountsRequest,
ManualWithdrawalDetails,
UserAttentionsCountResponse,
UserAttentionsRequest,
UserAttentionsResponse,
PrepareDepositRequest,
PrepareDepositResponse,
PreparePayRequest,
@ -102,6 +105,7 @@ import {
WithdrawFakebankRequest,
WithdrawTestBalanceRequest,
WithdrawUriInfoResponse,
UserAttentionByIdRequest,
} from "@gnu-taler/taler-util";
import { WalletContractData } from "./db.js";
import {
@ -133,6 +137,9 @@ export enum WalletApiOperation {
GetWithdrawalDetailsForAmount = "getWithdrawalDetailsForAmount",
AcceptManualWithdrawal = "acceptManualWithdrawal",
GetBalances = "getBalances",
GetUserAttentionRequests = "getUserAttentionRequests",
GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
GetPendingOperations = "getPendingOperations",
SetExchangeTosAccepted = "setExchangeTosAccepted",
ApplyRefund = "applyRefund",
@ -746,6 +753,33 @@ export type WithdrawFakebankOp = {
response: EmptyObject;
};
/**
* Get wallet-internal pending tasks.
*/
export type GetUserAttentionRequests = {
op: WalletApiOperation.GetUserAttentionRequests;
request: UserAttentionsRequest;
response: UserAttentionsResponse;
};
/**
* Get wallet-internal pending tasks.
*/
export type MarkAttentionRequestAsRead = {
op: WalletApiOperation.MarkAttentionRequestAsRead;
request: UserAttentionByIdRequest;
response: EmptyObject;
};
/**
* Get wallet-internal pending tasks.
*/
export type GetUserAttentionsUnreadCount = {
op: WalletApiOperation.GetUserAttentionUnreadCount;
request: UserAttentionsRequest;
response: UserAttentionsCountResponse;
};
/**
* Get wallet-internal pending tasks.
*/
@ -798,6 +832,9 @@ export type WalletOperations = {
[WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;
[WalletApiOperation.RetryPendingNow]: RetryPendingNowOp;
[WalletApiOperation.GetPendingOperations]: GetPendingTasksOp;
[WalletApiOperation.GetUserAttentionRequests]: GetUserAttentionRequests;
[WalletApiOperation.GetUserAttentionUnreadCount]: GetUserAttentionsUnreadCount;
[WalletApiOperation.MarkAttentionRequestAsRead]: MarkAttentionRequestAsRead;
[WalletApiOperation.DumpCoins]: DumpCoinsOp;
[WalletApiOperation.SetCoinSuspended]: SetCoinSuspendedOp;
[WalletApiOperation.ForceRefresh]: ForceRefreshOp;

View File

@ -55,6 +55,7 @@ import {
codecForInitiatePeerPushPaymentRequest,
codecForIntegrationTestArgs,
codecForListKnownBankAccounts,
codecForUserAttentionsRequest,
codecForPrepareDepositRequest,
codecForPreparePayRequest,
codecForPreparePeerPullPaymentRequest,
@ -98,6 +99,7 @@ import {
URL,
WalletCoreVersion,
WalletNotification,
codecForUserAttentionByIdRequest,
} from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
@ -147,6 +149,11 @@ import {
} from "./operations/backup/index.js";
import { setWalletDeviceId } from "./operations/backup/state.js";
import { getBalances } from "./operations/balance.js";
import {
getUserAttentions,
getUserAttentionsUnreadCount,
markAttentionRequestAsRead,
} from "./operations/attention.js";
import {
getExchangeTosStatus,
makeExchangeListItem,
@ -1094,6 +1101,18 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
case WalletApiOperation.GetBalances: {
return await getBalances(ws);
}
case WalletApiOperation.GetUserAttentionRequests: {
const req = codecForUserAttentionsRequest().decode(payload);
return await getUserAttentions(ws, req);
}
case WalletApiOperation.MarkAttentionRequestAsRead: {
const req = codecForUserAttentionByIdRequest().decode(payload);
return await markAttentionRequestAsRead(ws, req);
}
case WalletApiOperation.GetUserAttentionUnreadCount: {
const req = codecForUserAttentionsRequest().decode(payload);
return await getUserAttentionsUnreadCount(ws, req);
}
case WalletApiOperation.GetPendingOperations: {
return await getPendingOperations(ws);
}

View File

@ -24,7 +24,7 @@
/**
* Imports.
*/
import { h, VNode } from "preact";
import { Fragment, h, VNode } from "preact";
import {
NavigationHeader,
NavigationHeaderHolder,
@ -33,6 +33,11 @@ import {
import { useTranslationContext } from "./context/translation.js";
import settingsIcon from "./svg/settings_black_24dp.svg";
import qrIcon from "./svg/qr_code_24px.svg";
import warningIcon from "./svg/warning_24px.svg";
import { useAsyncAsHook } from "./hooks/useAsyncAsHook.js";
import { wxApi } from "./wxApi.js";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { JustInDevMode } from "./components/JustInDevMode.js";
/**
* List of pages used by the wallet
@ -102,6 +107,7 @@ export const Pages = {
backupProviderAdd: "/backup/provider/add",
qr: "/qr",
notifications: "/notifications",
settings: "/settings",
settingsExchangeAdd: pageDefinition<{ currency?: string }>(
"/settings/exchange/add/:currency?",
@ -127,7 +133,21 @@ export const Pages = {
),
};
export function PopupNavBar({ path = "" }: { path?: string }): VNode {
export function PopupNavBar({
path = "",
}: {
path?: string;
}): // api: typeof wxApi,
VNode {
const api = wxApi; //FIXME: as parameter
const hook = useAsyncAsHook(async () => {
return await api.wallet.call(
WalletApiOperation.GetUserAttentionUnreadCount,
{},
);
});
const attentionCount = !hook || hook.hasError ? 0 : hook.response.total;
const { i18n } = useTranslationContext();
return (
<NavigationHeader>
@ -141,6 +161,17 @@ export function PopupNavBar({ path = "" }: { path?: string }): VNode {
<i18n.Translate>Backup</i18n.Translate>
</a>
<div style={{ display: "flex", paddingTop: 4, justifyContent: "right" }}>
{attentionCount > 0 ? (
<a href={Pages.notifications}>
<SvgIcon
title={i18n.str`Notifications`}
dangerouslySetInnerHTML={{ __html: warningIcon }}
color="yellow"
/>
</a>
) : (
<Fragment />
)}
<a href={Pages.qr}>
<SvgIcon
title={i18n.str`QR Reader and Taler URI`}
@ -178,9 +209,15 @@ export function WalletNavBar({ path = "" }: { path?: string }): VNode {
<i18n.Translate>Backup</i18n.Translate>
</a>
<a href={Pages.notifications}>
<i18n.Translate>Notifications</i18n.Translate>
</a>
<JustInDevMode>
<a href={Pages.dev} class={path.startsWith("/dev") ? "active" : ""}>
<i18n.Translate>Dev</i18n.Translate>
</a>
</JustInDevMode>
<div
style={{ display: "flex", paddingTop: 4, justifyContent: "right" }}

View File

@ -50,7 +50,6 @@ function RenderAmount(): VNode {
<AmountField
required
label={<i18n.Translate>Amount</i18n.Translate>}
currency="USD"
highestDenom={2000000}
lowestDenom={0.01}
handler={handler}

View File

@ -27,6 +27,7 @@ import { h, VNode } from "preact";
import { useTranslationContext } from "../context/translation.js";
import { Avatar } from "../mui/Avatar.js";
import { Pages } from "../NavigationBar.js";
import { assertUnreachable } from "../utils/index.js";
import {
Column,
ExtraLargeText,
@ -175,8 +176,7 @@ export function TransactionItem(props: { tx: Transaction }): VNode {
/>
);
default: {
const pe: never = tx;
throw Error(`unsupported transaction type ${pe}`);
assertUnreachable(tx);
}
}
}

View File

@ -89,6 +89,7 @@ export function useComponentState(
const insufficientBalance: PreparePayResult = {
status: PreparePayResultType.InsufficientBalance,
talerUri: "taler://pay",
proposalId: "fakeID",
contractTerms: {} as any,
amountRaw: hook.response.p2p.amount,

View File

@ -18,6 +18,7 @@ import {
AmountJson,
PreparePayResult,
PreparePayResultAlreadyConfirmed,
PreparePayResultInsufficientBalance,
PreparePayResultPaymentPossible,
} from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
@ -26,7 +27,7 @@ import { ButtonHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js";
import { wxApi } from "../../wxApi.js";
import { useComponentState } from "./state.js";
import { BaseView, LoadingUriView } from "./views.js";
import { BaseView, LoadingUriView, LostView } from "./views.js";
export interface Props {
talerPayUri?: string;
@ -40,6 +41,7 @@ export type State =
| State.LoadingUriError
| State.Ready
| State.NoEnoughBalance
| State.Lost
| State.NoBalanceForCurrency
| State.Confirmed;
@ -62,12 +64,15 @@ export namespace State {
}
export interface NoBalanceForCurrency extends BaseInfo {
status: "no-balance-for-currency";
payStatus: PreparePayResult;
payStatus:
| PreparePayResultInsufficientBalance
| PreparePayResultPaymentPossible
| PreparePayResultAlreadyConfirmed;
balance: undefined;
}
export interface NoEnoughBalance extends BaseInfo {
status: "no-enough-balance";
payStatus: PreparePayResult;
payStatus: PreparePayResultInsufficientBalance;
balance: AmountJson;
}
export interface Ready extends BaseInfo {
@ -77,6 +82,11 @@ export namespace State {
balance: AmountJson;
}
export interface Lost {
status: "lost";
error: undefined;
}
export interface Confirmed extends BaseInfo {
status: "confirmed";
payStatus: PreparePayResultAlreadyConfirmed;
@ -89,6 +99,7 @@ const viewMapping: StateViewMap<State> = {
"loading-uri": LoadingUriView,
"no-balance-for-currency": BaseView,
"no-enough-balance": BaseView,
lost: LostView,
confirmed: BaseView,
ready: BaseView,
};

View File

@ -82,6 +82,14 @@ export function useComponentState(
};
}
const { payStatus } = hook.response;
if (payStatus.status === PreparePayResultType.Lost) {
return {
status: "lost",
error: undefined,
};
}
const amount = Amounts.parseOrThrow(payStatus.amountRaw);
const foundBalance = hook.response.balance.balances.find(

View File

@ -44,6 +44,7 @@ export const NoBalance = createExample(BaseView, {
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
talerUri: "taler://pay/..",
noncePriv: "",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
contractTerms: {
@ -73,6 +74,7 @@ export const NoEnoughBalance = createExample(BaseView, {
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
talerUri: "taler://pay/..",
noncePriv: "",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
contractTerms: {
@ -102,6 +104,7 @@ export const EnoughBalanceButRestricted = createExample(BaseView, {
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
talerUri: "taler://pay/..",
noncePriv: "",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
contractTerms: {
@ -136,6 +139,7 @@ export const PaymentPossible = createExample(BaseView, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
talerUri: "taler://pay/..",
amountEffective: "USD:10",
amountRaw: "USD:10",
noncePriv: "",
@ -176,6 +180,7 @@ export const PaymentPossibleWithFee = createExample(BaseView, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
talerUri: "taler://pay/..",
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
@ -213,6 +218,7 @@ export const TicketWithAProductList = createExample(BaseView, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
talerUri: "taler://pay/..",
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
@ -269,6 +275,7 @@ export const TicketWithShipping = createExample(BaseView, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
talerUri: "taler://pay/..",
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
@ -315,6 +322,7 @@ export const AlreadyConfirmedByOther = createExample(BaseView, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
talerUri: "taler://pay/..",
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {

View File

@ -26,6 +26,7 @@ import {
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Amount } from "../../components/Amount.js";
import { ErrorMessage } from "../../components/ErrorMessage.js";
import { LoadingError } from "../../components/LoadingError.js";
import { LogoHeader } from "../../components/LogoHeader.js";
import { Part } from "../../components/Part.js";
@ -43,6 +44,7 @@ import { Time } from "../../components/Time.js";
import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js";
import { ButtonHandler } from "../../mui/handlers.js";
import { assertUnreachable } from "../../utils/index.js";
import { MerchantDetails, PurchaseDetails } from "../../wallet/Transaction.js";
import { State } from "./index.js";
@ -63,8 +65,24 @@ type SupportedStates =
| State.NoBalanceForCurrency
| State.NoEnoughBalance;
export function LostView(state: State.Lost): VNode {
const { i18n } = useTranslationContext();
return (
<ErrorMessage
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
description={
<i18n.Translate>
The proposal was lost, another should be downloaded
</i18n.Translate>
}
/>
);
}
export function BaseView(state: SupportedStates): VNode {
const { i18n } = useTranslationContext();
const contractTerms: ContractTerms = state.payStatus.contractTerms;
const price = {
@ -399,8 +417,9 @@ export function ButtonsSection({
</Fragment>
);
}
const error: never = payStatus;
if (payStatus.status === PreparePayResultType.Lost) {
return <Fragment />;
}
assertUnreachable(payStatus);
}

View File

@ -150,6 +150,10 @@ export function Application(): VNode {
component={RedirectToWalletPage}
/>
<Route path={Pages.dev} component={RedirectToWalletPage} />
<Route
path={Pages.notifications}
component={RedirectToWalletPage}
/>
<Route default component={Redirect} to={Pages.balance} />
</Router>

View File

@ -171,7 +171,11 @@ export function useComponentState(
switch (resp.status) {
case "payment-required":
if (resp.talerUri) {
return onPaymentRequired(resp.talerUri);
} else {
return onComplete(url);
}
case "error":
return setOperationError(resp.error);
case "ok":

View File

@ -66,6 +66,7 @@ import { TransferPickupPage } from "../cta/TransferPickup/index.js";
import { InvoicePayPage } from "../cta/InvoicePay/index.js";
import { RecoveryPage } from "../cta/Recovery/index.js";
import { AddBackupProviderPage } from "./AddBackupProvider/index.js";
import { NotificationsPage } from "./Notifications/index.js";
export function Application(): VNode {
const [globalNotification, setGlobalNotification] = useState<
@ -206,6 +207,7 @@ export function Application(): VNode {
/>
<Route path={Pages.settings} component={SettingsPage} />
<Route path={Pages.notifications} component={NotificationsPage} />
{/**
* BACKUP
@ -218,6 +220,12 @@ export function Application(): VNode {
<Route
path={Pages.backupProviderDetail.pattern}
component={ProviderDetailPage}
onPayProvider={(uri: string) =>
redirectTo(`${Pages.ctaPay}?talerPayUri=${uri}`)
}
onWithdraw={(amount: string) =>
redirectTo(Pages.receiveCash({ amount }))
}
onBack={() => redirectTo(Pages.backup)}
/>
<Route
@ -254,7 +262,7 @@ export function Application(): VNode {
path={Pages.ctaPay}
component={PaymentPage}
goToWalletManualWithdraw={(amount?: string) =>
redirectTo(Pages.ctaWithdrawManual({ amount }))
redirectTo(Pages.receiveCash({ amount }))
}
cancel={() => redirectTo(Pages.balance)}
onSuccess={(tid: string) =>
@ -321,7 +329,7 @@ export function Application(): VNode {
path={Pages.ctaInvoicePay}
component={InvoicePayPage}
goToWalletManualWithdraw={(amount?: string) =>
redirectTo(Pages.ctaWithdrawManual({ amount }))
redirectTo(Pages.receiveCash({ amount }))
}
onClose={() => redirectTo(Pages.balance)}
onSuccess={(tid: string) =>

View File

@ -89,6 +89,7 @@ export const LotOfProviders = createExample(TestedComponent, {
paymentProposalIds: [],
paymentStatus: {
type: ProviderPaymentType.Pending,
talerUri: "taler://",
},
terms: {
annualFee: "KUDOS:0.1",
@ -103,6 +104,7 @@ export const LotOfProviders = createExample(TestedComponent, {
paymentProposalIds: [],
paymentStatus: {
type: ProviderPaymentType.InsufficientBalance,
amount: "KUDOS:10",
},
terms: {
annualFee: "KUDOS:0.1",

View File

@ -0,0 +1,61 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { UserAttentionUnreadList } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import { compose, StateViewMap } from "../../utils/index.js";
import { wxApi } from "../../wxApi.js";
import { useComponentState } from "./state.js";
import { LoadingUriView, ReadyView } from "./views.js";
export interface Props {}
export type State = State.Loading | State.LoadingUriError | State.Ready;
export namespace State {
export interface Loading {
status: "loading";
error: undefined;
}
export interface LoadingUriError {
status: "loading-error";
error: HookError;
}
export interface BaseInfo {
error: undefined;
}
export interface Ready extends BaseInfo {
status: "ready";
error: undefined;
list: UserAttentionUnreadList;
}
}
const viewMapping: StateViewMap<State> = {
loading: Loading,
"loading-error": LoadingUriView,
ready: ReadyView,
};
export const NotificationsPage = compose(
"NotificationsPage",
(p: Props) => useComponentState(p, wxApi),
viewMapping,
);

View File

@ -0,0 +1,48 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import { wxApi } from "../../wxApi.js";
import { Props, State } from "./index.js";
export function useComponentState({}: Props, api: typeof wxApi): State {
const hook = useAsyncAsHook(async () => {
return await api.wallet.call(
WalletApiOperation.GetUserAttentionRequests,
{},
);
});
if (!hook) {
return {
status: "loading",
error: undefined,
};
}
if (hook.hasError) {
return {
status: "loading-error",
error: hook,
};
}
return {
status: "ready",
error: undefined,
list: hook.response.pending,
};
}

View File

@ -0,0 +1,58 @@
/*
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/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { AbsoluteTime, AttentionType } from "@gnu-taler/taler-util";
import { createExample } from "../../test-utils.js";
import { ReadyView } from "./views.js";
export default {
title: "wallet/notifications",
};
export const Ready = createExample(ReadyView, {
list: [
{
when: AbsoluteTime.now(),
read: false,
info: {
type: AttentionType.KycWithdrawal,
transactionId: "123",
},
},
{
when: AbsoluteTime.now(),
read: false,
info: {
type: AttentionType.MerchantRefund,
transactionId: "123",
},
},
{
when: AbsoluteTime.now(),
read: false,
info: {
type: AttentionType.BackupUnpaid,
provider_base_url: "http://sync.taler.net",
talerUri: "taler://payment/asdasdasd",
},
},
],
});

View File

@ -0,0 +1,28 @@
/*
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/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { expect } from "chai";
describe("test description", () => {
it("should assert", () => {
expect([]).deep.equals([]);
});
});

View File

@ -0,0 +1,220 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
AbsoluteTime,
AttentionInfo,
AttentionType,
} from "@gnu-taler/taler-util";
import { Fragment, h, VNode } from "preact";
import { LoadingError } from "../../components/LoadingError.js";
import {
Column,
DateSeparator,
HistoryRow,
LargeText,
SmallLightText,
} from "../../components/styled/index.js";
import { Time } from "../../components/Time.js";
import { useTranslationContext } from "../../context/translation.js";
import { Avatar } from "../../mui/Avatar.js";
import { Button } from "../../mui/Button.js";
import { Grid } from "../../mui/Grid.js";
import { Pages } from "../../NavigationBar.js";
import { assertUnreachable } from "../../utils/index.js";
import { State } from "./index.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
return (
<LoadingError
title={<i18n.Translate>Could not load notifications</i18n.Translate>}
error={error}
/>
);
}
const term = 1000 * 60 * 60 * 24;
function normalizeToDay(x: number): number {
return Math.round(x / term) * term;
}
export function ReadyView({ list }: State.Ready): VNode {
const { i18n } = useTranslationContext();
if (list.length < 1) {
return (
<section>
<i18n.Translate>No notification left</i18n.Translate>
</section>
);
}
const byDate = list.reduce((rv, x) => {
const theDate = x.when.t_ms === "never" ? 0 : normalizeToDay(x.when.t_ms);
if (theDate) {
(rv[theDate] = rv[theDate] || []).push(x);
}
return rv;
}, {} as { [x: string]: typeof list });
const datesWithNotifications = Object.keys(byDate);
return (
<section>
{datesWithNotifications.map((d, i) => {
return (
<Fragment key={i}>
<DateSeparator>
<Time
timestamp={{ t_ms: Number.parseInt(d, 10) }}
format="dd MMMM yyyy"
/>
</DateSeparator>
{byDate[d].map((n, i) => (
<NotificationItem
key={i}
info={n.info}
isRead={n.read}
timestamp={n.when}
/>
))}
</Fragment>
);
})}
</section>
);
}
function NotificationItem({
info,
isRead,
timestamp,
}: {
info: AttentionInfo;
timestamp: AbsoluteTime;
isRead: boolean;
}): VNode {
switch (info.type) {
case AttentionType.KycWithdrawal:
return (
<NotificationLayout
timestamp={timestamp}
href={Pages.balanceTransaction({ tid: info.transactionId })}
title="Withdrawal on hold"
subtitle="Know-your-customer validation is required"
iconPath={"K"}
isRead={isRead}
/>
);
case AttentionType.MerchantRefund:
return (
<NotificationLayout
timestamp={timestamp}
href={Pages.balanceTransaction({ tid: info.transactionId })}
title="Merchant has refund your payment"
subtitle="Accept or deny refund"
iconPath={"K"}
isRead={isRead}
/>
);
case AttentionType.BackupUnpaid:
return (
<NotificationLayout
timestamp={timestamp}
href={`${Pages.ctaPay}?talerPayUri=${info.talerUri}`}
title="Backup provider is unpaid"
subtitle="Complete the payment or remove the service provider"
iconPath={"K"}
isRead={isRead}
/>
);
case AttentionType.AuditorDenominationsExpires:
return <div>not implemented</div>;
case AttentionType.AuditorKeyExpires:
return <div>not implemented</div>;
case AttentionType.AuditorTosChanged:
return <div>not implemented</div>;
case AttentionType.ExchangeDenominationsExpired:
return <div>not implemented</div>;
// case AttentionType.ExchangeDenominationsExpiresSoon:
// return <div>not implemented</div>;
case AttentionType.ExchangeKeyExpired:
return <div>not implemented</div>;
// case AttentionType.ExchangeKeyExpiresSoon:
// return <div>not implemented</div>;
case AttentionType.ExchangeTosChanged:
return <div>not implemented</div>;
case AttentionType.BackupExpiresSoon:
return <div>not implemented</div>;
case AttentionType.PushPaymentReceived:
return <div>not implemented</div>;
case AttentionType.PullPaymentPaid:
return <div>not implemented</div>;
default:
assertUnreachable(info);
}
}
function NotificationLayout(props: {
title: string;
href: string;
subtitle?: string;
timestamp: AbsoluteTime;
iconPath: string;
isRead: boolean;
}): VNode {
const { i18n } = useTranslationContext();
return (
<HistoryRow
href={props.href}
style={{
backgroundColor: props.isRead ? "lightcyan" : "inherit",
alignItems: "center",
}}
>
<Avatar
style={{
border: "solid gray 1px",
color: "gray",
boxSizing: "border-box",
}}
>
{props.iconPath}
</Avatar>
<Column>
<LargeText>
<div>{props.title}</div>
{props.subtitle && (
<div style={{ color: "gray", fontSize: "medium", marginTop: 5 }}>
{props.subtitle}
</div>
)}
</LargeText>
<SmallLightText style={{ marginTop: 5 }}>
<Time timestamp={props.timestamp} format="HH:mm" />
</SmallLightText>
</Column>
<Column>
<Grid>
<Button variant="outlined">
<i18n.Translate>Ignore</i18n.Translate>
</Button>
</Grid>
</Column>
</HistoryRow>
);
}

View File

@ -174,6 +174,7 @@ export const InactiveInsufficientBalance = createExample(TestedComponent, {
paymentProposalIds: [],
paymentStatus: {
type: ProviderPaymentType.InsufficientBalance,
amount: "EUR:123",
},
terms: {
annualFee: "EUR:0.1",
@ -191,6 +192,7 @@ export const InactivePending = createExample(TestedComponent, {
paymentProposalIds: [],
paymentStatus: {
type: ProviderPaymentType.Pending,
talerUri: "taler://pay/sad",
},
terms: {
annualFee: "EUR:0.1",

View File

@ -36,9 +36,16 @@ import { wxApi } from "../wxApi.js";
interface Props {
pid: string;
onBack: () => Promise<void>;
onPayProvider: (uri: string) => Promise<void>;
onWithdraw: (amount: string) => Promise<void>;
}
export function ProviderDetailPage({ pid: providerURL, onBack }: Props): VNode {
export function ProviderDetailPage({
pid: providerURL,
onBack,
onPayProvider,
onWithdraw,
}: Props): VNode {
const { i18n } = useTranslationContext();
async function getProviderInfo(): Promise<ProviderInfo | null> {
//create a first list of backup info by currency
@ -71,11 +78,30 @@ export function ProviderDetailPage({ pid: providerURL, onBack }: Props): VNode {
/>
);
}
const info = state.response;
if (info === null) {
return (
<Fragment>
<section>
<p>
<i18n.Translate>
There is not known provider with url &quot;{providerURL}&quot;.
</i18n.Translate>
</p>
</section>
<footer>
<Button variant="contained" color="secondary" onClick={onBack}>
<i18n.Translate>See providers</i18n.Translate>
</Button>
<div />
</footer>
</Fragment>
);
}
return (
<ProviderView
url={providerURL}
info={state.response}
info={info}
onSync={async () =>
wxApi.wallet
.call(WalletApiOperation.RunBackupCycle, {
@ -83,6 +109,16 @@ export function ProviderDetailPage({ pid: providerURL, onBack }: Props): VNode {
})
.then()
}
onPayProvider={async () => {
if (info.paymentStatus.type !== ProviderPaymentType.Pending) return;
if (!info.paymentStatus.talerUri) return;
onPayProvider(info.paymentStatus.talerUri);
}}
onWithdraw={async () => {
if (info.paymentStatus.type !== ProviderPaymentType.InsufficientBalance)
return;
onWithdraw(info.paymentStatus.amount);
}}
onDelete={() =>
wxApi.wallet
.call(WalletApiOperation.RemoveBackupProvider, {
@ -99,42 +135,25 @@ export function ProviderDetailPage({ pid: providerURL, onBack }: Props): VNode {
}
export interface ViewProps {
url: string;
info: ProviderInfo | null;
info: ProviderInfo;
onDelete: () => Promise<void>;
onSync: () => Promise<void>;
onBack: () => Promise<void>;
onExtend: () => Promise<void>;
onPayProvider: () => Promise<void>;
onWithdraw: () => Promise<void>;
}
export function ProviderView({
info,
url,
onDelete,
onPayProvider,
onWithdraw,
onSync,
onBack,
onExtend,
}: ViewProps): VNode {
const { i18n } = useTranslationContext();
if (info === null) {
return (
<Fragment>
<section>
<p>
<i18n.Translate>
There is not known provider with url &quot;{url}&quot;.
</i18n.Translate>
</p>
</section>
<footer>
<Button variant="contained" color="secondary" onClick={onBack}>
<i18n.Translate>See providers</i18n.Translate>
</Button>
<div />
</footer>
</Fragment>
);
}
const lb = info.lastSuccessfulBackupTimestamp
? AbsoluteTime.fromTimestamp(info.lastSuccessfulBackupTimestamp)
: undefined;
@ -230,6 +249,18 @@ export function ProviderView({
<Button variant="contained" color="error" onClick={onDelete}>
<i18n.Translate>Remove provider</i18n.Translate>
</Button>
{info.paymentStatus.type === ProviderPaymentType.Pending &&
info.paymentStatus.talerUri ? (
<Button variant="contained" color="primary" onClick={onPayProvider}>
<i18n.Translate>Pay</i18n.Translate>
</Button>
) : undefined}
{info.paymentStatus.type ===
ProviderPaymentType.InsufficientBalance ? (
<Button variant="contained" color="primary" onClick={onWithdraw}>
<i18n.Translate>Withdraw</i18n.Translate>
</Button>
) : undefined}
</div>
</footer>
</Fragment>

View File

@ -36,6 +36,7 @@ import * as a17 from "./QrReader.stories.js";
import * as a18 from "./DestinationSelection.stories.js";
import * as a19 from "./ExchangeSelection/stories.js";
import * as a20 from "./ManageAccount/stories.js";
import * as a21 from "./Notifications/stories.js";
export default [
a1,
@ -55,4 +56,5 @@ export default [
a18,
a19,
a20,
a21,
];