net.sf.jmoney.paypal.CsvImportWizard.java Source code

Java tutorial

Introduction

Here is the source code for net.sf.jmoney.paypal.CsvImportWizard.java

Source

/*
 * 
 *  JMoney - A Personal Finance Manager
 *  Copyright (c) 2004 Nigel Westbury <westbury@users.sourceforge.net>
 * 
 * 
 *  This program 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 2 of the License, or
 *  (at your option) any later version.
 *
 *  This program 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 this program; if not, write to the Free Software
 *  Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 *
 */

package net.sf.jmoney.paypal;

import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.Iterator;
import java.util.List;

import net.sf.jmoney.isolation.TransactionManager;
import net.sf.jmoney.model2.Account;
import net.sf.jmoney.model2.CapitalAccount;
import net.sf.jmoney.model2.Currency;
import net.sf.jmoney.model2.DatastoreManager;
import net.sf.jmoney.model2.Entry;
import net.sf.jmoney.model2.IncomeExpenseAccount;
import net.sf.jmoney.model2.Session;
import net.sf.jmoney.model2.Transaction;
import net.sf.jmoney.reconciliation.ReconciliationEntryInfo;

import org.eclipse.jface.dialogs.IDialogConstants;
import org.eclipse.jface.dialogs.IDialogSettings;
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.jface.viewers.IStructuredSelection;
import org.eclipse.jface.wizard.Wizard;
import org.eclipse.ui.IImportWizard;
import org.eclipse.ui.IWorkbench;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.PlatformUI;

import au.com.bytecode.opencsv.CSVReader;

/**
 * A wizard to import data from a comma-separated file that has been downloaded
 * from Paypal.
 * <P>
 * Currently this wizard if a single page wizard that asks only for the file.
 * This feature is implemented as a wizard because the Eclipse workbench import
 * action requires all import implementations to be wizards.
 */
public class CsvImportWizard extends Wizard implements IImportWizard {

    private IWorkbenchWindow window;

    private CsvImportWizardPage filePage;

    /**
     * The transaction manager for all changes made by the import, the
     * transaction being committed when the file has been fully imported.
     */
    TransactionManager transactionManager;

    /**
     * Session, being the version inside the transaction so changes
     * are not applied to the datastore until the transaction is
     * committed.
     */
    private Session session;

    private PaypalAccount paypalAccount;

    public CsvImportWizard() {
        IDialogSettings workbenchSettings = Activator.getDefault().getDialogSettings();
        IDialogSettings section = workbenchSettings.getSection("CsvImportToAccountWizard");//$NON-NLS-1$
        if (section == null) {
            section = workbenchSettings.addNewSection("CsvImportToAccountWizard");//$NON-NLS-1$
        }
        setDialogSettings(section);
    }

