aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/crypto/cryptoWorker.ts2
-rw-r--r--src/logging.ts45
-rw-r--r--src/query.ts27
-rw-r--r--src/types.ts41
-rw-r--r--src/wallet.ts198
-rw-r--r--src/webex/messages.ts16
-rw-r--r--src/webex/notify.ts187
-rw-r--r--src/webex/pages/confirm-create-reserve.tsx47
-rw-r--r--src/webex/pages/error.tsx97
-rw-r--r--src/webex/pages/refund.html18
-rw-r--r--src/webex/pages/refund.tsx138
-rw-r--r--src/webex/renderHtml.tsx39
-rw-r--r--src/webex/wxApi.ts24
-rw-r--r--src/webex/wxBackend.ts27
14 files changed, 672 insertions, 234 deletions
diff --git a/src/crypto/cryptoWorker.ts b/src/crypto/cryptoWorker.ts
index b05d7d184..1db6e62d5 100644
--- a/src/crypto/cryptoWorker.ts
+++ b/src/crypto/cryptoWorker.ts
@@ -271,7 +271,7 @@ namespace RpcFunctions {
const newAmount = new native.Amount(cd.coin.currentAmount);
newAmount.sub(coinSpend);
cd.coin.currentAmount = newAmount.toJson();
- cd.coin.status = CoinStatus.TransactionPending;
+ cd.coin.status = CoinStatus.PurchasePending;
const d = new native.DepositRequestPS({
amount_with_fee: coinSpend.toNbo(),
diff --git a/src/logging.ts b/src/logging.ts
index a589c8091..2c559e8d9 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -208,6 +208,44 @@ export async function recordException(msg: string, e: any): Promise<void> {
return record("error", e.toString(), stack, frame.file, frame.line, frame.column);
}
+
+/**
+ * Cache for reports. Also used when something is so broken that we can't even
+ * access the database.
+ */
+const reportCache: { [reportId: string]: any } = {};
+
+
+/**
+ * Get a UUID that does not use cryptographically secure randomness.
+ * Formatted as RFC4122 version 4 UUID.
+ */
+function getInsecureUuid() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
+
+
+/**
+ * Store a report and return a unique identifier to retrieve it later.
+ */
+export async function storeReport(report: any): Promise<string> {
+ const uid = getInsecureUuid();
+ reportCache[uid] = report;
+ return uid;
+}
+
+
+/**
+ * Retrieve a report by its unique identifier.
+ */
+export async function getReport(reportUid: string): Promise<any> {
+ return reportCache[reportUid];
+}
+
+
/**
* Record a log entry in the database.
*/
@@ -218,6 +256,8 @@ export async function record(level: Level,
line?: number,
col?: number): Promise<void> {
if (typeof indexedDB === "undefined") {
+ console.log("can't access DB for logging in this context");
+ console.log("log was", { level, msg, detail, source, line, col });
return;
}
@@ -257,7 +297,7 @@ export async function record(level: Level,
}
}
-const loggingDbVersion = 1;
+const loggingDbVersion = 2;
const logsStore: Store<LogEntry> = new Store<LogEntry>("logs");
@@ -283,7 +323,8 @@ export function openLoggingDb(): Promise<IDBDatabase> {
console.error(e);
}
}
- resDb.createObjectStore("logs", {keyPath: "id", autoIncrement: true});
+ resDb.createObjectStore("logs", { keyPath: "id", autoIncrement: true });
+ resDb.createObjectStore("reports", { keyPath: "uid", autoIncrement: false });
};
});
}
diff --git a/src/query.ts b/src/query.ts
index dffff86eb..d7689f2bc 100644
--- a/src/query.ts
+++ b/src/query.ts
@@ -547,9 +547,18 @@ export class QueryRoot {
private finished: boolean = false;
+ private keys: { [keyName: string]: IDBValidKey } = {};
+
constructor(public db: IDBDatabase) {
}
+ /**
+ * Get a named key that was created during the query.
+ */
+ key(keyName: string): IDBValidKey|undefined {
+ return this.keys[keyName];
+ }
+
private checkFinished() {
if (this.finished) {
throw Error("Can't add work to query after it was started");
@@ -627,10 +636,15 @@ export class QueryRoot {
* Overrides if an existing object with the same key exists
* in the store.
*/
- put<T>(store: Store<T>, val: T): QueryRoot {
+ put<T>(store: Store<T>, val: T, keyName?: string): QueryRoot {
this.checkFinished();
const doPut = (tx: IDBTransaction) => {
- tx.objectStore(store.name).put(val);
+ const req = tx.objectStore(store.name).put(val);
+ if (keyName) {
+ req.onsuccess = () => {
+ this.keys[keyName] = req.result;
+ };
+ }
};
this.scheduleFinish();
this.addWork(doPut, store.name, true);
@@ -658,13 +672,13 @@ export class QueryRoot {
/**
* Get, modify and store an element inside a transaction.
*/
- mutate<T>(store: Store<T>, key: any, f: (v: T) => T): QueryRoot {
+ mutate<T>(store: Store<T>, key: any, f: (v: T|undefined) => T|undefined): QueryRoot {
this.checkFinished();
const doPut = (tx: IDBTransaction) => {
const reqGet = tx.objectStore(store.name).get(key);
reqGet.onsuccess = () => {
const r = reqGet.result;
- let m: T;
+ let m: T|undefined;
try {
m = f(r);
} catch (e) {
@@ -674,8 +688,9 @@ export class QueryRoot {
}
throw e;
}
-
- tx.objectStore(store.name).put(m);
+ if (m !== undefined && m !== null) {
+ tx.objectStore(store.name).put(m);
+ }
};
};
this.scheduleFinish();
diff --git a/src/types.ts b/src/types.ts
index 9031b19b7..d016b7fea 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -759,6 +759,11 @@ export interface RefreshSessionRecord {
* Is this session finished?
*/
finished: boolean;
+
+ /**
+ * Record ID when retrieved from the DB.
+ */
+ id?: number;
}
@@ -798,9 +803,9 @@ export enum CoinStatus {
*/
Fresh,
/**
- * Currently planned to be sent to a merchant for a transaction.
+ * Currently planned to be sent to a merchant for a purchase.
*/
- TransactionPending,
+ PurchasePending,
/**
* Used for a completed transaction and now dirty.
*/
@@ -1307,7 +1312,7 @@ export namespace Amounts {
}
value = value + x.value + Math.floor((fraction + x.fraction) / fractionalBase);
- fraction = (fraction + x.fraction) % fractionalBase;
+ fraction = Math.floor((fraction + x.fraction) % fractionalBase);
if (value > Number.MAX_SAFE_INTEGER) {
return { amount: getMaxAmount(currency), saturated: true };
}
@@ -1435,7 +1440,7 @@ export namespace Amounts {
export function fromFloat(floatVal: number, currency: string) {
return {
currency,
- fraction: (floatVal - Math.floor(floatVal)) * fractionalBase,
+ fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase),
value: Math.floor(floatVal),
};
}
@@ -1662,3 +1667,31 @@ export class ReturnCoinsRequest {
*/
static checked: (obj: any) => ReturnCoinsRequest;
}
+
+
+export interface RefundPermission {
+ refund_amount: AmountJson;
+ refund_fee: AmountJson;
+ h_contract_terms: string;
+ coin_pub: string;
+ rtransaction_id: number;
+ merchant_pub: string;
+ merchant_sig: string;
+}
+
+
+export interface PurchaseRecord {
+ contractTermsHash: string;
+ contractTerms: ContractTerms;
+ payReq: PayReq;
+ merchantSig: string;
+
+ /**
+ * The purchase isn't active anymore, it's either successfully paid or
+ * refunded/aborted.
+ */
+ finished: boolean;
+
+ refundsPending: { [refundSig: string]: RefundPermission };
+ refundsDone: { [refundSig: string]: RefundPermission };
+}
diff --git a/src/wallet.ts b/src/wallet.ts
index 68d70b0bb..3d095fc06 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -82,6 +82,8 @@ import {
WalletBalanceEntry,
WireFee,
WireInfo,
+ RefundPermission,
+ PurchaseRecord,
} from "./types";
import URI = require("urijs");
@@ -241,19 +243,6 @@ class WireDetailJson {
}
-interface TransactionRecord {
- contractTermsHash: string;
- contractTerms: ContractTerms;
- payReq: PayReq;
- merchantSig: string;
-
- /**
- * The transaction isn't active anymore, it's either successfully paid
- * or refunded/aborted.
- */
- finished: boolean;
-}
-
/**
* Badge that shows activity for the wallet.
@@ -424,6 +413,8 @@ export function selectPayCoins(cds: CoinWithDenom[], paymentAmount: AmountJson,
denom.feeDeposit).amount) >= 0;
isBelowFee = Amounts.cmp(accFee, depositFeeLimit) <= 0;
+ console.log("coin selection", { coversAmount, isBelowFee, accFee, accAmount, paymentAmount });
+
if ((coversAmount && isBelowFee) || coversAmountWithFee) {
return cdsResult;
}
@@ -516,13 +507,13 @@ export namespace Stores {
}
}
- class TransactionsStore extends Store<TransactionRecord> {
+ class PurchasesStore extends Store<PurchaseRecord> {
constructor() {
- super("transactions", {keyPath: "contractTermsHash"});
+ super("purchases", {keyPath: "contractTermsHash"});
}
- fulfillmentUrlIndex = new Index<string, TransactionRecord>(this, "fulfillment_url", "contractTerms.fulfillment_url");
- orderIdIndex = new Index<string, TransactionRecord>(this, "order_id", "contractTerms.order_id");
+ fulfillmentUrlIndex = new Index<string, PurchaseRecord>(this, "fulfillment_url", "contractTerms.fulfillment_url");
+ orderIdIndex = new Index<string, PurchaseRecord>(this, "order_id", "contractTerms.order_id");
}
class DenominationsStore extends Store<DenominationRecord> {
@@ -566,9 +557,9 @@ export namespace Stores {
export const nonces = new NonceStore();
export const precoins = new Store<PreCoinRecord>("precoins", {keyPath: "coinPub"});
export const proposals = new ProposalsStore();
- export const refresh = new Store<RefreshSessionRecord>("refresh", {keyPath: "meltCoinPub"});
+ export const refresh = new Store<RefreshSessionRecord>("refresh", {keyPath: "id", autoIncrement: true});
export const reserves = new Store<ReserveRecord>("reserves", {keyPath: "reserve_pub"});
- export const transactions = new TransactionsStore();
+ export const purchases = new PurchasesStore();
}
/* tslint:enable:completed-docs */
@@ -770,6 +761,8 @@ export class Wallet {
cds.push({coin, denom});
}
+ console.log("coin return: selecting from possible coins", { cds, amount } );
+
return selectPayCoins(cds, amount, amount);
}
@@ -909,12 +902,14 @@ export class Wallet {
merchant_pub: proposal.contractTerms.merchant_pub,
order_id: proposal.contractTerms.order_id,
};
- const t: TransactionRecord = {
+ const t: PurchaseRecord = {
contractTerms: proposal.contractTerms,
contractTermsHash: proposal.contractTermsHash,
finished: false,
merchantSig: proposal.merchantSig,
payReq,
+ refundsDone: {},
+ refundsPending: {},
};
const historyEntry: HistoryRecord = {
@@ -931,7 +926,7 @@ export class Wallet {
};
await this.q()
- .put(Stores.transactions, t)
+ .put(Stores.purchases, t)
.put(Stores.history, historyEntry)
.putAll(Stores.coins, payCoinInfo.map((pci) => pci.updatedCoin))
.finish();
@@ -972,9 +967,9 @@ export class Wallet {
throw Error(`proposal with id ${proposalId} not found`);
}
- const transaction = await this.q().get(Stores.transactions, proposal.contractTermsHash);
+ const purchase = await this.q().get(Stores.purchases, proposal.contractTermsHash);
- if (transaction) {
+ if (purchase) {
// Already payed ...
return "paid";
}
@@ -1017,8 +1012,8 @@ export class Wallet {
}
// First check if we already payed for it.
- const transaction = await this.q().get(Stores.transactions, proposal.contractTermsHash);
- if (transaction) {
+ const purchase = await this.q().get(Stores.purchases, proposal.contractTermsHash);
+ if (purchase) {
return "paid";
}
@@ -1049,7 +1044,7 @@ export class Wallet {
async queryPayment(url: string): Promise<QueryPaymentResult> {
console.log("query for payment", url);
- const t = await this.q().getIndexed(Stores.transactions.fulfillmentUrlIndex, url);
+ const t = await this.q().getIndexed(Stores.purchases.fulfillmentUrlIndex, url);
if (!t) {
console.log("query for payment failed");
@@ -1845,7 +1840,7 @@ export class Wallet {
if (c.suspended) {
return balance;
}
- if (!(c.status === CoinStatus.Dirty || c.status === CoinStatus.Fresh)) {
+ if (!(c.status === CoinStatus.Fresh)) {
return balance;
}
console.log("collecting balance");
@@ -1890,7 +1885,7 @@ export class Wallet {
return balance;
}
- function collectPayments(t: TransactionRecord, balance: WalletBalance) {
+ function collectPayments(t: PurchaseRecord, balance: WalletBalance) {
if (t.finished) {
return balance;
}
@@ -1934,7 +1929,7 @@ export class Wallet {
.reduce(collectPendingWithdraw, balance);
tx.iter(Stores.reserves)
.reduce(collectPaybacks, balance);
- tx.iter(Stores.transactions)
+ tx.iter(Stores.purchases)
.reduce(collectPayments, balance);
await tx.finish();
return balance;
@@ -2008,25 +2003,30 @@ export class Wallet {
// Store refresh session and subtract refreshed amount from
// coin in the same transaction.
- await this.q()
- .put(Stores.refresh, refreshSession)
- .mutate(Stores.coins, coin.coinPub, mutateCoin)
- .finish();
+ const query = this.q();
+ query.put(Stores.refresh, refreshSession, "refreshKey")
+ .mutate(Stores.coins, coin.coinPub, mutateCoin);
+ await query.finish();
+
+ const key = query.key("refreshKey");
+ if (!key || typeof key !== "number") {
+ throw Error("insert failed");
+ }
+
+ refreshSession.id = key;
return refreshSession;
}
async refresh(oldCoinPub: string): Promise<void> {
- let refreshSession: RefreshSessionRecord|undefined;
- const oldSession = await this.q().get(Stores.refresh, oldCoinPub);
- if (oldSession) {
- console.log("got old session for", oldCoinPub);
- console.log(oldSession);
- refreshSession = oldSession;
- } else {
- refreshSession = await this.createRefreshSession(oldCoinPub);
+
+ const oldRefreshSessions = await this.q().iter(Stores.refresh).toArray();
+ for (const session of oldRefreshSessions) {
+ console.log("got old session for", oldCoinPub, session);
+ this.continueRefreshSession(session);
}
+ let refreshSession = await this.createRefreshSession(oldCoinPub);
if (!refreshSession) {
// refreshing not necessary
console.log("not refreshing", oldCoinPub);
@@ -2040,9 +2040,8 @@ export class Wallet {
return;
}
if (typeof refreshSession.norevealIndex !== "number") {
- const coinPub = refreshSession.meltCoinPub;
await this.refreshMelt(refreshSession);
- const r = await this.q().get<RefreshSessionRecord>(Stores.refresh, coinPub);
+ const r = await this.q().get<RefreshSessionRecord>(Stores.refresh, refreshSession.id);
if (!r) {
throw Error("refresh session does not exist anymore");
}
@@ -2282,7 +2281,7 @@ export class Wallet {
async paymentSucceeded(contractTermsHash: string, merchantSig: string): Promise<any> {
const doPaymentSucceeded = async() => {
- const t = await this.q().get<TransactionRecord>(Stores.transactions,
+ const t = await this.q().get<PurchaseRecord>(Stores.purchases,
contractTermsHash);
if (!t) {
console.error("contract not found");
@@ -2309,7 +2308,7 @@ export class Wallet {
await this.q()
.putAll(Stores.coins, modifiedCoins)
- .put(Stores.transactions, t)
+ .put(Stores.purchases, t)
.finish();
for (const c of t.payReq.coins) {
this.refresh(c.coin_pub);
@@ -2422,7 +2421,7 @@ export class Wallet {
const senderWiresSet = new Set();
await this.q().iter(Stores.reserves).map((x) => {
if (x.senderWire) {
- senderWiresSet.add(JSON.stringify(x.senderWire));
+ senderWiresSet.add(canonicalJson(x.senderWire));
}
}).run();
const senderWires = Array.from(senderWiresSet).map((x) => JSON.parse(x));
@@ -2450,6 +2449,7 @@ export class Wallet {
console.error(`Exchange ${req.exchange} not known to the wallet`);
return;
}
+ console.log("selecting coins for return:", req);
const cds = await this.getCoinsForReturn(req.exchange, req.amount);
console.log(cds);
@@ -2560,4 +2560,110 @@ export class Wallet {
await this.q().put(Stores.coinsReturns, currentCrr);
}
}
+
+ async acceptRefund(refundPermissions: RefundPermission[]): Promise<void> {
+ if (!refundPermissions.length) {
+ console.warn("got empty refund list");
+ return;
+ }
+ const hc = refundPermissions[0].h_contract_terms;
+ if (!hc) {
+ throw Error("h_contract_terms missing in refund permission");
+ }
+ const m = refundPermissions[0].merchant_pub;
+ if (!hc) {
+ throw Error("merchant_pub missing in refund permission");
+ }
+ for (const perm of refundPermissions) {
+ if (perm.h_contract_terms !== hc) {
+ throw Error("h_contract_terms different in refund permission");
+ }
+ if (perm.merchant_pub !== m) {
+ throw Error("merchant_pub different in refund permission");
+ }
+ }
+
+ /**
+ * Add refund to purchase if not already added.
+ */
+ function f(t: PurchaseRecord|undefined): PurchaseRecord|undefined {
+ if (!t) {
+ console.error("purchase not found, not adding refunds");
+ return;
+ }
+
+ for (const perm of refundPermissions) {
+ if (!t.refundsPending[perm.merchant_sig] && !t.refundsDone[perm.merchant_sig]) {
+ t.refundsPending[perm.merchant_sig] = perm;
+ }
+ }
+ return t;
+ }
+
+ // Add the refund permissions to the purchase within a DB transaction
+ await this.q().mutate(Stores.purchases, hc, f).finish();
+ this.notifier.notify();
+
+ // Start submitting it but don't wait for it here.
+ this.submitRefunds(hc);
+ }
+
+ async submitRefunds(contractTermsHash: string): Promise<void> {
+ const purchase = await this.q().get(Stores.purchases, contractTermsHash);
+ if (!purchase) {
+ console.error("not submitting refunds, contract terms not found:", contractTermsHash);
+ return;
+ }
+ const pendingKeys = Object.keys(purchase.refundsPending);
+ if (pendingKeys.length === 0) {
+ return;
+ }
+ for (const pk of pendingKeys) {
+ const perm = purchase.refundsPending[pk];
+ console.log("sending refund permission", perm);
+ const reqUrl = (new URI("refund")).absoluteTo(purchase.payReq.exchange);
+ const resp = await this.http.postJson(reqUrl.href(), perm);
+ if (resp.status !== 200) {
+ console.error("refund failed", resp);
+ continue;
+ }
+
+ // Transactionally mark successful refunds as done
+ const transformPurchase = (t: PurchaseRecord|undefined): PurchaseRecord|undefined => {
+ if (!t) {
+ console.warn("purchase not found, not updating refund");
+ return;
+ }
+ if (t.refundsPending[pk]) {
+ t.refundsDone[pk] = t.refundsPending[pk];
+ delete t.refundsPending[pk];
+ }
+ return t;
+ };
+ const transformCoin = (c: CoinRecord|undefined): CoinRecord|undefined => {
+ if (!c) {
+ console.warn("coin not found, can't apply refund");
+ return;
+ }
+ c.status = CoinStatus.Dirty;
+ c.currentAmount = Amounts.add(c.currentAmount, perm.refund_amount).amount;
+ c.currentAmount = Amounts.sub(c.currentAmount, perm.refund_fee).amount;
+
+ return c;
+ };
+
+
+ await this.q()
+ .mutate(Stores.purchases, contractTermsHash, transformPurchase)
+ .mutate(Stores.coins, perm.coin_pub, transformCoin)
+ .finish();
+ this.refresh(perm.coin_pub);
+ }
+
+ this.notifier.notify();
+ }
+
+ async getPurchase(contractTermsHash: string): Promise<PurchaseRecord|undefined> {
+ return this.q().get(Stores.purchases, contractTermsHash);
+ }
}
diff --git a/src/webex/messages.ts b/src/webex/messages.ts
index d7ecd06a1..7de28b9e9 100644
--- a/src/webex/messages.ts
+++ b/src/webex/messages.ts
@@ -176,6 +176,22 @@ export interface MessageMap {
request: { };
response: void;
};
+ "log-and-display-error": {
+ request: any;
+ response: void;
+ };
+ "get-report": {
+ request: { reportUid: string };
+ response: void;
+ };
+ "accept-refund": {
+ request: any;
+ response: void;
+ };
+ "get-purchase": {
+ request: any;
+ response: void;
+ }
}
/**
diff --git a/src/webex/notify.ts b/src/webex/notify.ts
index 51abdb0e0..5e024d619 100644
--- a/src/webex/notify.ts
+++ b/src/webex/notify.ts
@@ -30,6 +30,8 @@ import wxApi = require("./wxApi");
import { QueryPaymentResult } from "../types";
+import axios from 'axios';
+
declare var cloneInto: any;
let logVerbose: boolean = false;
@@ -98,85 +100,39 @@ function setStyles(installed: boolean) {
}
-function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) {
+async function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) {
if (!maybeFoundResponse.found) {
console.log("pay-failed", {hint: "payment not found in the wallet"});
return;
}
const walletResp = maybeFoundResponse;
- /**
- * Handle a failed payment.
- *
- * Try to notify the wallet first, before we show a potentially
- * synchronous error message (such as an alert) or leave the page.
- */
- async function handleFailedPayment(r: XMLHttpRequest) {
- let timeoutHandle: number|null = null;
- function err() {
- // FIXME: proper error reporting!
- console.log("pay-failed", {status: r.status, response: r.responseText});
- }
- function onTimeout() {
- timeoutHandle = null;
- err();
- }
- timeoutHandle = window.setTimeout(onTimeout, 200);
-
- await wxApi.paymentFailed(walletResp.contractTermsHash);
- if (timeoutHandle !== null) {
- clearTimeout(timeoutHandle);
- timeoutHandle = null;
- }
- err();
- }
logVerbose && console.log("handling taler-notify-payment: ", walletResp);
- // Payment timeout in ms.
- let timeout_ms = 1000;
- // Current request.
- let r: XMLHttpRequest|null;
- let timeoutHandle: number|null = null;
- function sendPay() {
- r = new XMLHttpRequest();
- r.open("post", walletResp.contractTerms.pay_url);
- r.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
- r.send(JSON.stringify(walletResp.payReq));
- r.onload = async () => {
- if (!r) {
- return;
- }
- switch (r.status) {
- case 200:
- const merchantResp = JSON.parse(r.responseText);
- logVerbose && console.log("got success from pay_url");
- await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig);
- const nextUrl = walletResp.contractTerms.fulfillment_url;
- logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl);
- window.location.href = nextUrl;
- window.location.reload(true);
- break;
- default:
- handleFailedPayment(r);
- break;
- }
- r = null;
- if (timeoutHandle !== null) {
- clearTimeout(timeoutHandle!);
- timeoutHandle = null;
- }
- };
- function retry() {
- if (r) {
- r.abort();
- r = null;
- }
- timeout_ms = Math.min(timeout_ms * 2, 10 * 1000);
- logVerbose && console.log("sendPay timed out, retrying in ", timeout_ms, "ms");
- sendPay();
+ let resp;
+ try {
+ const config = {
+ timeout: 5000, /* 5 seconds */
+ headers: { "Content-Type": "application/json;charset=UTF-8" },
+ validateStatus: (s: number) => s == 200,
}
- timeoutHandle = window.setTimeout(retry, timeout_ms);
+ resp = await axios.post(walletResp.contractTerms.pay_url, walletResp.payReq, config);
+ } catch (e) {
+ // Gives the user the option to retry / abort and refresh
+ wxApi.logAndDisplayError({
+ name: "pay-post-failed",
+ contractTerms: walletResp.contractTerms,
+ message: e.message,
+ response: e.response,
+ });
+ throw e;
}
- sendPay();
+ const merchantResp = resp.data;
+ logVerbose && console.log("got success from pay_url");
+ await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig);
+ const nextUrl = walletResp.contractTerms.fulfillment_url;
+ logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl);
+ window.location.href = nextUrl;
+ window.location.reload(true);
}
@@ -233,53 +189,24 @@ function init() {
type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void;
-function downloadContract(url: string, nonce: string): Promise<any> {
+async function downloadContract(url: string, nonce: string): Promise<any> {
const parsed_url = new URI(url);
url = parsed_url.setQuery({nonce}).href();
- // FIXME: include and check nonce!
- return new Promise((resolve, reject) => {
- const contract_request = new XMLHttpRequest();
- console.log("downloading contract from '" + url + "'");
- contract_request.open("GET", url, true);
- contract_request.onload = (e) => {
- if (contract_request.readyState === 4) {
- if (contract_request.status === 200) {
- console.log("response text:",
- contract_request.responseText);
- const contract_wrapper = JSON.parse(contract_request.responseText);
- if (!contract_wrapper) {
- console.error("response text was invalid json");
- const detail = {
- body: contract_request.responseText,
- hint: "invalid json",
- status: contract_request.status,
- };
- reject(detail);
- return;
- }
- resolve(contract_wrapper);
- } else {
- const detail = {
- body: contract_request.responseText,
- hint: "contract download failed",
- status: contract_request.status,
- };
- reject(detail);
- return;
- }
- }
- };
- contract_request.onerror = (e) => {
- const detail = {
- body: contract_request.responseText,
- hint: "contract download failed",
- status: contract_request.status,
- };
- reject(detail);
- return;
- };
- contract_request.send();
- });
+ console.log("downloading contract from '" + url + "'");
+ let resp;
+ try {
+ resp = await axios.get(url, { validateStatus: (s) => s == 200 });
+ } catch (e) {
+ wxApi.logAndDisplayError({
+ name: "contract-download-failed",
+ message: e.message,
+ response: e.response,
+ sameTab: true,
+ });
+ throw e;
+ }
+ console.log("got response", resp);
+ return resp.data;
}
async function processProposal(proposal: any) {
@@ -328,8 +255,38 @@ async function processProposal(proposal: any) {
document.location.replace(target);
}
+
+/**
+ * Handle a payment request (coming either from an HTTP 402 or
+ * the JS wallet API).
+ */
function talerPay(msg: any): Promise<any> {
+ // Use a promise directly instead of of an async
+ // function since some paths never resolve the promise.
return new Promise(async(resolve, reject) => {
+ if (msg.refund_url) {
+ console.log("processing refund");
+ let resp;
+ try {
+ const config = {
+ validateStatus: (s: number) => s == 200,
+ }
+ resp = await axios.get(msg.refund_url, config);
+ } catch (e) {
+ wxApi.logAndDisplayError({
+ name: "refund-download-failed",
+ message: e.message,
+ response: e.response,
+ sameTab: true,
+ });
+ throw e;
+ }
+ await wxApi.acceptRefund(resp.data);
+ const hc = resp.data.refund_permissions[0].h_contract_terms;
+ document.location.href = chrome.extension.getURL(`/src/webex/pages/refund.html?contractTermsHash=${hc}`);
+ return;
+ }
+
// current URL without fragment
const url = new URI(document.location.href).fragment("").href();
const res = await wxApi.queryPayment(url);
diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx
index 4e3b6748f..7d543860f 100644
--- a/src/webex/pages/confirm-create-reserve.tsx
+++ b/src/webex/pages/confirm-create-reserve.tsx
@@ -41,7 +41,7 @@ import {
getReserveCreationInfo,
} from "../wxApi";
-import {renderAmount} from "../renderHtml";
+import {Collapsible, renderAmount} from "../renderHtml";
import * as React from "react";
import * as ReactDOM from "react-dom";
@@ -80,40 +80,6 @@ class EventTrigger {
}
-interface CollapsibleState {
- collapsed: boolean;
-}
-
-interface CollapsibleProps {
- initiallyCollapsed: boolean;
- title: string;
-}
-
-class Collapsible extends React.Component<CollapsibleProps, CollapsibleState> {
- constructor(props: CollapsibleProps) {
- super(props);
- this.state = { collapsed: props.initiallyCollapsed };
- }
- render() {
- const doOpen = (e: any) => {
- this.setState({collapsed: false});
- e.preventDefault();
- };
- const doClose = (e: any) => {
- this.setState({collapsed: true});
- e.preventDefault();
- };
- if (this.state.collapsed) {
- return <h2><a className="opener opener-collapsed" href="#" onClick={doOpen}>{this.props.title}</a></h2>;
- }
- return (
- <div>
- <h2><a className="opener opener-open" href="#" onClick={doClose}>{this.props.title}</a></h2>
- {this.props.children}
- </div>
- );
- }
-}
function renderAuditorDetails(rci: ReserveCreationInfo|null) {
console.log("rci", rci);
@@ -405,7 +371,7 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
if (this.statusString()) {
return (
<p>
- <strong style={{color: "red"}}>{i18n.str`A problem occured, see below. ${this.statusString()}`}</strong>
+ <strong style={{color: "red"}}>{this.statusString()}</strong>
</p>
);
}
@@ -549,12 +515,9 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
console.dir(r);
} catch (e) {
console.log("get exchange info rejected", e);
- if (e.hasOwnProperty("httpStatus")) {
- this.statusString(`Error: request failed with status ${e.httpStatus}`);
- } else if (e.hasOwnProperty("errorResponse")) {
- const resp = e.errorResponse;
- this.statusString(`Error: ${resp.error} (${resp.hint})`);
- }
+ this.statusString(`Error: ${e.message}`);
+ // Re-try every 5 seconds as long as there is a problem
+ setTimeout(() => this.statusString() ? this.forceReserveUpdate() : undefined, 5000);
}
}
diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx
index e86b6cf4c..2edef5e5b 100644
--- a/src/webex/pages/error.tsx
+++ b/src/webex/pages/error.tsx
@@ -22,40 +22,103 @@
* @author Florian Dold
*/
+
import * as React from "react";
import * as ReactDOM from "react-dom";
import URI = require("urijs");
+import * as wxApi from "../wxApi";
+
+import { Collapsible } from "../renderHtml";
+
interface ErrorProps {
- message: string;
+ report: any;
}
class ErrorView extends React.Component<ErrorProps, { }> {
render(): JSX.Element {
- return (
- <div>
- An error occurred: {this.props.message}
- </div>
- );
+ const report = this.props.report;
+ if (!report) {
+ return (
+ <div id="main">
+ <h1>Error Report Not Found</h1>
+ <p>This page is supposed to display an error reported by the GNU Taler wallet,
+ but the corresponding error report can't be found.</p>
+ <p>Maybe the error occured before the browser was restarted or the wallet was reloaded.</p>
+ </div>
+ );
+ }
+ try {
+ switch (report.name) {
+ case "pay-post-failed": {
+ const summary = report.contractTerms.summary || report.contractTerms.order_id;
+ return (
+ <div id="main">
+ <h1>Failed to send payment</h1>
+ <p>Failed to send payment for <strong>{summary}</strong> to merchant <strong>{report.contractTerms.merchant.name}</strong>.</p>
+ <p>You can <a href={report.contractTerms.fulfillment_url}>retry</a> the payment. If this problem persists,
+ please contact the mechant with the error details below.</p>
+ <Collapsible initiallyCollapsed={true} title="Error Details">
+ <pre>
+ {JSON.stringify(report, null, " ")}
+ </pre>
+ </Collapsible>
+ </div>
+ );
+ }
+ default:
+ return (
+ <div id="main">
+ <h1>Unknown Error</h1>
+ The GNU Taler wallet reported an unknown error. Here are the details:
+ <pre>
+ {JSON.stringify(report, null, " ")}
+ </pre>
+ </div>
+ );
+ }
+ } catch (e) {
+ return (
+ <div id="main">
+ <h1>Error</h1>
+ The GNU Taler wallet reported an error. Here are the details:
+ <pre>
+ {JSON.stringify(report, null, " ")}
+ </pre>
+ A detailed error report could not be generated:
+ <pre>
+ {e.toString()}
+ </pre>
+ </div>
+ );
+ }
}
}
async function main() {
- try {
- const url = new URI(document.location.href);
- const query: any = URI.parseQuery(url.query());
+ const url = new URI(document.location.href);
+ const query: any = URI.parseQuery(url.query());
- const message: string = query.message || "unknown error";
+ const container = document.getElementById("container");
+ if (!container) {
+ console.error("fatal: can't mount component, countainer missing");
+ return;
+ }
- ReactDOM.render(<ErrorView message={message} />, document.getElementById(
- "container")!);
+ // report that we'll render, either looked up from the
+ // logging module or synthesized here for fixed/fatal errors
+ let report;
- } catch (e) {
- // TODO: provide more context information, maybe factor it out into a
- // TODO:generic error reporting function or component.
- document.body.innerText = `Fatal error: "${e.message}".`;
- console.error(`got error "${e.message}"`, e);
+ const reportUid: string = query.reportUid;
+ if (!reportUid) {
+ report = {
+ name: "missing-error",
+ };
+ } else {
+ report = await wxApi.getReport(reportUid);
}
+
+ ReactDOM.render(<ErrorView report={report} />, container);
}
document.addEventListener("DOMContentLoaded", () => main());
diff --git a/src/webex/pages/refund.html b/src/webex/pages/refund.html
new file mode 100644
index 000000000..f97dc9d6c
--- /dev/null
+++ b/src/webex/pages/refund.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Refund Status</title>
+
+ <link rel="stylesheet" type="text/css" href="../style/wallet.css">
+
+ <link rel="icon" href="/img/icon.png">
+
+ <script src="/dist/page-common-bundle.js"></script>
+ <script src="/dist/refund-bundle.js"></script>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx
new file mode 100644
index 000000000..b9506bf29
--- /dev/null
+++ b/src/webex/pages/refund.tsx
@@ -0,0 +1,138 @@
+/*
+ This file is part of TALER
+ (C) 2015-2016 GNUnet e.V.
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+
+/**
+ * Page that shows refund status for purchases.
+ *
+ * @author Florian Dold
+ */
+
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import URI = require("urijs");
+
+import * as wxApi from "../wxApi";
+import * as types from "../../types";
+
+import { AmountDisplay } from "../renderHtml";
+
+interface RefundStatusViewProps {
+ contractTermsHash: string;
+}
+
+interface RefundStatusViewState {
+ purchase?: types.PurchaseRecord;
+ gotResult: boolean;
+}
+
+
+const RefundDetail = ({purchase}: {purchase: types.PurchaseRecord}) => {
+ const pendingKeys = Object.keys(purchase.refundsPending);
+ const doneKeys = Object.keys(purchase.refundsDone);
+ if (pendingKeys.length == 0 && doneKeys.length == 0) {
+ return <p>No refunds</p>;
+ }
+
+ const currency = { ...purchase.refundsDone, ...purchase.refundsPending }[([...pendingKeys, ...doneKeys][0])].refund_amount.currency;
+ if (!currency) {
+ throw Error("invariant");
+ }
+
+ let amountPending = types.Amounts.getZero(currency);
+ let feesPending = types.Amounts.getZero(currency)
+ for (let k of pendingKeys) {
+ amountPending = types.Amounts.add(amountPending, purchase.refundsPending[k].refund_amount).amount;
+ feesPending = types.Amounts.add(feesPending, purchase.refundsPending[k].refund_fee).amount;
+ }
+ let amountDone = types.Amounts.getZero(currency);
+ let feesDone = types.Amounts.getZero(currency);
+ for (let k of doneKeys) {
+ amountDone = types.Amounts.add(amountDone, purchase.refundsDone[k].refund_amount).amount;
+ feesDone = types.Amounts.add(feesDone, purchase.refundsDone[k].refund_fee).amount;
+ }
+
+ return (
+ <div>
+ <p>Refund fully received: <AmountDisplay amount={amountDone} /> (refund fees: <AmountDisplay amount={feesDone} />)</p>
+ <p>Refund incoming: <AmountDisplay amount={amountPending} /> (refund fees: <AmountDisplay amount={feesPending} />)</p>
+ </div>
+ );
+};
+
+class RefundStatusView extends React.Component<RefundStatusViewProps, RefundStatusViewState> {
+
+ constructor(props: RefundStatusViewProps) {
+ super(props);
+ this.state = { gotResult: false };
+ }
+
+ componentDidMount() {
+ this.update();
+ const port = chrome.runtime.connect();
+ port.onMessage.addListener((msg: any) => {
+ if (msg.notify) {
+ console.log("got notified");
+ this.update();
+ }
+ });
+ }
+
+ render(): JSX.Element {
+ const purchase = this.state.purchase;
+ if (!purchase) {
+ if (this.state.gotResult) {
+ return <span>No purchase with contract terms hash {this.props.contractTermsHash} found</span>;
+ } else {
+ return <span>...</span>;
+ }
+ }
+ const merchantName = purchase.contractTerms.merchant.name || "(unknown)";
+ const summary = purchase.contractTerms.summary || purchase.contractTerms.order_id;
+ return (
+ <div id="main">
+ <h1>Refund Status</h1>
+ <p>Status of purchase <strong>{summary}</strong> from merchant <strong>{merchantName}</strong> (order id {purchase.contractTerms.order_id}).</p>
+ <p>Total amount: <AmountDisplay amount={purchase.contractTerms.amount} /></p>
+ {purchase.finished ? <RefundDetail purchase={purchase} /> : <p>Purchase not completed.</p>}
+ </div>
+ );
+ }
+
+ async update() {
+ const purchase = await wxApi.getPurchase(this.props.contractTermsHash);
+ console.log("got purchase", purchase);
+ this.setState({ purchase, gotResult: true });
+ }
+}
+
+
+async function main() {
+ const url = new URI(document.location.href);
+ const query: any = URI.parseQuery(url.query());
+
+ const container = document.getElementById("container");
+ if (!container) {
+ console.error("fatal: can't mount component, countainer missing");
+ return;
+ }
+
+ const contractTermsHash = query.contractTermsHash || "(none)";
+ ReactDOM.render(<RefundStatusView contractTermsHash={contractTermsHash} />, container);
+}
+
+document.addEventListener("DOMContentLoaded", () => main());
diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx
index 51f9019ef..2a5b50533 100644
--- a/src/webex/renderHtml.tsx
+++ b/src/webex/renderHtml.tsx
@@ -73,6 +73,8 @@ export function renderAmount(amount: AmountJson) {
return <span>{x}&nbsp;{amount.currency}</span>;
}
+export const AmountDisplay = ({amount}: {amount: AmountJson}) => renderAmount(amount);
+
/**
* Abbreviate a string to a given length, and show the full
@@ -89,3 +91,40 @@ export function abbrev(s: string, n: number = 5) {
</span>
);
}
+
+
+
+interface CollapsibleState {
+ collapsed: boolean;
+}
+
+interface CollapsibleProps {
+ initiallyCollapsed: boolean;
+ title: string;
+}
+
+export class Collapsible extends React.Component<CollapsibleProps, CollapsibleState> {
+ constructor(props: CollapsibleProps) {
+ super(props);
+ this.state = { collapsed: props.initiallyCollapsed };
+ }
+ render() {
+ const doOpen = (e: any) => {
+ this.setState({collapsed: false});
+ e.preventDefault();
+ };
+ const doClose = (e: any) => {
+ this.setState({collapsed: true});
+ e.preventDefault();
+ };
+ if (this.state.collapsed) {
+ return <h2><a className="opener opener-collapsed" href="#" onClick={doOpen}>{this.props.title}</a></h2>;
+ }
+ return (
+ <div>
+ <h2><a className="opener opener-open" href="#" onClick={doClose}>{this.props.title}</a></h2>
+ {this.props.children}
+ </div>
+ );
+ }
+}
diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts
index 1371e27e4..1423da53b 100644
--- a/src/webex/wxApi.ts
+++ b/src/webex/wxApi.ts
@@ -31,6 +31,7 @@ import {
DenominationRecord,
ExchangeRecord,
PreCoinRecord,
+ PurchaseRecord,
QueryPaymentResult,
ReserveCreationInfo,
ReserveRecord,
@@ -321,3 +322,26 @@ export function getSenderWireInfos(): Promise<SenderWireInfos> {
export function returnCoins(args: { amount: AmountJson, exchange: string, senderWire: object }): Promise<void> {
return callBackend("return-coins", args);
}
+
+
+/**
+ * Record an error report and display it in a tabl.
+ *
+ * If sameTab is set, the error report will be opened in the current tab,
+ * otherwise in a new tab.
+ */
+export function logAndDisplayError(args: any): Promise<void> {
+ return callBackend("log-and-display-error", args);
+}
+
+export function getReport(reportUid: string): Promise<void> {
+ return callBackend("get-report", { reportUid });
+}
+
+export function acceptRefund(refundData: any): Promise<number> {
+ return callBackend("accept-refund", refundData);
+}
+
+export function getPurchase(contractTermsHash: string): Promise<PurchaseRecord> {
+ return callBackend("get-purchase", { contractTermsHash });
+}
diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts
index 974bcb3c2..2f249af44 100644
--- a/src/webex/wxBackend.ts
+++ b/src/webex/wxBackend.ts
@@ -303,6 +303,26 @@ function handleMessage(sender: MessageSender,
}
return resp;
}
+ case "log-and-display-error":
+ logging.storeReport(detail).then((reportUid) => {
+ const url = chrome.extension.getURL(`/src/webex/pages/error.html?reportUid=${reportUid}`);
+ if (detail.sameTab && sender && sender.tab && sender.tab.id) {
+ chrome.tabs.update(detail.tabId, { url });
+ } else {
+ chrome.tabs.create({ url });
+ }
+ });
+ return;
+ case "get-report":
+ return logging.getReport(detail.reportUid);
+ case "accept-refund":
+ return needsWallet().acceptRefund(detail.refund_permissions);
+ case "get-purchase":
+ const contractTermsHash = detail.contractTermsHash;
+ if (!contractTermsHash) {
+ throw Error("contractTermsHash missing");
+ }
+ return needsWallet().getPurchase(contractTermsHash);
default:
// Exhaustiveness check.
// See https://www.typescriptlang.org/docs/handbook/advanced-types.html
@@ -332,7 +352,7 @@ async function dispatch(req: any, sender: any, sendResponse: any): Promise<void>
try {
sendResponse({
error: "exception",
- hint: e.message,
+ message: e.message,
stack,
});
} catch (e) {
@@ -371,6 +391,9 @@ class ChromeNotifier implements Notifier {
/**
* Mapping from tab ID to payment information (if any).
+ *
+ * Used to pass information from an intercepted HTTP header to the content
+ * script on the page.
*/
const paymentRequestCookies: { [n: number]: any } = {};
@@ -392,6 +415,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri
const fields = {
contract_url: headers["x-taler-contract-url"],
offer_url: headers["x-taler-offer-url"],
+ refund_url: headers["x-taler-refund-url"],
};
const talerHeaderFound = Object.keys(fields).filter((x: any) => (fields as any)[x]).length !== 0;
@@ -406,6 +430,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri
const payDetail = {
contract_url: fields.contract_url,
offer_url: fields.offer_url,
+ refund_url: fields.refund_url,
};
console.log("got pay detail", payDetail);