Repository: ej2/python-quickbooks

Issue:
Add the ability to void all voidable QB types
Currently, the [VoidMixin](https://github.com/ej2/python-quickbooks/blob/master/quickbooks/mixins.py#L127) is built to be able to void Invoices and uses the `operation=void` param. When it comes to voidable types in QB, we have

* Payment
* SalesReceipt
* BillPayment
* Invoice

Here, Invoice is actually the exception, not the rule. For the first three types, you void them with an `operation=update` and `inlcude=void` params. Invoice is the only one that uses the `operation=void` param. 

So currently, the VoidMixin only works for Invoices, and the [Payment type has a special `void` method](https://github.com/ej2/python-quickbooks/blob/master/quickbooks/objects/payment.py#L87) with this one-off functionality, added by [ZedObaia](https://github.com/ZedObaia) to fix his [issue on voiding Payments](https://github.com/ej2/python-quickbooks/issues/247). The other types are not voidable without custom code like

```
def void_receipt(receipt_id: str) -> bool:
    qb = get_client()
    receipt = QBObjects.SalesReceipt()
    sparse_update_data = {
        "Id": receipt_id,
        "SyncToken": 0,
        "sparse": True
    }
    try:
        endpoint = receipt.qbo_object_name.lower()
        url = "{0}/company/{1}/{2}".format(qb.api_url, qb.company_id, endpoint)
        qb.post(url, json.dumps(sparse_update_data), params={"include": "void"})
        return True
    except:
        log.exception(f"Failed to void receipt {receipt_id}")
        return False
```

I would propose then that the VoidMixin be updated to use the `operation=update` and `inlcude=void` params and that the Invoice type should be the only one to have it's own special `void` method with this one-off functionality that uses the `operation=void` param. 

Comments:

Patch:
diff --git a/quickbooks/mixins.py b/quickbooks/mixins.py
index 9b51a42..2083139 100644
--- a/quickbooks/mixins.py
+++ b/quickbooks/mixins.py
@@ -119,6 +119,53 @@ class SendMixin(object):
 
 
 class VoidMixin(object):
+
+    def get_void_params(self):
+        qb_object_params_map = {
+            "Payment": {
+                "operation": "update",
+                "include": "void"
+            },
+            "SalesReceipt": {
+                "operation": "update",
+                "include": "void"
+            },
+            "BillPayment": {
+                "operation": "update",
+                "include": "void"
+            },
+            "Invoice": {
+                "operation": "void",
+            },
+        }
+        # setting the default operation to void (the original behavior)
+        return qb_object_params_map.get(self.qbo_object_name, {"operation": "void"})
+
+    def get_void_data(self):
+        qb_object_params_map = {
+            "Payment": {
+                "Id": self.Id,
+                "SyncToken": self.SyncToken,
+                "sparse": True
+            },
+            "SalesReceipt": {
+                "Id": self.Id,
+                "SyncToken": self.SyncToken,
+                "sparse": True
+            },
+            "BillPayment": {
+                "Id": self.Id,
+                "SyncToken": self.SyncToken,
+                "sparse": True
+            },
+            "Invoice": {
+                "Id": self.Id,
+                "SyncToken": self.SyncToken,
+            },
+        }
+        # setting the default operation to void (the original behavior)
+        return qb_object_params_map.get(self.qbo_object_name, {"operation": "void"})
+
     def void(self, qb=None):
         if not qb:
             qb = QuickBooks()
@@ -126,14 +173,12 @@ class VoidMixin(object):
         if not self.Id:
             raise QuickbooksException('Cannot void unsaved object')
 
-        data = {
-            'Id': self.Id,
-            'SyncToken': self.SyncToken,
-        }
-
         endpoint = self.qbo_object_name.lower()
         url = "{0}/company/{1}/{2}".format(qb.api_url, qb.company_id, endpoint)
-        results = qb.post(url, json.dumps(data), params={'operation': 'void'})
+
+        data = self.get_void_data()
+        params = self.get_void_params()
+        results = qb.post(url, json.dumps(data), params=params)
 
         return results
 
diff --git a/quickbooks/objects/attachable.py b/quickbooks/objects/attachable.py
index e7d23d0..23e7132 100644
--- a/quickbooks/objects/attachable.py
+++ b/quickbooks/objects/attachable.py
@@ -58,7 +58,7 @@ class Attachable(DeleteMixin, QuickbooksManagedObject, QuickbooksTransactionEnti
         else:
             json_data = qb.create_object(self.qbo_object_name, self.to_json(), _file_path=self._FilePath)
 
-        if self.FileName:
+        if self.Id is None and self.FileName:
             obj = type(self).from_json(json_data['AttachableResponse'][0]['Attachable'])
         else:
             obj = type(self).from_json(json_data['Attachable'])
diff --git a/quickbooks/objects/billpayment.py b/quickbooks/objects/billpayment.py
index 3f175d9..64569d9 100644
--- a/quickbooks/objects/billpayment.py
+++ b/quickbooks/objects/billpayment.py
@@ -1,6 +1,6 @@
 from .base import QuickbooksBaseObject, Ref, LinkedTxn, QuickbooksManagedObject, LinkedTxnMixin, \
     QuickbooksTransactionEntity
-from ..mixins import DeleteMixin
+from ..mixins import DeleteMixin, VoidMixin
 
 
 class CheckPayment(QuickbooksBaseObject):
@@ -47,7 +47,7 @@ class BillPaymentLine(QuickbooksBaseObject):
         return str(self.Amount)
 
 
-class BillPayment(DeleteMixin, QuickbooksManagedObject, QuickbooksTransactionEntity, LinkedTxnMixin):
+class BillPayment(DeleteMixin, QuickbooksManagedObject, QuickbooksTransactionEntity, LinkedTxnMixin, VoidMixin):
     """
     QBO definition: A BillPayment entity represents the financial transaction of payment
     of bills that the business owner receives from a vendor for goods or services purchased
diff --git a/quickbooks/objects/payment.py b/quickbooks/objects/payment.py
index 07e1522..75cbf57 100644
--- a/quickbooks/objects/payment.py
+++ b/quickbooks/objects/payment.py
@@ -3,7 +3,7 @@ from .base import QuickbooksBaseObject, Ref, LinkedTxn, \
     LinkedTxnMixin, MetaData
 from ..client import QuickBooks
 from .creditcardpayment import CreditCardPayment
-from ..mixins import DeleteMixin
+from ..mixins import DeleteMixin, VoidMixin
 import json
 
 
@@ -21,7 +21,7 @@ class PaymentLine(QuickbooksBaseObject):
         return str(self.Amount)
 
 
-class Payment(DeleteMixin, QuickbooksManagedObject, QuickbooksTransactionEntity, LinkedTxnMixin):
+class Payment(DeleteMixin, QuickbooksManagedObject, QuickbooksTransactionEntity, LinkedTxnMixin, VoidMixin):
     """
     QBO definition: A Payment entity records a payment in QuickBooks. The payment can be
     applied for a particular customer against multiple Invoices and Credit Memos. It can also
@@ -81,24 +81,5 @@ class Payment(DeleteMixin, QuickbooksManagedObject, QuickbooksTransactionEntity,
         # These fields are for minor version 4
         self.TransactionLocationType = None
 
-    def void(self, qb=None):
-        if not qb:
-            qb = QuickBooks()
-
-        if not self.Id:
-            raise qb.QuickbooksException('Cannot void unsaved object')
-
-        data = {
-            'Id': self.Id,
-            'SyncToken': self.SyncToken,
-            'sparse': True
-        }
-
-        endpoint = self.qbo_object_name.lower()
-        url = "{0}/company/{1}/{2}".format(qb.api_url, qb.company_id, endpoint)
-        results = qb.post(url, json.dumps(data), params={'operation': 'update', 'include': 'void'})
-
-        return results
-
     def __str__(self):
         return str(self.TotalAmt)
diff --git a/quickbooks/objects/salesreceipt.py b/quickbooks/objects/salesreceipt.py
index 83b55a2..0a42925 100644
--- a/quickbooks/objects/salesreceipt.py
+++ b/quickbooks/objects/salesreceipt.py
@@ -2,11 +2,11 @@ from .base import Ref, CustomField, QuickbooksManagedObject, LinkedTxnMixin, Add
     EmailAddress, QuickbooksTransactionEntity, LinkedTxn
 from .tax import TxnTaxDetail
 from .detailline import DetailLine
-from ..mixins import QuickbooksPdfDownloadable, DeleteMixin
+from ..mixins import QuickbooksPdfDownloadable, DeleteMixin, VoidMixin
 
 
 class SalesReceipt(DeleteMixin, QuickbooksPdfDownloadable, QuickbooksManagedObject,
-                   QuickbooksTransactionEntity, LinkedTxnMixin):
+                   QuickbooksTransactionEntity, LinkedTxnMixin, VoidMixin):
     """
     QBO definition: SalesReceipt represents the sales receipt that is given to a customer.
     A sales receipt is similar to an invoice. However, for a sales receipt, payment is received


Test patch:
diff --git a/tests/integration/test_billpayment.py b/tests/integration/test_billpayment.py
index fab990c..c7ce650 100644
--- a/tests/integration/test_billpayment.py
+++ b/tests/integration/test_billpayment.py
@@ -1,5 +1,6 @@
 from datetime import datetime
 
+from quickbooks.objects import AccountBasedExpenseLine, Ref, AccountBasedExpenseLineDetail
 from quickbooks.objects.account import Account
 from quickbooks.objects.bill import Bill
 from quickbooks.objects.billpayment import BillPayment, BillPaymentLine, CheckPayment
@@ -14,12 +15,30 @@ class BillPaymentTest(QuickbooksTestCase):
         self.account_number = datetime.now().strftime('%d%H%M')
         self.name = "Test Account {0}".format(self.account_number)
 
-    def test_create(self):
+    def create_bill(self, amount):
+        bill = Bill()
+        line = AccountBasedExpenseLine()
+        line.Amount = amount
+        line.DetailType = "AccountBasedExpenseLineDetail"
+
+        account_ref = Ref()
+        account_ref.type = "Account"
+        account_ref.value = 1
+        line.AccountBasedExpenseLineDetail = AccountBasedExpenseLineDetail()
+        line.AccountBasedExpenseLineDetail.AccountRef = account_ref
+        bill.Line.append(line)
+
+        vendor = Vendor.all(max_results=1, qb=self.qb_client)[0]
+        bill.VendorRef = vendor.to_ref()
+
+        return bill.save(qb=self.qb_client)
+
+    def create_bill_payment(self, bill, amount, private_note, pay_type):
         bill_payment = BillPayment()
 
-        bill_payment.PayType = "Check"
-        bill_payment.TotalAmt = 200
-        bill_payment.PrivateNote = "Private Note"
+        bill_payment.PayType = pay_type
+        bill_payment.TotalAmt = amount
+        bill_payment.PrivateNote = private_note
 
         vendor = Vendor.all(max_results=1, qb=self.qb_client)[0]
         bill_payment.VendorRef = vendor.to_ref()
@@ -31,14 +50,18 @@ class BillPaymentTest(QuickbooksTestCase):
         ap_account = Account.where("AccountSubType = 'AccountsPayable'", qb=self.qb_client)[0]
         bill_payment.APAccountRef = ap_account.to_ref()
 
-        bill = Bill.all(max_results=1, qb=self.qb_client)[0]
-
         line = BillPaymentLine()
         line.LinkedTxn.append(bill.to_linked_txn())
         line.Amount = 200
 
         bill_payment.Line.append(line)
-        bill_payment.save(qb=self.qb_client)
+        return bill_payment.save(qb=self.qb_client)
+
+    def test_create(self):
+        # create new bill for testing, reusing the same bill will cause Line to be empty
+        # and the new bill payment will be voided automatically
+        bill = self.create_bill(amount=200)
+        bill_payment = self.create_bill_payment(bill, 200, "Private Note", "Check")
 
         query_bill_payment = BillPayment.get(bill_payment.Id, qb=self.qb_client)
 
@@ -48,3 +71,16 @@ class BillPaymentTest(QuickbooksTestCase):
 
         self.assertEqual(len(query_bill_payment.Line), 1)
         self.assertEqual(query_bill_payment.Line[0].Amount, 200.0)
+
+    def test_void(self):
+        bill = self.create_bill(amount=200)
+        bill_payment = self.create_bill_payment(bill, 200, "Private Note", "Check")
+        query_payment = BillPayment.get(bill_payment.Id, qb=self.qb_client)
+        self.assertEqual(query_payment.TotalAmt, 200.0)
+        self.assertNotIn('Voided', query_payment.PrivateNote)
+
+        bill_payment.void(qb=self.qb_client)
+        query_payment = BillPayment.get(bill_payment.Id, qb=self.qb_client)
+
+        self.assertEqual(query_payment.TotalAmt, 0.0)
+        self.assertIn('Voided', query_payment.PrivateNote)
\ No newline at end of file
diff --git a/tests/integration/test_invoice.py b/tests/integration/test_invoice.py
index 4a686a5..c02e40e 100644
--- a/tests/integration/test_invoice.py
+++ b/tests/integration/test_invoice.py
@@ -75,3 +75,14 @@ class InvoiceTest(QuickbooksTestCase):
 
         query_invoice = Invoice.filter(Id=invoice_id, qb=self.qb_client)
         self.assertEqual([], query_invoice)
+
+    def test_void(self):
+        customer = Customer.all(max_results=1, qb=self.qb_client)[0]
+        invoice = self.create_invoice(customer)
+        invoice_id = invoice.Id
+        invoice.void(qb=self.qb_client)
+
+        query_invoice = Invoice.get(invoice_id, qb=self.qb_client)
+        self.assertEqual(query_invoice.Balance, 0.0)
+        self.assertEqual(query_invoice.TotalAmt, 0.0)
+        self.assertIn('Voided', query_invoice.PrivateNote)
diff --git a/tests/integration/test_salesreceipt.py b/tests/integration/test_salesreceipt.py
new file mode 100644
index 0000000..ce3bd2a
--- /dev/null
+++ b/tests/integration/test_salesreceipt.py
@@ -0,0 +1,59 @@
+from datetime import datetime
+
+from quickbooks.objects import SalesReceipt, Customer, \
+    SalesItemLine, SalesItemLineDetail, Item
+from tests.integration.test_base import QuickbooksTestCase
+
+
+class SalesReceiptTest(QuickbooksTestCase):
+    def setUp(self):
+        super(SalesReceiptTest, self).setUp()
+
+        self.account_number = datetime.now().strftime('%d%H%M')
+        self.name = "Test Account {0}".format(self.account_number)
+
+    def create_sales_receipt(self, qty=1, unit_price=100.0):
+        sales_receipt = SalesReceipt()
+        sales_receipt.TotalAmt = qty * unit_price
+        customer = Customer.all(max_results=1, qb=self.qb_client)[0]
+        sales_receipt.CustomerRef = customer.to_ref()
+        item = Item.all(max_results=1, qb=self.qb_client)[0]
+        line = SalesItemLine()
+        sales_item_line_detail = SalesItemLineDetail()
+        sales_item_line_detail.ItemRef = item.to_ref()
+        sales_item_line_detail.Qty = qty
+        sales_item_line_detail.UnitPrice = unit_price
+        today = datetime.now()
+        sales_item_line_detail.ServiceDate = today.strftime(
+            "%Y-%m-%d"
+        )
+        line.SalesItemLineDetail = sales_item_line_detail
+        line.Amount = qty * unit_price
+        sales_receipt.Line = [line]
+
+        return sales_receipt.save(qb=self.qb_client)
+
+    def test_create(self):
+        sales_receipt = self.create_sales_receipt(
+            qty=1,
+            unit_price=100.0
+        )
+        query_sales_receipt = SalesReceipt.get(sales_receipt.Id, qb=self.qb_client)
+
+        self.assertEqual(query_sales_receipt.TotalAmt, 100.0)
+        self.assertEqual(query_sales_receipt.Line[0].Amount, 100.0)
+        self.assertEqual(query_sales_receipt.Line[0].SalesItemLineDetail['Qty'], 1)
+        self.assertEqual(query_sales_receipt.Line[0].SalesItemLineDetail['UnitPrice'], 100.0)
+
+    def test_void(self):
+        sales_receipt = self.create_sales_receipt(
+            qty=1,
+            unit_price=100.0
+        )
+        query_sales_receipt = SalesReceipt.get(sales_receipt.Id, qb=self.qb_client)
+        self.assertEqual(query_sales_receipt.TotalAmt, 100.0)
+        self.assertNotIn('Voided', query_sales_receipt.PrivateNote)
+        sales_receipt.void(qb=self.qb_client)
+        query_sales_receipt = SalesReceipt.get(sales_receipt.Id, qb=self.qb_client)
+        self.assertEqual(query_sales_receipt.TotalAmt, 0.0)
+        self.assertIn('Voided', query_sales_receipt.PrivateNote)
diff --git a/tests/unit/test_mixins.py b/tests/unit/test_mixins.py
index c3ff3ed..017df5d 100644
--- a/tests/unit/test_mixins.py
+++ b/tests/unit/test_mixins.py
@@ -4,7 +4,7 @@ import os
 import unittest
 from urllib.parse import quote
 
-from quickbooks.objects import Bill, Invoice
+from quickbooks.objects import Bill, Invoice, Payment, BillPayment
 
 from tests.integration.test_base import QuickbooksUnitTestCase
 
@@ -381,12 +381,33 @@ class SendMixinTest(QuickbooksUnitTestCase):
 
 class VoidMixinTest(QuickbooksUnitTestCase):
     @patch('quickbooks.mixins.QuickBooks.post')
-    def test_void(self, post):
+    def test_void_invoice(self, post):
         invoice = Invoice()
         invoice.Id = 2
         invoice.void(qb=self.qb_client)
         self.assertTrue(post.called)
 
+    @patch('quickbooks.mixins.QuickBooks.post')
+    def test_void_payment(self, post):
+        payment = Payment()
+        payment.Id = 2
+        payment.void(qb=self.qb_client)
+        self.assertTrue(post.called)
+
+    @patch('quickbooks.mixins.QuickBooks.post')
+    def test_void_sales_receipt(self, post):
+        sales_receipt = SalesReceipt()
+        sales_receipt.Id = 2
+        sales_receipt.void(qb=self.qb_client)
+        self.assertTrue(post.called)
+
+    @patch('quickbooks.mixins.QuickBooks.post')
+    def test_void_bill_payment(self, post):
+        bill_payment = BillPayment()
+        bill_payment.Id = 2
+        bill_payment.void(qb=self.qb_client)
+        self.assertTrue(post.called)
+
     def test_delete_unsaved_exception(self):
         from quickbooks.exceptions import QuickbooksException