    /**
     * We will cache window object in order to be able to provide parent shell
     * for the message dialog.
     */
    public void init(IWorkbench workbench, IStructuredSelection selection) {

        this.window = workbench.getActiveWorkbenchWindow();

        DatastoreManager sessionManager = (DatastoreManager) window.getActivePage().getInput();

        // Original JMoney disabled the import menu items when no
        // session was open. I don't know how to do that in Eclipse,
        // so we display a message instead.
        if (sessionManager == null) {
            MessageDialog waitDialog = new MessageDialog(window.getShell(), "Disabled Action Selected", null, // accept the default window icon
                    "You cannot import data into an accounting session unless you have a session open.  You must first open a session or create a new session.",
                    MessageDialog.INFORMATION, new String[] { IDialogConstants.OK_LABEL }, 0);
            waitDialog.open();
            return;
        }

        /*
         * Create a transaction to be used to import the entries.  This allows the entries to
         * be more efficiently written to the back-end datastore and it also groups
         * the entire import as a single change for undo/redo purposes.
         */
        transactionManager = new TransactionManager(sessionManager);
        session = transactionManager.getSession();

        /*
         * Find the Paypal account. Currently this assumes that there is
         * only one Paypal account. If you have more than one then you will
         * need to implement some changes, perhaps require the account be
         * selected before the wizard is run, or add a wizard page that asks
         * which of the Paypal accounts is to be used.
         */
        PaypalAccount accountOutside = null;
        for (Iterator<CapitalAccount> iter = sessionManager.getSession().getCapitalAccountIterator(); iter
                .hasNext();) {
            CapitalAccount eachAccount = iter.next();
            if (eachAccount instanceof PaypalAccount) {
                if (accountOutside != null) {
                    MessageDialog.openError(window.getShell(), "Problem",
                            "Multiple Paypal accounts.  Don't know which to use.  If you have multiple Paypal accounts, please submit a patch.");
                    return;
                }
                accountOutside = (PaypalAccount) eachAccount;
            }
        }

        if (accountOutside == null) {
            MessageDialog.openError(window.getShell(), "Problem", "No Paypal account has been created");
            return;
        }

        paypalAccount = transactionManager.getCopyInTransaction(accountOutside);

        filePage = new CsvImportWizardPage(window);
        addPage(filePage);
    }

    @Override
    public boolean performFinish() {
        String fileName = filePage.getFileName();
        if (fileName != null) {
            File csvFile = new File(fileName);
            importFile(csvFile);
        }

        return true;
    }

