Repository: dj-stripe/dj-stripe

Issue:
Add handling for charge.refunded webhook
Implemented Refund model, but `charge.refunded` isn't being handled yet.


Discussion:

Resolving patch:
diff --git a/djstripe/models/base.py b/djstripe/models/base.py
--- a/djstripe/models/base.py
+++ b/djstripe/models/base.py
@@ -516,6 +516,30 @@ def _stripe_object_to_subscription_items(cls, target_cls, data, subscription):
 
 		return subscriptionitems
 
+	@classmethod
+	def _stripe_object_to_refunds(cls, target_cls, data, charge):
+		"""
+		Retrieves Refunds for a charge
+		:param target_cls: The target class to instantiate per invoice item.
+		:type target_cls: ``Refund``
+		:param data: The data dictionary received from the Stripe API.
+		:type data: dict
+		:param charge: The charge object that refunds are for.
+		:type invoice: ``djstripe.models.Refund``
+		:return:
+		"""
+
+		refunds = data.get("refunds")
+		if not refunds:
+			return []
+
+		refund_objs = []
+		for refund_data in refunds.get("data", []):
+			item, _ = target_cls._get_or_create_from_stripe_object(refund_data, refetch=False)
+			refund_objs.append(item)
+
+		return refund_objs
+
 	def _sync(self, record_data):
 		for attr, value in record_data.items():
 			setattr(self, attr, value)
diff --git a/djstripe/models/core.py b/djstripe/models/core.py
--- a/djstripe/models/core.py
+++ b/djstripe/models/core.py
@@ -346,6 +346,11 @@ def _stripe_object_destination_to_account(cls, target_cls, data):
 		if "destination" in data and data["destination"]:
 			return target_cls._get_or_create_from_stripe_object(data, "destination")[0]
 
