diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/db.ts | 2 | ||||
-rw-r--r-- | src/operations/pay.ts | 10 | ||||
-rw-r--r-- | src/operations/reserves.ts | 51 | ||||
-rw-r--r-- | src/operations/tip.ts | 16 | ||||
-rw-r--r-- | src/operations/transactions.ts | 55 | ||||
-rw-r--r-- | src/operations/withdraw.ts | 20 | ||||
-rw-r--r-- | src/types/dbTypes.ts | 22 | ||||
-rw-r--r-- | src/types/talerTypes.ts | 94 | ||||
-rw-r--r-- | src/types/transactions.ts | 128 |
9 files changed, 257 insertions, 141 deletions
@@ -7,7 +7,7 @@ import { openDatabase, Database, Store, Index } from "./util/query"; * with each major change. When incrementing the major version, * the wallet should import data from the previous version. */ -const TALER_DB_NAME = "taler-walletdb-v3"; +const TALER_DB_NAME = "taler-walletdb-v4"; /** * Current database minor version, should be incremented diff --git a/src/operations/pay.ts b/src/operations/pay.ts index 45caa9583..20d62dea2 100644 --- a/src/operations/pay.ts +++ b/src/operations/pay.ts @@ -137,11 +137,7 @@ export async function getTotalPaymentCost( ws: InternalWalletState, pcs: PayCoinSelection, ): Promise<PayCostInfo> { - const costs = [ - pcs.paymentAmount, - pcs.customerDepositFees, - pcs.customerWireFees, - ]; + const costs = []; for (let i = 0; i < pcs.coinPubs.length; i++) { const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]); if (!coin) { @@ -165,6 +161,7 @@ export async function getTotalPaymentCost( const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i]) .amount; const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft); + costs.push(pcs.coinContributions[i]); costs.push(refreshCost); } return { @@ -670,6 +667,9 @@ async function processDownloadProposalImpl( wireMethod: parsedContractTerms.wire_method, wireInfoHash: parsedContractTerms.h_wire, maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), + merchant: parsedContractTerms.merchant, + products: parsedContractTerms.products, + summaryI18n: parsedContractTerms.summary_i18n, }, contractTermsRaw: JSON.stringify(proposalResp.contract_terms), }; diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts index 347f6e894..3d45d8d5a 100644 --- a/src/operations/reserves.ts +++ b/src/operations/reserves.ts @@ -35,6 +35,7 @@ import { WalletReserveHistoryItemType, WithdrawalSourceType, ReserveHistoryRecord, + ReserveBankInfo, } from "../types/dbTypes"; import { Logger } from "../util/logging"; import { Amounts } from "../util/amounts"; @@ -48,9 +49,11 @@ import { assertUnreachable } from "../util/assertUnreachable"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { randomBytes } from "../crypto/primitives/nacl-fast"; import { - getVerifiedWithdrawDenomList, + selectWithdrawalDenoms, processWithdrawGroup, getBankWithdrawalInfo, + denomSelectionInfoToState, + getWithdrawDenomList, } from "./withdraw"; import { guardOperationException, @@ -100,6 +103,20 @@ export async function createReserve( reserveStatus = ReserveRecordStatus.UNCONFIRMED; } + let bankInfo: ReserveBankInfo | undefined; + + if (req.bankWithdrawStatusUrl) { + const denomSelInfo = await selectWithdrawalDenoms(ws, canonExchange, req.amount); + const denomSel = denomSelectionInfoToState(denomSelInfo); + bankInfo = { + statusUrl: req.bankWithdrawStatusUrl, + amount: req.amount, + bankWithdrawalGroupId: encodeCrock(getRandomBytes(32)), + withdrawalStarted: false, + denomSel, + }; + } + const reserveRecord: ReserveRecord = { timestampCreated: now, exchangeBaseUrl: canonExchange, @@ -108,14 +125,7 @@ export async function createReserve( senderWire: req.senderWire, timestampConfirmed: undefined, timestampReserveInfoPosted: undefined, - bankInfo: req.bankWithdrawStatusUrl - ? { - statusUrl: req.bankWithdrawStatusUrl, - amount: req.amount, - bankWithdrawalGroupId: encodeCrock(getRandomBytes(32)), - withdrawalStarted: false, - } - : undefined, + bankInfo, exchangeWire: req.exchangeWire, reserveStatus, lastSuccessfulStatusQuery: undefined, @@ -286,10 +296,11 @@ async function registerReserveWithBank( default: return; } - const bankStatusUrl = reserve.bankInfo?.statusUrl; - if (!bankStatusUrl) { + const bankInfo = reserve.bankInfo; + if (!bankInfo) { return; } + const bankStatusUrl = bankInfo.statusUrl; console.log("making selection"); if (reserve.timestampReserveInfoPosted) { throw Error("bank claims that reserve info selection is not done"); @@ -309,6 +320,9 @@ async function registerReserveWithBank( } r.timestampReserveInfoPosted = getTimestampNow(); r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK; + if (!r.bankInfo) { + throw Error("invariant failed"); + } r.retryInfo = initRetryInfo(); return r; }); @@ -657,7 +671,7 @@ async function depleteReserve( logger.trace(`getting denom list`); - const denomsForWithdraw = await getVerifiedWithdrawDenomList( + const denomsForWithdraw = await selectWithdrawalDenoms( ws, reserve.exchangeBaseUrl, withdrawAmount, @@ -752,17 +766,8 @@ async function depleteReserve( retryInfo: initRetryInfo(), lastErrorPerCoin: {}, lastError: undefined, - denomsSel: { - totalCoinValue: denomsForWithdraw.totalCoinValue, - totalWithdrawCost: denomsForWithdraw.totalWithdrawCost, - selectedDenoms: denomsForWithdraw.selectedDenoms.map((x) => { - return { - count: x.count, - denomPubHash: x.denom.denomPubHash, - }; - }), - }, - }; + denomsSel: denomSelectionInfoToState(denomsForWithdraw), + }; await tx.put(Stores.reserves, newReserve); await tx.put(Stores.reserveHistory, newHist); diff --git a/src/operations/tip.ts b/src/operations/tip.ts index f584fc502..15d2339b5 100644 --- a/src/operations/tip.ts +++ b/src/operations/tip.ts @@ -34,8 +34,9 @@ import { } from "../types/dbTypes"; import { getExchangeWithdrawalInfo, - getVerifiedWithdrawDenomList, + selectWithdrawalDenoms, processWithdrawGroup, + denomSelectionInfoToState, } from "./withdraw"; import { updateExchangeFromUrl } from "./exchanges"; import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; @@ -81,7 +82,7 @@ export async function getTipStatus( ); const tipId = encodeCrock(getRandomBytes(32)); - const selectedDenoms = await getVerifiedWithdrawDenomList( + const selectedDenoms = await selectWithdrawalDenoms( ws, tipPickupStatus.exchange_url, amount, @@ -107,16 +108,7 @@ export async function getTipStatus( ).amount, retryInfo: initRetryInfo(), lastError: undefined, - denomsSel: { - totalCoinValue: selectedDenoms.totalCoinValue, - totalWithdrawCost: selectedDenoms.totalWithdrawCost, - selectedDenoms: selectedDenoms.selectedDenoms.map((x) => { - return { - count: x.count, - denomPubHash: x.denom.denomPubHash, - }; - }), - }, + denomsSel: denomSelectionInfoToState(selectedDenoms), }; await ws.db.put(Stores.tips, tipRecord); } diff --git a/src/operations/transactions.ts b/src/operations/transactions.ts index 4cc6154b5..fd7679621 100644 --- a/src/operations/transactions.ts +++ b/src/operations/transactions.ts @@ -18,7 +18,7 @@ * Imports. */ import { InternalWalletState } from "./state"; -import { Stores, ReserveRecordStatus, PurchaseRecord, ProposalStatus } from "../types/dbTypes"; +import { Stores, ReserveRecordStatus, PurchaseRecord } from "../types/dbTypes"; import { Amounts, AmountJson } from "../util/amounts"; import { timestampCmp } from "../util/time"; import { @@ -131,10 +131,8 @@ export async function getTransactions( if (wsr.timestampFinish) { transactions.push({ type: TransactionType.Withdrawal, - amountEffective: Amounts.stringify( - wsr.denomsSel.totalWithdrawCost, - ), - amountRaw: Amounts.stringify(wsr.denomsSel.totalCoinValue), + amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wsr.denomsSel.totalWithdrawCost), confirmed: true, exchangeBaseUrl: wsr.exchangeBaseUrl, pending: !wsr.timestampFinish, @@ -163,9 +161,9 @@ export async function getTransactions( transactions.push({ type: TransactionType.Withdrawal, confirmed: false, - amountRaw: Amounts.stringify(r.bankInfo.amount), - amountEffective: undefined, - exchangeBaseUrl: undefined, + amountRaw: Amounts.stringify(r.bankInfo.denomSel.totalWithdrawCost), + amountEffective: Amounts.stringify(r.bankInfo.denomSel.totalCoinValue), + exchangeBaseUrl: r.exchangeBaseUrl, pending: true, timestamp: r.timestampCreated, bankConfirmationUrl: r.bankInfo.confirmUrl, @@ -176,38 +174,6 @@ export async function getTransactions( }); }); - tx.iter(Stores.proposals).forEachAsync(async (proposal) => { - if (!proposal.download) { - return; - } - if (proposal.proposalStatus !== ProposalStatus.PROPOSED) { - return; - } - const dl = proposal.download; - const purchase = await tx.get(Stores.purchases, proposal.proposalId); - if (purchase) { - return; - } - - transactions.push({ - type: TransactionType.Payment, - amountRaw: Amounts.stringify(dl.contractData.amount), - amountEffective: undefined, - status: PaymentStatus.Offered, - pending: true, - timestamp: proposal.timestamp, - transactionId: makeEventId(TransactionType.Payment, proposal.proposalId), - info: { - fulfillmentUrl: dl.contractData.fulfillmentUrl, - merchant: {}, - orderId: dl.contractData.orderId, - products: [], - summary: dl.contractData.summary, - summary_i18n: {}, - }, - }); - }); - tx.iter(Stores.purchases).forEachAsync(async (pr) => { if ( transactionsRequest?.currency && @@ -231,11 +197,11 @@ export async function getTransactions( transactionId: makeEventId(TransactionType.Payment, pr.proposalId), info: { fulfillmentUrl: pr.contractData.fulfillmentUrl, - merchant: {}, + merchant: pr.contractData.merchant, orderId: pr.contractData.orderId, - products: [], + products: pr.contractData.products, summary: pr.contractData.summary, - summary_i18n: {}, + summary_i18n: pr.contractData.summaryI18n, }, }); @@ -258,7 +224,8 @@ export async function getTransactions( timestamp: rg.timestampQueried, transactionId: makeEventId( TransactionType.Refund, - `{rg.timestampQueried.t_ms}`, + pr.proposalId, + `${rg.timestampQueried.t_ms}`, ), refundedTransactionId: makeEventId( TransactionType.Payment, diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts index 21c30d7af..14071be79 100644 --- a/src/operations/withdraw.ts +++ b/src/operations/withdraw.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019-2029 Taler Systems SA + (C) 2019-2020 Taler Systems SA 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 @@ -27,6 +27,7 @@ import { DenominationSelectionInfo, PlanchetRecord, WithdrawalSourceType, + DenomSelectionState, } from "../types/dbTypes"; import { BankWithdrawDetails, @@ -419,6 +420,19 @@ async function processPlanchet( } } +export function denomSelectionInfoToState(dsi: DenominationSelectionInfo): DenomSelectionState { + return { + selectedDenoms: dsi.selectedDenoms.map((x) => { + return { + count: x.count, + denomPubHash: x.denom.denomPubHash + }; + }), + totalCoinValue: dsi.totalCoinValue, + totalWithdrawCost: dsi.totalWithdrawCost, + } +} + /** * Get a list of denominations to withdraw from the given exchange for the * given amount, making sure that all denominations' signatures are verified. @@ -426,7 +440,7 @@ async function processPlanchet( * Writes to the DB in order to record the result from verifying * denominations. */ -export async function getVerifiedWithdrawDenomList( +export async function selectWithdrawalDenoms( ws: InternalWalletState, exchangeBaseUrl: string, amount: AmountJson, @@ -603,7 +617,7 @@ export async function getExchangeWithdrawalInfo( throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`); } - const selectedDenoms = await getVerifiedWithdrawDenomList( + const selectedDenoms = await selectWithdrawalDenoms( ws, baseUrl, amount, diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts index 37a66251a..79966eb34 100644 --- a/src/types/dbTypes.ts +++ b/src/types/dbTypes.ts @@ -31,6 +31,8 @@ import { PayReq, TipResponse, ExchangeSignKeyJson, + MerchantInfo, + Product, } from "./talerTypes"; import { Index, Store } from "../util/query"; @@ -216,6 +218,15 @@ export interface ReserveHistoryRecord { reserveTransactions: WalletReserveHistoryItem[]; } +export interface ReserveBankInfo { + statusUrl: string; + confirmUrl?: string; + amount: AmountJson; + bankWithdrawalGroupId: string; + withdrawalStarted: boolean; + denomSel: DenomSelectionState; +} + /** * A reserve record as stored in the wallet's database. */ @@ -278,13 +289,7 @@ export interface ReserveRecord { * Extra state for when this is a withdrawal involving * a Taler-integrated bank. */ - bankInfo?: { - statusUrl: string; - confirmUrl?: string; - amount: AmountJson; - bankWithdrawalGroupId: string; - withdrawalStarted: boolean; - }; + bankInfo?: ReserveBankInfo; reserveStatus: ReserveRecordStatus; @@ -1179,10 +1184,13 @@ export interface AllowedExchangeInfo { * processing in the wallet. */ export interface WalletContractData { + products?: Product[]; + summaryI18n: { [lang_tag: string]: string } | undefined; fulfillmentUrl: string; contractTermsHash: string; merchantSig: string; merchantPub: string; + merchant: MerchantInfo; amount: AmountJson; orderId: string; merchantBaseUrl: string; diff --git a/src/types/talerTypes.ts b/src/types/talerTypes.ts index 17d11eea8..eb10d6e1f 100644 --- a/src/types/talerTypes.ts +++ b/src/types/talerTypes.ts @@ -260,6 +260,55 @@ export class AuditorHandle { url: string; } +export interface MerchantInfo { + name: string; + jurisdiction: string | undefined; + address: string | undefined; +} + +export interface Tax { + // the name of the tax + name: string; + + // amount paid in tax + tax: AmountString; +} + +export interface Product { + // merchant-internal identifier for the product. + product_id?: string; + + // Human-readable product description. + description: string; + + // Map from IETF BCP 47 language tags to localized descriptions + description_i18n?: { [lang_tag: string]: string }; + + // The number of units of the product to deliver to the customer. + quantity?: number; + + // The unit in which the product is measured (liters, kilograms, packages, etc.) + unit?: string; + + // The price of the product; this is the total price for quantity times unit of this product. + price?: AmountString; + + // An optional base64-encoded product image + image?: string; + + // a list of taxes paid by the merchant for this product. Can be empty. + taxes?: Tax[]; + + // time indicating when this product should be delivered + delivery_date?: Timestamp; + + // where to deliver this product. This may be an URL for online delivery + // (i.e. 'http://example.com/download' or 'mailto:customer@example.com'), + // or a location label defined inside the proposition's 'locations'. + // The presence of a colon (':') indicates the use of an URL. + delivery_location?: string; +} + /** * Contract terms from a merchant. */ @@ -284,6 +333,8 @@ export class ContractTerms { */ summary: string; + summary_i18n?: { [lang_tag: string]: string }; + /** * Nonce used to ensure freshness. */ @@ -317,7 +368,7 @@ export class ContractTerms { /** * Information about the merchant. */ - merchant: any; + merchant: MerchantInfo; /** * Public key of the merchant. @@ -332,7 +383,7 @@ export class ContractTerms { /** * Products that are sold in this contract. */ - products?: any[]; + products?: Product[]; /** * Deadline for refunds. @@ -805,6 +856,35 @@ export const codecForAuditorHandle = (): Codec<AuditorHandle> => .property("url", codecForString) .build("AuditorHandle"); +export const codecForMerchantInfo = (): Codec<MerchantInfo> => + makeCodecForObject<MerchantInfo>() + .property("name", codecForString) + .property("address", makeCodecOptional(codecForString)) + .property("jurisdiction", makeCodecOptional(codecForString)) + .build("MerchantInfo"); + +export const codecForTax = (): Codec<Tax> => + makeCodecForObject<Tax>() + .property("name", codecForString) + .property("tax", codecForString) + .build("Tax"); + + +export const codecForI18n = (): Codec<{ [lang_tag: string]: string }> => + makeCodecForMap(codecForString) + +export const codecForProduct = (): Codec<Product> => + makeCodecForObject<Product>() + .property("product_id", makeCodecOptional(codecForString)) + .property("description", codecForString) + .property("description_i18n", makeCodecOptional(codecForI18n())) + .property("quantity", makeCodecOptional(codecForNumber)) + .property("unit", makeCodecOptional(codecForString)) + .property("price", makeCodecOptional(codecForString)) + .property("delivery_date", makeCodecOptional(codecForTimestamp)) + .property("delivery_location", makeCodecOptional(codecForString)) + .build("Tax"); + export const codecForContractTerms = (): Codec<ContractTerms> => makeCodecForObject<ContractTerms>() .property("order_id", codecForString) @@ -814,6 +894,7 @@ export const codecForContractTerms = (): Codec<ContractTerms> => .property("auto_refund", makeCodecOptional(codecForDuration)) .property("wire_method", codecForString) .property("summary", codecForString) + .property("summary_i18n", makeCodecOptional(codecForI18n())) .property("nonce", codecForString) .property("amount", codecForString) .property("auditors", makeCodecForList(codecForAuditorHandle())) @@ -824,10 +905,10 @@ export const codecForContractTerms = (): Codec<ContractTerms> => .property("locations", codecForAny) .property("max_fee", codecForString) .property("max_wire_fee", makeCodecOptional(codecForString)) - .property("merchant", codecForAny) + .property("merchant", codecForMerchantInfo()) .property("merchant_pub", codecForString) .property("exchanges", makeCodecForList(codecForExchangeHandle())) - .property("products", makeCodecOptional(makeCodecForList(codecForAny))) + .property("products", makeCodecOptional(makeCodecForList(codecForProduct()))) .property("extra", codecForAny) .build("ContractTerms"); @@ -852,10 +933,7 @@ export const codecForMerchantRefundResponse = (): Codec< makeCodecForObject<MerchantRefundResponse>() .property("merchant_pub", codecForString) .property("h_contract_terms", codecForString) - .property( - "refunds", - makeCodecForList(codecForMerchantRefundPermission()), - ) + .property("refunds", makeCodecForList(codecForMerchantRefundPermission())) .build("MerchantRefundResponse"); export const codecForReserveSigSingleton = (): Codec<ReserveSigSingleton> => diff --git a/src/types/transactions.ts b/src/types/transactions.ts index 263e08a4e..b1d033c09 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -16,13 +16,16 @@ /** * Type and schema definitions for the wallet's transaction list. + * + * @author Florian Dold + * @author Torsten Grote */ /** * Imports. */ import { Timestamp } from "../util/time"; -import { AmountString } from "./talerTypes"; +import { AmountString, Product } from "./talerTypes"; export interface TransactionsRequest { /** @@ -44,6 +47,24 @@ export interface TransactionsResponse { transactions: Transaction[]; } +interface TransactionError { + /** + * TALER_EC_* unique error code. + * The action(s) offered and message displayed on the transaction item depend on this code. + */ + ec: number; + + /** + * English-only error hint, if available. + */ + hint?: string; + + /** + * Error details specific to "ec", if applicable/available + */ + details?: any; +} + export interface TransactionCommon { // opaque unique ID for the transaction, used as a starting point for paginating queries // and for invoking actions on the transaction (e.g. deleting/hiding it from the history) @@ -64,16 +85,17 @@ export interface TransactionCommon { amountRaw: AmountString; // Amount added or removed from the wallet's balance (including all fees and other costs) - amountEffective?: AmountString; + amountEffective: AmountString; + + error?: TransactionError; } -export type Transaction = ( - TransactionWithdrawal | - TransactionPayment | - TransactionRefund | - TransactionTip | - TransactionRefresh -) +export type Transaction = + | TransactionWithdrawal + | TransactionPayment + | TransactionRefund + | TransactionTip + | TransactionRefresh; export const enum TransactionType { Withdrawal = "withdrawal", @@ -93,79 +115,109 @@ interface TransactionWithdrawal extends TransactionCommon { */ exchangeBaseUrl?: string; - // true if the bank has confirmed the withdrawal, false if not. - // An unconfirmed withdrawal usually requires user-input and should be highlighted in the UI. - // See also bankConfirmationUrl below. + /** + * true if the bank has confirmed the withdrawal, false if not. + * An unconfirmed withdrawal usually requires user-input and should be highlighted in the UI. + * See also bankConfirmationUrl below. + */ confirmed: boolean; - // If the withdrawal is unconfirmed, this can include a URL for user initiated confirmation. + /** + * If the withdrawal is unconfirmed, this can include a URL for user + * initiated confirmation. + */ bankConfirmationUrl?: string; - // Amount that has been subtracted from the reserve's balance for this withdrawal. + /** + * Amount that got subtracted from the reserve balance. + */ amountRaw: AmountString; /** * Amount that actually was (or will be) added to the wallet's balance. - * Only present if an exchange has already been selected. */ - amountEffective?: AmountString; + amountEffective: AmountString; } export const enum PaymentStatus { - // Explicitly aborted after timeout / failure + /** + * Explicitly aborted after timeout / failure + */ Aborted = "aborted", - // Payment failed, wallet will auto-retry. - // User should be given the option to retry now / abort. + /** + * Payment failed, wallet will auto-retry. + * User should be given the option to retry now / abort. + */ Failed = "failed", - // Paid successfully + /** + * Paid successfully + */ Paid = "paid", - // Only offered, user must accept / decline - Offered = "offered", - - // User accepted, payment is processing. + /** + * User accepted, payment is processing. + */ Accepted = "accepted", } export interface TransactionPayment extends TransactionCommon { type: TransactionType.Payment; - // Additional information about the payment. + /** + * Additional information about the payment. + */ info: PaymentShortInfo; + /** + * How far did the wallet get with processing the payment? + */ status: PaymentStatus; - // Amount that must be paid for the contract + /** + * Amount that must be paid for the contract + */ amountRaw: AmountString; - // Amount that was paid, including deposit, wire and refresh fees. - amountEffective?: AmountString; + /** + * Amount that was paid, including deposit, wire and refresh fees. + */ + amountEffective: AmountString; } - interface PaymentShortInfo { - // Order ID, uniquely identifies the order within a merchant instance + /** + * Order ID, uniquely identifies the order within a merchant instance + */ orderId: string; - // More information about the merchant + /** + * More information about the merchant + */ merchant: any; - // Summary of the order, given by the merchant + /** + * Summary of the order, given by the merchant + */ summary: string; - // Map from IETF BCP 47 language tags to localized summaries + /** + * Map from IETF BCP 47 language tags to localized summaries + */ summary_i18n?: { [lang_tag: string]: string }; - // List of products that are part of the order - products: any[]; + /** + * List of products that are part of the order + */ + products: Product[] | undefined; - // URL of the fulfillment, given by the merchant + /** + * URL of the fulfillment, given by the merchant + */ fulfillmentUrl: string; } - interface TransactionRefund extends TransactionCommon { type: TransactionType.Refund; @@ -221,4 +273,4 @@ interface TransactionRefresh extends TransactionCommon { // Amount that will be paid as fees for the refresh amountEffective: AmountString; -}
\ No newline at end of file +} |