    public void importFile(File file) {

        try {
            CSVReader reader = new CSVReader(new FileReader(file));

            Collection<Row> refunds = new ArrayList<Row>();
            Collection<Row> reversals = new ArrayList<Row>();

            /**
             * Currency of the Paypal account, being the currency in which
             * amounts are to be formatted when put in the memo
             */
            Currency currency = paypalAccount.getCurrency();

            // Pass the header
            reader.readNext();

            Row row = readRow(reader);
            while (row != null) {

                boolean readAlreadyDone = false;

                if (row.type.equals("Shopping Cart Payment Sent")) {
                    /**
                     * Shopping cart entries are split across multiple rows, with a 'Payment Sent' row
                     * following by one or more 'Item' rows.  These must be combined into a single
                     * transaction.  To enable us to do this, this class is used to put each row into,
                     * and it can then output the transaction when a row is found that is in the
                     * next transaction.
                     */

                    List<Row> rowItems = new ArrayList<Row>();

                    Row rowItem = readRow(reader);
                    while (rowItem.type.equals("Shopping Cart Item")) {
                        if (rowItem.shippingAndHandlingAmount != row.shippingAndHandlingAmount) {
                            // TODO report an error
                        }

                        rowItems.add(rowItem);
                        rowItem = readRow(reader);
                    }

                    // Distribute the shipping and handling amount
                    distribute(row.shippingAndHandlingAmount, rowItems);
                    //                 long [] amounts = distribute(row.shippingAndHandlingAmount, rowItems);
                    //                 for (int i = 0; i < rowItems.size(); i++) {
                    //                    rowItems.get(i).shippingAndHandlingAmount = amounts[i];
                    //                 }

                    // Start a new transaction
                    Transaction trans = session.createTransaction();
                    trans.setDate(row.date);

                    PaypalEntry mainEntry = trans.createEntry().getExtension(PaypalEntryInfo.getPropertySet(),
                            true);
                    mainEntry.setAccount(paypalAccount);
                    mainEntry.setAmount(row.grossAmount);
                    mainEntry.setMemo("payment - " + row.payeeName);
                    mainEntry.setMerchantEmail(row.merchantEmail);
                    mainEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(), row.transactionId);

                    for (Row rowItem2 : rowItems) {
                        createCategoryEntry(trans, rowItem2, paypalAccount.getSaleAndPurchaseAccount());
                    }

                    /*
                     * Look for a refunds that match.  Move them into the cart so they can
                     * be processed as part of the same transaction.
                     */
                    if (row.status.equals("Partially Refunded")) {
                        long refundAmount = 0;
                        for (Iterator<Row> iter = refunds.iterator(); iter.hasNext();) {
                            Row refund = iter.next();
                            if (refund.payeeName.equals(row.payeeName)) {
                                /*
                                 * Create the refund entry in the Paypal account
                                 */
                                PaypalEntry refundEntry = trans.createEntry()
                                        .getExtension(PaypalEntryInfo.getPropertySet(), true);
                                refundEntry.setAccount(paypalAccount);
                                refundEntry.setAmount(refund.grossAmount);
                                refundEntry.setMemo("refund - " + refund.payeeName);
                                refundEntry.setValuta(refund.date);
                                refundEntry.setMerchantEmail(refund.merchantEmail);
                                refundEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(),
                                        refund.transactionId);

                                refundAmount += refund.grossAmount;

                                iter.remove();
                            }
                        }

                        // Create a single income entry with the total amount refunded
                        PaypalEntry lineItemEntry = trans.createEntry()
                                .getExtension(PaypalEntryInfo.getPropertySet(), true);
                        lineItemEntry.setAccount(paypalAccount.getSaleAndPurchaseAccount());
                        lineItemEntry.setAmount(-refundAmount);
                        lineItemEntry.setMemo(row.payeeName + " - amount refunded");
                    }

                    row = rowItem;
                    readAlreadyDone = true;
                    assertValid(trans);
                } else if (row.type.equals("Shopping Cart Item")) {
                    throw new UnexpectedDataException(
                            "'Shopping Cart Item' row found but it is not preceeded by a 'Shopping Cart Payment Sent' or 'eBay Payment Sent' row.");
                } else if (row.type.equals("Refund")) {
                    /*
                    * Refunds are combined with the original transaction.
                    * 
                    * Because the input file is in reverse date order, we find
                    * the refund first. We save the refund information in a
                    * collection. Whenever a 'Shopping Cart Payment Sent' or a
                    * 'eBayPaymentSent' or 'Express Checkout Payment Sent' is
                    * found with a status of 'Partially Refunded' or 'Refunded'
                    * and the payee name exactly matches the we add the refund
                    * an another pair of split entries in the same transaction.
                    */
                    refunds.add(row);
                } else if (row.type.equals("Reversal")) {
                    /*
                     * Reversals are processed in a similar way to refunds.  We keep
                     * and list and match them to later entries.
                     */
                    reversals.add(row);
                } else if (row.type.equals("eBay Payment Sent") || row.type.equals("eBay Payment Received")
                        || row.type.equals("Payment Received") || row.type.equals("Payment Sent")
                        || row.type.equals("Web Accept Payment Sent")) {

                    if (row.status.equals("Refunded")) {
                        /*
                         * Find the refund entry.  We create a single transaction with two entries both
                         * in this Paypal account. 
                         */
                        Row match = null;
                        for (Row refund : refunds) {
                            if (refund.payeeName.equals(row.payeeName) && refund.grossAmount == -row.grossAmount) {
                                match = refund;
                                break;
                            }
                        }
                        if (match == null) {
                            throw new UnexpectedDataException(
                                    "An entry was found that says it was refunded, but no matching 'Refund' entry was found.");
                        }
                        refunds.remove(match);

                        createRefundTransaction(row, match);
                    } else if (row.status.equals("Reversed")) {
                        /*
                         * Find the reversal entry.  We don't create anything if an
                         * entry was reversed. 
                         */
                        Row match = null;
                        for (Row reversal : reversals) {
                            if (reversal.payeeName.equals(row.payeeName)
                                    && reversal.grossAmount == -row.grossAmount) {
                                match = reversal;
                                break;
                            }
                        }
                        if (match == null) {
                            throw new UnexpectedDataException(
                                    "An entry was found that says it was reversed, but no matching 'Reversal' entry was found.");
                        }
                        reversals.remove(match);
                    } else {
                        Transaction trans = session.createTransaction();
                        trans.setDate(row.date);

                        PaypalEntry mainEntry = trans.createEntry().getExtension(PaypalEntryInfo.getPropertySet(),
                                true);
                        mainEntry.setAccount(paypalAccount);
                        mainEntry.setAmount(row.netAmount);
                        mainEntry.setMemo("payment - " + row.payeeName);
                        mainEntry.setValuta(row.date);
                        mainEntry.setMerchantEmail(row.merchantEmail);
                        mainEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(),
                                row.transactionId);

                        if (row.status.equals("Partially Refunded")) {
                            /*
                             * Look for a refunds that match.  Put them in this transaction.
                             * If the transaction is not itemized then we reduce the expense entry
                             * by the amount of the refund.  If the transaction is itemized then we
                             * create a separate entry for the total amount refunded.
                             * 
                             * (Though currently we have no cases of itemized transactions here so this
                             * is not supported.  We probably need to merge this with "Shopping Cart Payment Sent"
                             * processing).
                             */

                            long refundAmount = 0;

                            for (Iterator<Row> iter = refunds.iterator(); iter.hasNext();) {
                                Row refund = iter.next();
                                if (refund.payeeName.equals(row.payeeName)) {
                                    /*
                                     * Create the refund entry in the Paypal account
                                     */
                                    PaypalEntry refundEntry = trans.createEntry()
                                            .getExtension(PaypalEntryInfo.getPropertySet(), true);
                                    refundEntry.setAccount(paypalAccount);
                                    refundEntry.setAmount(refund.grossAmount);
                                    refundEntry.setMemo("refund - " + refund.payeeName);
                                    refundEntry.setValuta(refund.date);
                                    refundEntry.setMerchantEmail(refund.merchantEmail);
                                    refundEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(),
                                            refund.transactionId);

                                    refundAmount += refund.grossAmount;

                                    iter.remove();
                                }
                            }

                            if (-row.netAmount - refundAmount == row.shippingAndHandlingAmount) {
                                // All was refunded except s&h, so indicate accordingly in the memo
                                row.memo = row.memo + " (s&h not refunded after return)";
                            } else {
                                // Indicate the original amount paid and refund amount in the memo 
                                row.memo = row.memo + " ($" + currency.format(-row.netAmount) + " less $"
                                        + currency.format(refundAmount) + " refunded)";
                            }

                            // Note that the amounts in the row will be negative, which is
                            // why we add the refund amount when it may seem we should deduct
                            // the refund amount.
                            row.netAmount += refundAmount;
                            row.grossAmount += refundAmount;
                        }

                        if (row.type.equals("eBay Payment Sent")) {
                            /*
                             * Rows have been found where there is a row of type 'eBay Payment Sent' followed
                             * by a row of type 'Shopping Cart Item'.  This seems very strange because 'Shopping
                             * Cart Item' rows normally follow a 'Shopping Cart Payment Sent' row.  Furthermore,
                             * the 'Shopping Cart Item' row does not have any data in it of any use except for
                             * a quantity (in known cases always 1, but presumably this would be some other number
                             * if the quantity were not 1).  The 'eBay Payment Sent' row does not have a quantity
                             * so we use the quantity from the 'Shopping Cart Item' row.
                             */
                            Row thisRow = row;
                            Row nextRow = readRow(reader);
                            if (nextRow.type.equals("Shopping Cart Item")) {
                                thisRow.quantityString = nextRow.quantityString;
                            } else {
                                row = nextRow;
                                readAlreadyDone = true;
                            }

                            createCategoryEntry(trans, thisRow, paypalAccount.getSaleAndPurchaseAccount());
                        } else {
                            if (row.fee != 0) {
                                // For non-sale transfers, treat the Paypal fee as a bank service
                                // charge.  For E-bay sales, absorb in the price or proceeds.

                                if (row.type.equals("Payment Received") || row.type.equals("Payment Sent")) {
                                    if (paypalAccount.getPaypalFeesAccount() == null) {
                                        throw new UnexpectedDataException(
                                                "A Paypal fee has been found in the imported data.  However, no category has been configured in the properties for this Paypal account for such fees.");
                                    }

                                    // Note that fee shows up as a negative amount, and we want
                                    // a positive amount in the category account to be used for the fee.
                                    Entry feeEntry = trans.createEntry();
                                    feeEntry.setAccount(paypalAccount.getPaypalFeesAccount());
                                    feeEntry.setAmount(-row.fee);
                                    feeEntry.setMemo("Paypal");
                                    // Set fee to zero so it does not appear in the memo
                                    row.fee = 0L;
                                    row.netAmount = row.grossAmount;
                                }
                            }

                            if (row.memo.length() == 0) {
                                // Certain transactions don't have memos, so we fill one in
                                if (row.type.equals("Payment Received")) {
                                    row.memo = row.payeeName + " - gross payment";
                                }
                                if (row.type.equals("Payment Sent")) {
                                    row.memo = row.payeeName + " - payment";
                                }
                            }
                            createCategoryEntry(trans, row, paypalAccount.getSaleAndPurchaseAccount());
                        }
                        assertValid(trans);
                    }
                } else if (row.type.equals("Donation Sent")) {
                    if (paypalAccount.getDonationAccount() == null) {
                        throw new UnexpectedDataException(
                                "A donation has been found in the imported data.  However, no category was set for donations.  Please go to the Paypal account properties and select a category to be used for donations.");
                    }

                    // Donations do not have memos set, so the payee name is used as the memo in the
                    // expense category entry.
                    createTransaction(row, "donation sent", paypalAccount.getDonationAccount(), row.payeeName);
                } else if (row.type.equals("Add Funds from a Bank Account")) {
                    if (paypalAccount.getTransferBank() == null) {
                        throw new UnexpectedDataException(
                                "A bank account transfer has been found in the imported data.  However, no bank account has been set in the properties for this Paypal account.");
                    }
                    createTransaction(row, "transfer from bank", paypalAccount.getTransferBank(),
                            "transfer to Paypal");
                } else if (row.type.equals("Update to eCheck Sent")) {
                    // Updates do not involve a financial transaction
                    // so nothing to import.
                } else if (row.type.equals("Update to eCheck Received")) {
                    // Updates do not involve a financial transaction
                    // so nothing to import.
                } else if (row.type.equals("Update to Payment Received")) {
                    // Updates do not involve a financial transaction
                    // so nothing to import.
                } else if (row.type.equals("eCheck Sent")) {
                    if (paypalAccount.getSaleAndPurchaseAccount() == null) {
                        throw new UnexpectedDataException(
                                "An eCheck entry has been found in the imported data.  However, no sale and purchase account has been set in the properties for this Paypal account.");
                    }
                    createTransaction(row, "payment by transfer", paypalAccount.getSaleAndPurchaseAccount(),
                            "transfer from Paypal");
                } else if (row.type.equals("Express Checkout Payment Sent")) {
                    if (paypalAccount.getSaleAndPurchaseAccount() == null) {
                        throw new UnexpectedDataException(
                                "An 'Express Checkout' entry has been found in the imported data.  However, no sale and purchase account has been set in the properties for this Paypal account.");
                    }

                    if (row.status.equals("Refunded")) {
                        /*
                         * Find the refund entry.  We create a single transaction with two entries both
                         * in this Paypal account. 
                         */
                        Row match = null;
                        for (Row refund : refunds) {
                            if (refund.payeeName.equals(row.payeeName) && refund.grossAmount == -row.grossAmount) {
                                match = refund;
                                break;
                            }
                        }
                        if (match == null) {
                            throw new UnexpectedDataException(
                                    "An entry was found that says it was refunded, but no matching 'Refund' entry was found.");
                        }
                        refunds.remove(match);

                        createRefundTransaction(row, match);
                    } else {
                        createTransaction(row, row.payeeName, paypalAccount.getSaleAndPurchaseAccount(),
                                row.payeeName + " - Paypal payment");
                    }
                } else if (row.type.equals("Charge From Credit Card")) {
                    if (paypalAccount.getTransferCreditCard() == null) {
                        throw new UnexpectedDataException(
                                "A credit card charge has been found in the imported data.  However, no credit card account has been set in the properties for this Paypal account.");
                    }
                    createTransaction(row, "payment from credit card", paypalAccount.getTransferCreditCard(),
                            "transfer to Paypal");
                } else if (row.type.equals("Credit to Credit Card")) {
                    if (paypalAccount.getTransferCreditCard() == null) {
                        throw new UnexpectedDataException(
                                "A credit card refund has been found in the imported data.  However, no credit card account has been set in the properties for this Paypal account.");
                    }
                    createTransaction(row, "refund to credit card", paypalAccount.getTransferCreditCard(),
                            "refund from Paypal");
                } else {
                    //                 throw new UnexpectedData("type", type);
                    MessageDialog.openError(PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(),
                            "Unable to read CSV file", "Entry found with unknown type: '" + row.type + "'.");
                }

                if (!readAlreadyDone) {
                    row = readRow(reader);
                }
            }

            /*
             * All entries have been imported and all the properties
             * have been set and should be in a valid state, so we
             * can now commit the imported entries to the datastore.
             */
            transactionManager.commit("Import Paypal " + file.getName());

        } catch (UnexpectedDataException e) {
            MessageDialog.openError(window.getShell(), "Import Failed", e.getLocalizedMessage());
        } catch (IOException e) {
            MessageDialog.openError(window.getShell(), "Import Failed",
                    "A file I/O error occurred. " + e.getLocalizedMessage());
        }
    }

    private void assertValid(Transaction trans) {
        long total = 0;
        for (Entry entry : trans.getEntryCollection()) {
            total += entry.getAmount();
        }
        if (total != 0) {
            System.out.println("unbalanced");
        }
        assert (total == 0);
    }

    /**
     * The gross and net amounts differ only by the fee.  This method will
     * absorb the fee into the proceeds (i.e. the amount shown in the accounts
     * that the item was sold for will be reduced by the fee).  If this is not
     * an item sale but a funds transfer then the fee is not absorbed.  It is
     * accounted for as a separate split entry in the transaction.  In that case
     * the caller will have zeroed out the fee and set the net amount to be the same
     * as the gross amount.
     * 
     * @param trans
     * @param rowItem
     * @param account
     */
    private void createCategoryEntry(Transaction trans, Row row, IncomeExpenseAccount account) {
        PaypalEntry lineItemEntry = trans.createEntry().getExtension(PaypalEntryInfo.getPropertySet(), true);
        // TODO: Support pattern matching
        lineItemEntry.setAccount(account);

        /*
        * Shopping cart items have positive amounts in the 'gross amount' field
        * only, others have negative amounts that are in both the 'gross
        * amount' and the 'net amount' fields. We want to set a positive amount
        * in the category. (Though signs may be opposite if a refund or
        * something).
        */
        if (row.type.equals("Shopping Cart Item")) {
            lineItemEntry.setAmount(row.grossAmount);
        } else {
            lineItemEntry.setAmount(-row.netAmount);
        }

        if (row.itemUrlString.length() != 0) {
            try {
                URL itemUrl = new URL(row.itemUrlString);
                lineItemEntry.setItemUrl(itemUrl);
            } catch (MalformedURLException e) {
                // Leave the URL blank
            }
        }

        StringBuffer adjustmentsBuffer = new StringBuffer();

        Currency currency = paypalAccount.getCurrency();
        String separator = "";
        long baseAmount = lineItemEntry.getAmount();
        String memo = row.memo;

        if (row.quantityString.length() != 0 && !row.quantityString.equals("0")
                && !row.quantityString.equals("1")) {
            memo = memo + " x" + row.quantityString;
        }

        if (row.shippingAndHandlingAmount != 0) {
            adjustmentsBuffer.append("s&h $").append(currency.format(row.shippingAndHandlingAmount))
                    .append(separator);
            separator = ", ";
            baseAmount -= row.shippingAndHandlingAmount;
        }
        if (row.insurance != 0) {
            adjustmentsBuffer.append("insurance $").append(currency.format(row.insurance)).append(separator);
            separator = ", ";
            baseAmount -= row.insurance;
        }
        if (row.salesTax != 0) {
            adjustmentsBuffer.append("tax $").append(currency.format(row.salesTax)).append(separator);
            separator = ", ";
            baseAmount -= row.salesTax;
        }
        if (row.fee != 0) {
            adjustmentsBuffer.append("less Paypal fee $").append(currency.format(row.fee)).append(separator);
            separator = ", ";
            baseAmount -= row.fee;
        }

        if (adjustmentsBuffer.length() == 0) {
            lineItemEntry.setMemo(memo);
        } else {
            lineItemEntry.setMemo(
                    memo + " ($" + currency.format(baseAmount) + " + " + adjustmentsBuffer.toString() + ")");
        }
    }

    /**
     * We distribute the shipping and handling among the items in proportion
     * to the price of each item.  This is the preference of the author.
     * If this is not your preference then please add a preference to the preferences
     * to indicate if a separate line item should instead be created for the
     * shipping and handling and implement it.
     * @throws UnexpectedDataException 
     */
    private void distribute(long toDistribute, List<Row> rowItems) throws UnexpectedDataException {
        long netTotal = 0;
        for (Row rowItem : rowItems) {
            if (rowItem.grossAmount <= 0) {
                throw new UnexpectedDataException("Shopping Cart Item with zero or negative gross amount");
            }
            netTotal += rowItem.grossAmount;
        }

        long leftToDistribute = toDistribute;

        for (Row rowItem : rowItems) {
            long amount = toDistribute * rowItem.grossAmount / netTotal;
            rowItem.shippingAndHandlingAmount = amount;
            leftToDistribute -= amount;
        }

        // We have rounded down, so we may be under.  We now distribute
        // a penny to each to get a balanced transaction.
        for (Row rowItem : rowItems) {
            if (leftToDistribute > 0) {
                rowItem.shippingAndHandlingAmount++;
                leftToDistribute--;
            }
        }

        assert (leftToDistribute == 0);

        /*
         * normally both the gross and net amounts have the s&h included. The
         * itemized rows don't, and they have just the amount as a positive
         * value in the 'gross amount' field (nothing in the 'net amount' field)
         * so to make it consistent we adjust these amounts (which are positive
         * amounts for normal sales) by the s&h amount.
         */
        for (Row rowItem : rowItems) {
            rowItem.grossAmount += rowItem.shippingAndHandlingAmount;
        }
    }

    private Row readRow(CSVReader reader) throws IOException, UnexpectedDataException {
        String[] nextLine = reader.readNext();
        if (nextLine != null && (nextLine.length == 42 || nextLine.length == 43)) {
            Row row = new Row();
            String dateString = nextLine[0];
            row.payeeName = nextLine[3];
            row.type = nextLine[4];
            row.status = nextLine[5];
            row.grossAmount = getAmount(nextLine[6]);
            row.fee = getAmount(nextLine[7]);
            row.netAmount = getAmount(nextLine[8]);

            /*
             * We are not interested in our own e-mail.  The merchant
             * e-mail may be either in the 'from' or the 'to' e-mail address
             * column, depending on the row type.
             */
            if (row.type.equals("Refund") || row.type.equals("Reversal") || row.type.equals("Payment Received")
                    || row.type.equals("eBay Payment Received")) {
                row.merchantEmail = nextLine[9];
            } else {
                row.merchantEmail = nextLine[10];
            }

            row.transactionId = nextLine[11];
            row.memo = nextLine[14];
            row.shippingAndHandlingAmount = getAmount(nextLine[16]);
            row.insurance = getAmount(nextLine[17]);
            row.salesTax = getAmount(nextLine[18]);
            row.itemUrlString = nextLine[25];
            row.quantityString = nextLine[32];
            row.balance = getAmount(nextLine[34]);

            DateFormat df = new SimpleDateFormat("MM/dd/yy");
            try {
                row.date = df.parse(dateString);
            } catch (ParseException e) {
                throw new UnexpectedDataException("Date", dateString);
            }

            return row;
        }

        assert (nextLine.length == 0);
        return null;
    }

    private void createTransaction(Row row, String paypalAccountMemo, Account otherAccount,
            String otherAccountMemo) {
        Transaction trans = session.createTransaction();
        trans.setDate(row.date);

        PaypalEntry mainEntry = trans.createEntry().getExtension(PaypalEntryInfo.getPropertySet(), true);
        mainEntry.setAccount(paypalAccount);
        mainEntry.setAmount(row.grossAmount);
        mainEntry.setMemo(paypalAccountMemo);
        mainEntry.setValuta(row.date);
        mainEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(), row.transactionId);

        Entry otherEntry = trans.createEntry();
        otherEntry.setAccount(otherAccount);
        otherEntry.setAmount(-row.grossAmount);
        otherEntry.setMemo(otherAccountMemo);
    }

    /**
     * This is a helper method that creates a transaction where there are just two entries
     * and both are in the Paypal account.  This occurs when an entry is refunded in full.
     */
    private void createRefundTransaction(Row originalRow, Row refundRow) {
        Transaction trans = session.createTransaction();
        trans.setDate(originalRow.date);

        PaypalEntry mainEntry = trans.createEntry().getExtension(PaypalEntryInfo.getPropertySet(), true);
        mainEntry.setAccount(paypalAccount);
        mainEntry.setAmount(originalRow.grossAmount);
        mainEntry.setMemo(originalRow.payeeName);
        mainEntry.setValuta(originalRow.date);
        mainEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(), originalRow.transactionId);

        Entry refundEntry = trans.createEntry();
        refundEntry.setAccount(paypalAccount);
        refundEntry.setAmount(-originalRow.grossAmount);
        refundEntry.setMemo("refund - " + originalRow.payeeName);
        refundEntry.setValuta(refundRow.date);
        refundEntry.setPropertyValue(ReconciliationEntryInfo.getUniqueIdAccessor(), refundRow.transactionId);
    }

    long getAmount(String amountString) {
        if (amountString.length() == 0) {
            return 0;
        }

        boolean negate = false;
        if (amountString.charAt(0) == '-') {
            amountString = amountString.substring(1);
            negate = true;
        }

        try {
            String parts[] = amountString.replaceAll(",", "").split("\\.");
            long amount = Long.parseLong(parts[0]) * 100;
            if (parts.length > 1) {
                if (parts[1].length() == 1) {
                    parts[1] += "0";
                }
                amount += Long.parseLong(parts[1]);
            }
            return negate ? -amount : amount;
        } catch (NumberFormatException e) {
            return 0;
        }
    }

    public class Row {
        Date date;
        String payeeName;
        String type;
        String status;
        Long grossAmount;
        Long fee;
        Long netAmount;
        String merchantEmail;
        String transactionId;
        String memo;
        long shippingAndHandlingAmount;
        long insurance;
        long salesTax;
        String itemUrlString;
        String quantityString;
        Long balance;
    }
}