+	def _attach_objects_post_save_hook(self, cls, data, pending_relations=None):
+		super()._attach_objects_post_save_hook(cls, data, pending_relations=pending_relations)
+
+		cls._stripe_object_to_refunds(target_cls=Refund, data=data, charge=self)
+
 
 class Customer(StripeModel):
 	"""


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -599,6 +599,50 @@ def capture(self):
 	}
 )
 
+FAKE_CHARGE_REFUNDED = deepcopy(FAKE_CHARGE)
+FAKE_CHARGE_REFUNDED = FAKE_CHARGE_REFUNDED.refund(
+	amount=FAKE_CHARGE_REFUNDED["amount"]
+)
+
+FAKE_REFUND = {
+	"id": "re_1E0he8KatMEEd8456454S01Vc",
+	"object": "refund",
+	"amount": FAKE_CHARGE_REFUNDED["amount_refunded"],
+	"balance_transaction": "txn_1E0he8KaGRDEd998TDswMZuN",
+	"charge": FAKE_CHARGE_REFUNDED["id"],
+	"created": 1549425864,
+	"currency": "usd",
+	"metadata": {},
+	"reason": None,
+	"receipt_number": None,
+	"source_transfer_reversal": None,
+	"status": "succeeded",
+	"transfer_reversal": None,
+}
+
+# Balance transaction associated with the refund
+FAKE_BALANCE_TRANSACTION_REFUND = {
+	"id": "txn_1E0he8KaGRDEd998TDswMZuN",
+	"amount": -1 * FAKE_CHARGE_REFUNDED["amount_refunded"],
+	"available_on": 1549425864,
+	"created": 1549425864,
+	"currency": "usd",
+	"description": "REFUND FOR CHARGE (Payment for invoice G432DF1C-0028)",
+	"exchange_rate": None,
+	"fee": 0,
+	"fee_details": [],
+	"net": -1 * FAKE_CHARGE_REFUNDED["amount_refunded"],
+	"object": "balance_transaction",
+	"source": FAKE_REFUND["id"],
+	"status": "available",
+	"type": "refund",
+}
+
+
+FAKE_CHARGE_REFUNDED["refunds"].update(
+	{"total_count": 1, "data": [deepcopy(FAKE_REFUND)]}
+)
+
 
 FAKE_COUPON = {
 	"id": "fake-coupon-1",
diff --git a/tests/test_charge.py b/tests/test_charge.py
--- a/tests/test_charge.py
+++ b/tests/test_charge.py
@@ -12,7 +12,8 @@
 from djstripe.models import Account, Charge, Dispute, PaymentMethod
 
 from . import (
-	FAKE_ACCOUNT, FAKE_CHARGE, FAKE_CUSTOMER, FAKE_FILEUPLOAD, FAKE_INVOICE,
+	FAKE_ACCOUNT, FAKE_BALANCE_TRANSACTION, FAKE_BALANCE_TRANSACTION_REFUND, FAKE_CHARGE,
+	FAKE_CHARGE_REFUNDED, FAKE_CUSTOMER, FAKE_FILEUPLOAD, FAKE_INVOICE, FAKE_REFUND,
 	FAKE_SUBSCRIPTION, FAKE_TRANSFER, AssertStripeFksMixin, default_account
 )
 
@@ -127,6 +128,148 @@ def test_sync_from_stripe_data(
 			},
 		)
 
+	@patch("djstripe.models.Account.get_default_account")
+	@patch("stripe.Charge.retrieve")
+	@patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE))
+	@patch("stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION))
+	def test_sync_from_stripe_data_refunded_on_update(
+		self,
+		subscription_retrieve_mock,
+		invoice_retrieve_mock,
+		charge_retrieve_mock,
+		default_account_mock,
+	):
+		# first sync charge (as per test_sync_from_stripe_data) then sync refunded version,
+		# to hit the update code-path instead of insert
+
+		from djstripe.settings import STRIPE_SECRET_KEY
+
+		default_account_mock.return_value = self.account
+
+		fake_charge_copy = deepcopy(FAKE_CHARGE)
+
+		with patch(
+			"stripe.BalanceTransaction.retrieve", return_value=deepcopy(FAKE_BALANCE_TRANSACTION)
+		):
+			charge = Charge.sync_from_stripe_data(fake_charge_copy)
+
+		self.assertEqual(Decimal("22"), charge.amount)
+		self.assertEqual(True, charge.paid)
+		self.assertEqual(False, charge.refunded)
+		self.assertEqual(True, charge.captured)
+		self.assertEqual(False, charge.disputed)
+
+		self.assertEqual(len(charge.refunds.all()), 0)
+
+		fake_charge_refunded_copy = deepcopy(FAKE_CHARGE_REFUNDED)
+
+		with patch(
+			"stripe.BalanceTransaction.retrieve",
+			return_value=deepcopy(FAKE_BALANCE_TRANSACTION_REFUND),
+		) as balance_transaction_retrieve_mock:
+			charge_refunded = Charge.sync_from_stripe_data(fake_charge_refunded_copy)
+
+		self.assertEqual(charge.id, charge_refunded.id)
+
+		self.assertEqual(Decimal("22"), charge_refunded.amount)
+		self.assertEqual(True, charge_refunded.paid)
+		self.assertEqual(True, charge_refunded.refunded)
+		self.assertEqual(True, charge_refunded.captured)
+		self.assertEqual(False, charge_refunded.disputed)
+		self.assertEqual(
+			"VideoDoc consultation for ivanp0001 berkp0001", charge_refunded.description
+		)
+		self.assertEqual(charge_refunded.amount, charge_refunded.amount_refunded)
+
+		charge_retrieve_mock.assert_not_called()
+		balance_transaction_retrieve_mock.assert_called_once_with(
+			api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION_REFUND["id"]
+		)
+
+		refunds = list(charge_refunded.refunds.all())
+		self.assertEqual(len(refunds), 1)
+
+		refund = refunds[0]
+
+		self.assertEqual(refund.id, FAKE_REFUND["id"])
+
+		self.assertNotEqual(
+			charge_refunded.balance_transaction.id, refund.balance_transaction.id
+		)
+		self.assertEqual(
+			charge_refunded.balance_transaction.id, FAKE_BALANCE_TRANSACTION["id"]
+		)
+		self.assertEqual(refund.balance_transaction.id, FAKE_BALANCE_TRANSACTION_REFUND["id"])
+
+		self.assert_fks(
+			charge_refunded,
+			expected_blank_fks={
+				"djstripe.Account.business_logo",
+				"djstripe.Charge.dispute",
+				"djstripe.Charge.transfer",
+				"djstripe.Customer.coupon",
+				"djstripe.Plan.product",
+			},
+		)
+
+	@patch("djstripe.models.Account.get_default_account")
+	@patch(
+		"stripe.BalanceTransaction.retrieve",
+		return_value=deepcopy(FAKE_BALANCE_TRANSACTION_REFUND),
+	)
+	@patch("stripe.Charge.retrieve")
+	@patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE))
+	@patch("stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION))
+	def test_sync_from_stripe_data_refunded(
+		self,
+		subscription_retrieve_mock,
+		invoice_retrieve_mock,
+		charge_retrieve_mock,
+		balance_transaction_retrieve_mock,
+		default_account_mock,
+	):
+		from djstripe.settings import STRIPE_SECRET_KEY
+
+		default_account_mock.return_value = self.account
+		fake_charge_copy = deepcopy(FAKE_CHARGE_REFUNDED)
+
+		charge = Charge.sync_from_stripe_data(fake_charge_copy)
+
+		self.assertEqual(Decimal("22"), charge.amount)
+		self.assertEqual(True, charge.paid)
+		self.assertEqual(True, charge.refunded)
+		self.assertEqual(True, charge.captured)
+		self.assertEqual(False, charge.disputed)
+		self.assertEqual("VideoDoc consultation for ivanp0001 berkp0001", charge.description)
+		self.assertEqual(charge.amount, charge.amount_refunded)
+
+		charge_retrieve_mock.assert_not_called()
+		balance_transaction_retrieve_mock.assert_called_once_with(
+			api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION_REFUND["id"]
+		)
+
+		refunds = list(charge.refunds.all())
+		self.assertEqual(len(refunds), 1)
+
+		refund = refunds[0]
+
+		self.assertEqual(refund.id, FAKE_REFUND["id"])
+
+		self.assertNotEqual(charge.balance_transaction.id, refund.balance_transaction.id)
+		self.assertEqual(charge.balance_transaction.id, FAKE_BALANCE_TRANSACTION["id"])
+		self.assertEqual(refund.balance_transaction.id, FAKE_BALANCE_TRANSACTION_REFUND["id"])
+
+		self.assert_fks(
+			charge,
+			expected_blank_fks={
+				"djstripe.Account.business_logo",
+				"djstripe.Charge.dispute",
+				"djstripe.Charge.transfer",
+				"djstripe.Customer.coupon",
+				"djstripe.Plan.product",
+			},
+		)
+
 	@patch("stripe.BalanceTransaction.retrieve")
 	@patch("stripe.Charge.retrieve")
 	@patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE))