feat: charging tcs on sales invoice
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 566734e..0be63a8 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -21,6 +21,7 @@
 from erpnext.accounts.doctype.loyalty_program.loyalty_program import \
 	get_loyalty_program_details_with_points, get_loyalty_details, validate_loyalty_points
 from erpnext.accounts.deferred_revenue import validate_service_stop_date
+from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import get_party_tax_withholding_details
 
 from erpnext.healthcare.utils import manage_invoice_submit_cancel
 
@@ -73,6 +74,8 @@
 
 		if not self.is_pos:
 			self.so_dn_required()
+		
+		self.set_tax_withholding()
 
 		self.validate_proj_cust()
 		self.validate_pos_return()
@@ -151,6 +154,30 @@
 			if cost_center_company != self.company:
 				frappe.throw(_("Row #{0}: Cost Center {1} does not belong to company {2}").format(frappe.bold(item.idx), frappe.bold(item.cost_center), frappe.bold(self.company)))
 
+	def set_tax_withholding(self):
+		tax_withholding_details = get_party_tax_withholding_details(self)
+
+		if not tax_withholding_details:
+			return
+
+		accounts = []
+		for d in self.taxes:
+			if d.account_head == tax_withholding_details.get("account_head"):
+				d.update(tax_withholding_details)
+			accounts.append(d.account_head)
+
+		if not accounts or tax_withholding_details.get("account_head") not in accounts:
+			self.append("taxes", tax_withholding_details)
+
+		to_remove = [d for d in self.taxes
+			if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head")]
+
+		for d in to_remove:
+			self.remove(d)
+
+		# calculate totals again after applying TDS
+		self.calculate_taxes_and_totals()
+
 	def before_save(self):
 		set_account_for_mode_of_payment(self)
 
diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
index 3e0ba9a..36ed6de 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
@@ -104,31 +104,30 @@
 def get_tax_amount(party_type, parties, ref_doc, tax_details, fiscal_year_details, pan_no=None):
 	fiscal_year = fiscal_year_details[0]
 
-	vouchers = get_invoice_vouchers(parties, fiscal_year, ref_doc.company, party_type=party_type) or [""]
+	vouchers = get_invoice_vouchers(parties, fiscal_year, ref_doc.company, party_type=party_type)
 	advance_vouchers = get_advance_vouchers(parties, fiscal_year, ref_doc.company, party_type=party_type)
-	tax_vouchers = vouchers + advance_vouchers
+	taxable_vouchers = vouchers + advance_vouchers
 
 	tax_deducted = 0
-	dr_or_cr = 'credit' if party_type == 'Supplier' else 'debit'
-	if tax_vouchers:
+	if taxable_vouchers:
+		# check if tds / tcs is already charged on taxable vouchers
 		filters = {
-			dr_or_cr: ['>', 0],
-			'account': tax_details.account_head,
+			'is_cancelled': 0,
+			'credit': ['>', 0],
 			'fiscal_year': fiscal_year,
-			'voucher_no': ['in', tax_vouchers],
-			'is_cancelled': 0
+			'account': tax_details.account_head,
+			'voucher_no': ['in', taxable_vouchers],
 		}
-		field = "sum({})".format(dr_or_cr)
+		field = "sum(credit)"
 
 		tax_deducted = frappe.db.get_value('GL Entry', filters, field) or 0.0
 
 	tax_amount = 0
+	posting_date = ref_doc.posting_date
 	if party_type == 'Supplier':
-		net_total = ref_doc.net_total
-		posting_date = ref_doc.posting_date
 		ldc = get_lower_deduction_certificate(fiscal_year, pan_no)
-
 		if tax_deducted:
+			net_total = ref_doc.net_total
 			if ldc:
 				tax_amount = get_tds_amount_from_ldc(ldc, parties, fiscal_year, pan_no, tax_details, posting_date, net_total)
 			else:
@@ -139,6 +138,19 @@
 				fiscal_year_details, vouchers
 			)
 
+	elif party_type == 'Customer':
+		if tax_deducted:
+			grand_total = get_invoice_total_without_tcs(ref_doc, tax_details)
+			# if already tcs is charged, then (net total + gst amount) of invoice is chargeable
+			tax_amount = grand_total * tax_details.rate / 100 if grand_total > 0 else 0
+		else:
+			#  if no tcs has been charged in FY,
+			# then (prev invoices + advances) value crossing the threshold are chargeable
+			tax_amount = get_tcs_amount(
+				parties, ref_doc, tax_details,
+				fiscal_year_details, vouchers, advance_vouchers
+			)
+
 	return tax_amount
 
 def get_invoice_vouchers(parties, fiscal_year, company, party_type='Supplier'):
@@ -154,7 +166,7 @@
 		'is_cancelled': 0
 	}
 
-	return frappe.get_all('GL Entry', filters=filters, distinct=1, pluck="voucher_no")
+	return frappe.get_all('GL Entry', filters=filters, distinct=1, pluck="voucher_no") or [""]
 
 def get_advance_vouchers(parties, fiscal_year=None, company=None, from_date=None, to_date=None, party_type='Supplier'):
 	# for advance vouchers, debit and credit is reversed
@@ -162,10 +174,11 @@
 
 	filters = {
 		dr_or_cr: ['>', 0],
+		'is_opening': 'No',
+		'is_cancelled': 0,
 		'party_type': party_type,
 		'party': ['in', parties],
-		'is_opening': 'No',
-		'is_cancelled': 0
+		'against_voucher': ['is', 'not set']
 	}
 
 	if fiscal_year:
@@ -175,7 +188,7 @@
 	if from_date and to_date:
 		filters['posting_date'] = ['between', (from_date, to_date)]
 
-	return frappe.get_all('GL Entry', filters=filters, distinct=1, pluck='voucher_no')
+	return frappe.get_all('GL Entry', filters=filters, distinct=1, pluck='voucher_no') or [""]
 
 def get_tds_amount(ldc, parties, ref_doc, tax_details, fiscal_year_details, vouchers):
 	tds_amount = 0
@@ -210,6 +223,53 @@
 
 	return tds_amount
 
+def get_tcs_amount(parties, ref_doc, tax_details, fiscal_year_details, vouchers, adv_vouchers):
+	tcs_amount = 0
+	fiscal_year, _, _ = fiscal_year_details
+
+	# sum of debit entries made from sales invoices
+	invoiced_amt = frappe.db.get_value('GL Entry', {
+		'is_cancelled': 0,
+		'party': ['in', parties],
+		'company': ref_doc.company,
+		'voucher_no': ['in', vouchers],
+	}, 'sum(debit)') or 0.0
+
+	# sum of credit entries made from PE / JV with unset 'against voucher'
+	advance_amt = frappe.db.get_value('GL Entry', {
+		'is_cancelled': 0,
+		'party': ['in', parties],
+		'company': ref_doc.company,
+		'voucher_no': ['in', adv_vouchers],
+	}, 'sum(credit)') or 0.0
+
+	# sum of credit entries made from sales invoice
+	credit_note_amt = frappe.db.get_value('GL Entry', {
+		'is_cancelled': 0,
+		'credit': ['>', 0],
+		'party': ['in', parties],
+		'fiscal_year': fiscal_year,
+		'company': ref_doc.company,
+		'voucher_type': 'Sales Invoice',
+	}, 'sum(credit)') or 0.0
+
+	current_invoice_total = get_invoice_total_without_tcs(ref_doc, tax_details)
+	chargeable_amt = current_invoice_total + invoiced_amt + advance_amt - credit_note_amt
+
+	threshold = tax_details.get('threshold', 0)
+	cumulative_threshold = tax_details.get('cumulative_threshold', 0)
+
+	if ((threshold and chargeable_amt >= threshold) or (cumulative_threshold and chargeable_amt >= cumulative_threshold)):
+		tcs_amount = chargeable_amt * tax_details.rate / 100 if chargeable_amt > 0 else 0
+
+	return tcs_amount
+
+def get_invoice_total_without_tcs(ref_doc, tax_details):
+	tcs_tax_row = [d for d in ref_doc.taxes if d.account_head == tax_details.account_head]
+	tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0
+
+	return ref_doc.grand_total - tcs_tax_row_amount
+
 def get_tds_amount_from_ldc(ldc, parties, fiscal_year, pan_no, tax_details, posting_date, net_total):
 	tds_amount = 0
 	limit_consumed = frappe.db.get_value('Purchase Invoice', {
diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
index ef77674..c8bd083 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
@@ -9,7 +9,7 @@
 from erpnext.accounts.utils import get_fiscal_year
 from erpnext.buying.doctype.supplier.test_supplier import create_supplier
 
-test_dependencies = ["Supplier Group"]
+test_dependencies = ["Supplier Group", "Customer Group"]
 
 class TestTaxWithholdingCategory(unittest.TestCase):
 	@classmethod
@@ -128,9 +128,42 @@
 		for d in invoices:
 			d.cancel()
 
+	def test_cumulative_threshold_tcs(self):
+		frappe.db.set_value("Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS")
+		invoices = []
+
+		# create invoices for lower than single threshold tax rate
+		for _ in range(2):
+			si = create_sales_invoice(customer = "Test TCS Customer")
+			si.submit()
+			invoices.append(si)
+
+		# create another invoice whose total when added to previously created invoice,
+		# surpasses cumulative threshhold
+		si = create_sales_invoice(customer = "Test TCS Customer")
+		si.submit()
+
+		# assert tax collection on total invoice amount created until now
+		tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == 'TCS - _TC'])
+		self.assertEqual(tcs_charged, 3000)
+		self.assertEqual(si.grand_total, 13000)
+		invoices.append(si)
+
+		# TCS is already collected once, so going forward system will collect TCS on every invoice
+		si = create_sales_invoice(customer = "Test TCS Customer", rate=5000)
+		si.submit()
+
+		tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == 'TCS - _TC'])
+		self.assertEqual(tcs_charged, 500)
+		invoices.append(si)
+
+		#delete invoices to avoid clashing
+		for d in invoices:
+			d.cancel()
+
 def create_purchase_invoice(**args):
 	# return sales invoice doc object
-	item = frappe.get_doc('Item', {'item_name': 'TDS Item'})
+	item = frappe.db.get_value('Item', {'item_name': 'TDS Item'}, "name")
 
 	args = frappe._dict(args)
 	pi = frappe.get_doc({
@@ -145,7 +178,7 @@
 		"taxes": [],
 		"items": [{
 			'doctype': 'Purchase Invoice Item',
-			'item_code': item.name,
+			'item_code': item,
 			'qty': args.qty or 1,
 			'rate': args.rate or 10000,
 			'cost_center': 'Main - _TC',
@@ -156,6 +189,33 @@
 	pi.save()
 	return pi
 
+def create_sales_invoice(**args):
+	# return sales invoice doc object
+	item = frappe.db.get_value('Item', {'item_name': 'TCS Item'}, "name")
+
+	args = frappe._dict(args)
+	si = frappe.get_doc({
+		"doctype": "Sales Invoice",
+		"posting_date": today(),
+		"customer": args.customer,
+		"company": '_Test Company',
+		"taxes_and_charges": "",
+		"currency": "INR",
+		"debit_to": "Debtors - _TC",
+		"taxes": [],
+		"items": [{
+			'doctype': 'Sales Invoice Item',
+			'item_code': item,
+			'qty': args.qty or 1,
+			'rate': args.rate or 10000,
+			'cost_center': 'Main - _TC',
+			'expense_account': 'Cost of Goods Sold - _TC'
+		}]
+	})
+
+	si.save()
+	return si
+
 def create_records():
 	# create a new suppliers
 	for name in ['Test TDS Supplier', 'Test TDS Supplier1', 'Test TDS Supplier2']:
@@ -168,7 +228,17 @@
 			"doctype": "Supplier",
 		}).insert()
 
-	# create an item
+	for name in ['Test TCS Customer']:
+		if frappe.db.exists('Customer', name):
+			continue
+
+		frappe.get_doc({
+			"customer_group": "_Test Customer Group",
+			"customer_name": name,
+			"doctype": "Customer"
+		}).insert()
+
+	# create item
 	if not frappe.db.exists('Item', "TDS Item"):
 		frappe.get_doc({
 			"doctype": "Item",
@@ -178,7 +248,16 @@
 			"is_stock_item": 0,
 		}).insert()
 
-	# create an account
+	if not frappe.db.exists('Item', "TCS Item"):
+		frappe.get_doc({
+			"doctype": "Item",
+			"item_code": "TCS Item",
+			"item_name": "TCS Item",
+			"item_group": "All Item Groups",
+			"is_stock_item": 1
+		}).insert()
+
+	# create tds account
 	if not frappe.db.exists("Account", "TDS - _TC"):
 		frappe.get_doc({
 			'doctype': 'Account',
@@ -189,6 +268,17 @@
 			'root_type': 'Asset'
 		}).insert()
 
+	# create tcs account
+	if not frappe.db.exists("Account", "TCS - _TC"):
+		frappe.get_doc({
+			'doctype': 'Account',
+			'company': '_Test Company',
+			'account_name': 'TCS',
+			'parent_account': 'Duties and Taxes - _TC',
+			'report_type': 'Balance Sheet',
+			'root_type': 'Liability'
+		}).insert()
+
 def create_tax_with_holding_category():
 	fiscal_year = get_fiscal_year(today(), company="_Test Company")[0]
 
@@ -210,6 +300,23 @@
 			}]
 		}).insert()
 
+	if not frappe.db.exists("Tax Withholding Category", "Cumulative Threshold TCS"):
+		frappe.get_doc({
+			"doctype": "Tax Withholding Category",
+			"name": "Cumulative Threshold TCS",
+			"category_name": "10% TCS",
+			"rates": [{
+				'fiscal_year': fiscal_year,
+				'tax_withholding_rate': 10,
+				'single_threshold': 0,
+				'cumulative_threshold': 30000.00
+			}],
+			"accounts": [{
+				'company': '_Test Company',
+				'account': 'TCS - _TC'
+			}]
+		}).insert()
+
 	# Single thresold
 	if not frappe.db.exists("Tax Withholding Category", "Single Threshold TDS"):
 		frappe.get_doc({