From ca0bf7ac29cb8e72ed447144b04e3046aa9de2f3 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 17 Mar 2026 17:04:38 +0100 Subject: [PATCH 1/8] Expose validation errors on LineItem and Transaction schemas (PIP-304) Invoice already has an `errors` field, but LineItem and Transaction did not. Add `errors = fields.Dict(allow_none=True)` to both schemas so validation errors returned by the API are accessible on nested objects. Co-Authored-By: Claude Opus 4.6 --- chartmogul/api/invoice.py | 1 + chartmogul/api/transaction.py | 1 + test/api/test_invoice.py | 52 +++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/chartmogul/api/invoice.py b/chartmogul/api/invoice.py index c2b20b4..99401d0 100644 --- a/chartmogul/api/invoice.py +++ b/chartmogul/api/invoice.py @@ -27,6 +27,7 @@ class _Schema(Schema): account_code = fields.String(allow_none=True) description = fields.String(allow_none=True) event_order = fields.Int(allow_none=True) + errors = fields.Dict(allow_none=True) @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/transaction.py b/chartmogul/api/transaction.py index 2720606..181c846 100644 --- a/chartmogul/api/transaction.py +++ b/chartmogul/api/transaction.py @@ -17,6 +17,7 @@ class _Schema(Schema): result = fields.String() amount_in_cents = fields.Int(allow_none=True) transaction_fees_in_cents = fields.Int(allow_none=True) + errors = fields.Dict(allow_none=True) @post_load def make(self, data, **kwargs): diff --git a/test/api/test_invoice.py b/test/api/test_invoice.py index 74f74e9..1f35855 100644 --- a/test/api/test_invoice.py +++ b/test/api/test_invoice.py @@ -621,3 +621,55 @@ def test_all_invoices_with_all_params(self, mock_requests): self.assertEqual(qs["with_disabled"], ["true"]) self.assertTrue(isinstance(result, Invoice._many)) self.assertEqual(len(result.invoices), 1) + + @requests_mock.mock() + def test_line_item_and_transaction_errors(self, mock_requests): + responseWithErrors = { + "uuid": "inv_test", + "external_id": "INV0001", + "date": "2015-11-01T00:00:00.000Z", + "due_date": "2015-11-15T00:00:00.000Z", + "currency": "USD", + "line_items": [ + { + "uuid": "li_test", + "external_id": None, + "type": "subscription", + "prorated": False, + "amount_in_cents": 5000, + "quantity": 1, + "discount_amount_in_cents": 0, + "tax_amount_in_cents": 0, + "transaction_fees_in_cents": 0, + "errors": {"amount_in_cents": ["must be positive"]}, + }, + ], + "transactions": [ + { + "uuid": "tr_test", + "external_id": None, + "type": "payment", + "date": "2015-11-05T00:04:03.000Z", + "result": "successful", + "errors": {"date": ["is in the future"]}, + }, + ], + } + + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/invoices/inv_test", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=responseWithErrors, + ) + + config = Config("token") + result = Invoice.retrieve(config, uuid="inv_test").get() + + self.assertTrue(isinstance(result, Invoice)) + self.assertIsNotNone(result.line_items[0].errors) + self.assertEqual(result.line_items[0].errors["amount_in_cents"], ["must be positive"]) + self.assertIsNotNone(result.transactions[0].errors) + self.assertEqual(result.transactions[0].errors["date"], ["is in the future"]) From 6daf5950b9f5867d7dabd73a4977866905dbf2fb Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 17 Mar 2026 17:05:54 +0100 Subject: [PATCH 2/8] Add account ID and include query param support (PIP-120, PIP-76) Add `id` field to Account schema so the account identifier is accessible. Add `churn_recognition` and `churn_when_zero_mrr` fields to support the optional `include` query parameter on the /account endpoint. Co-Authored-By: Claude Opus 4.6 --- chartmogul/api/account.py | 3 ++ test/api/test_account.py | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/chartmogul/api/account.py b/chartmogul/api/account.py index 97d24bf..20120cc 100644 --- a/chartmogul/api/account.py +++ b/chartmogul/api/account.py @@ -10,10 +10,13 @@ class Account(Resource): _path = "/account" class _Schema(Schema): + id = fields.String(allow_none=True) name = fields.String() currency = fields.String() time_zone = fields.String() week_start_on = fields.String() + churn_recognition = fields.String(allow_none=True) + churn_when_zero_mrr = fields.String(allow_none=True) @post_load def make(self, data, **kwargs): diff --git a/test/api/test_account.py b/test/api/test_account.py index 1f68608..00844e7 100644 --- a/test/api/test_account.py +++ b/test/api/test_account.py @@ -12,6 +12,24 @@ "week_start_on": "sunday", } +jsonResponseWithId = { + "id": "acct_a1b2c3d4", + "name": "Example Test Company", + "currency": "EUR", + "time_zone": "Europe/Berlin", + "week_start_on": "sunday", +} + +jsonResponseWithInclude = { + "id": "acct_a1b2c3d4", + "name": "Example Test Company", + "currency": "EUR", + "time_zone": "Europe/Berlin", + "week_start_on": "sunday", + "churn_recognition": "immediate", + "churn_when_zero_mrr": "ignore", +} + class AccountTestCase(unittest.TestCase): """ @@ -35,3 +53,43 @@ def test_retrieve(self, mock_requests): self.assertEqual(account.currency, "EUR") self.assertEqual(account.time_zone, "Europe/Berlin") self.assertEqual(account.week_start_on, "sunday") + + @requests_mock.mock() + def test_retrieve_with_id(self, mock_requests): + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/account", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=jsonResponseWithId, + ) + + config = Config("token") + account = Account.retrieve(config).get() + self.assertTrue(isinstance(account, Account)) + self.assertEqual(account.id, "acct_a1b2c3d4") + + @requests_mock.mock() + def test_retrieve_with_include(self, mock_requests): + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/account?include=churn_recognition,churn_when_zero_mrr", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=jsonResponseWithInclude, + ) + + config = Config("token") + account = Account.retrieve( + config, + include="churn_recognition,churn_when_zero_mrr" + ).get() + self.assertTrue(isinstance(account, Account)) + self.assertEqual(account.id, "acct_a1b2c3d4") + self.assertEqual(account.churn_recognition, "immediate") + self.assertEqual(account.churn_when_zero_mrr, "ignore") + self.assertEqual( + mock_requests.last_request.qs, + {"include": ["churn_recognition,churn_when_zero_mrr"]}, + ) From 383f43a73f3f4f7a219e83f010b32b48941bf23f Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 17 Mar 2026 17:06:44 +0100 Subject: [PATCH 3/8] Add Invoice update-status and disable endpoint support Add Invoice.update_status (PATCH /invoices/{uuid}) for updating invoice status and Invoice.disable (PATCH /invoices/{uuid}/disable) for disabling invoices. Co-Authored-By: Claude Opus 4.6 --- chartmogul/api/invoice.py | 2 ++ test/api/test_invoice.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/chartmogul/api/invoice.py b/chartmogul/api/invoice.py index 99401d0..5173f80 100644 --- a/chartmogul/api/invoice.py +++ b/chartmogul/api/invoice.py @@ -98,3 +98,5 @@ def all(cls, config, **kwargs): "/data_sources{/data_source_uuid}/customers{/customer_uuid}/invoices", ) Invoice.retrieve = Invoice._method("retrieve", "get", "/invoices{/uuid}") +Invoice.update_status = Invoice._method("modify", "patch", "/invoices{/uuid}") +Invoice.disable = Invoice._method("patch", "patch", "/invoices{/uuid}/disable") diff --git a/test/api/test_invoice.py b/test/api/test_invoice.py index 1f35855..b8762ad 100644 --- a/test/api/test_invoice.py +++ b/test/api/test_invoice.py @@ -673,3 +673,52 @@ def test_line_item_and_transaction_errors(self, mock_requests): self.assertEqual(result.line_items[0].errors["amount_in_cents"], ["must be positive"]) self.assertIsNotNone(result.transactions[0].errors) self.assertEqual(result.transactions[0].errors["date"], ["is in the future"]) + + @requests_mock.mock() + def test_update_status(self, mock_requests): + updatedInvoice = dict(retrieveInvoiceExample) + updatedInvoice["disabled"] = False + + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/invoices/inv_22910fc6-c931-48e7-ac12-90d2cb5f0059", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=updatedInvoice, + ) + + config = Config("token") + result = Invoice.update_status( + config, + uuid="inv_22910fc6-c931-48e7-ac12-90d2cb5f0059", + data={"disabled": False} + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertTrue(isinstance(result, Invoice)) + self.assertEqual(result.uuid, "inv_22910fc6-c931-48e7-ac12-90d2cb5f0059") + + @requests_mock.mock() + def test_disable(self, mock_requests): + disabledInvoice = dict(retrieveInvoiceExample) + disabledInvoice["disabled"] = True + + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/invoices/inv_22910fc6-c931-48e7-ac12-90d2cb5f0059/disable", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=disabledInvoice, + ) + + config = Config("token") + result = Invoice.disable( + config, + uuid="inv_22910fc6-c931-48e7-ac12-90d2cb5f0059", + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertTrue(isinstance(result, Invoice)) + self.assertTrue(result.disabled) From 0b7a46797ed5afdc22f932839a7c08026240905d Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 17 Mar 2026 17:07:37 +0100 Subject: [PATCH 4/8] Add flat-param interface and disable/enable for SubscriptionEvent Add SubscriptionEvent.destroy() and .modify() that accept flat params (e.g. data={"id": 123}) and auto-wrap in the subscription_event envelope. The old _with_params methods are preserved for backwards compatibility. Add SubscriptionEvent.disable() and .enable() convenience methods for toggling the disabled state of subscription events. Co-Authored-By: Claude Opus 4.6 --- chartmogul/api/subscription_event.py | 44 ++++++++++++++++ test/api/test_subscription_event.py | 79 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/chartmogul/api/subscription_event.py b/chartmogul/api/subscription_event.py index f0f788d..c3f694b 100644 --- a/chartmogul/api/subscription_event.py +++ b/chartmogul/api/subscription_event.py @@ -46,3 +46,47 @@ def make(self, data, **kwargs): SubscriptionEvent.modify_with_params = SubscriptionEvent._method( "modify_with_params", "patch", "/subscription_events" ) + + +@classmethod +def _destroy(cls, config, **kwargs): + """Accept flat params and wrap in subscription_event envelope for the API.""" + data = kwargs.get("data", {}) + if "subscription_event" not in data: + data = {"subscription_event": data} + return cls.destroy_with_params(config, data=data) + + +@classmethod +def _modify(cls, config, **kwargs): + """Accept flat params and wrap in subscription_event envelope for the API.""" + data = kwargs.get("data", {}) + if "subscription_event" not in data: + data = {"subscription_event": data} + return cls.modify_with_params(config, data=data) + + +@classmethod +def _disable(cls, config, **kwargs): + """Disable a subscription event by setting disabled to true.""" + data = kwargs.get("data", {}) + data["disabled"] = True + if "subscription_event" not in data: + data = {"subscription_event": data} + return cls.modify_with_params(config, data=data) + + +@classmethod +def _enable(cls, config, **kwargs): + """Enable a subscription event by setting disabled to false.""" + data = kwargs.get("data", {}) + data["disabled"] = False + if "subscription_event" not in data: + data = {"subscription_event": data} + return cls.modify_with_params(config, data=data) + + +SubscriptionEvent.destroy = _destroy +SubscriptionEvent.modify = _modify +SubscriptionEvent.disable = _disable +SubscriptionEvent.enable = _enable diff --git a/test/api/test_subscription_event.py b/test/api/test_subscription_event.py index 0cf5da9..5eb839a 100644 --- a/test/api/test_subscription_event.py +++ b/test/api/test_subscription_event.py @@ -224,6 +224,85 @@ def test_all_subscription_events(self, mock_requests): ) self.assertTrue(isinstance(subscription_events.subscription_events[0], SubscriptionEvent)) + @requests_mock.mock() + def test_destroy_flat_params(self, mock_requests): + mock_requests.register_uri( + "DELETE", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=204, + ) + + config = Config("token") + result = SubscriptionEvent.destroy(config, data={"id": 7654321}).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.json(), + {"subscription_event": {"id": 7654321}}, + ) + self.assertTrue(result is None) + + @requests_mock.mock() + def test_modify_flat_params(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.modify( + config, data={"id": 7654321, "amount_in_cents": 10} + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.json(), + {"subscription_event": {"id": 7654321, "amount_in_cents": 10}}, + ) + self.assertTrue(isinstance(sub_ev, SubscriptionEvent)) + self.assertEqual(sub_ev.id, 7654321) + + @requests_mock.mock() + def test_disable_subscription_event(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.disable(config, data={"id": 7654321}).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + body = mock_requests.last_request.json() + self.assertEqual(body["subscription_event"]["id"], 7654321) + self.assertTrue(body["subscription_event"]["disabled"]) + self.assertTrue(isinstance(sub_ev, SubscriptionEvent)) + + @requests_mock.mock() + def test_enable_subscription_event(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.enable(config, data={"id": 7654321}).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + body = mock_requests.last_request.json() + self.assertEqual(body["subscription_event"]["id"], 7654321) + self.assertFalse(body["subscription_event"]["disabled"]) + @requests_mock.mock() def test_all_subscription_events_with_filters(self, mock_requests): mock_requests.register_uri( From c44557770b2018836170f06965b82020efaf78ec Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 17 Mar 2026 17:10:19 +0100 Subject: [PATCH 5/8] Add missing test coverage for recent SDK changes Add edge case, error path, and backwards-compatibility tests: Invoice: - Verify update_status request body is sent correctly - 404 error paths for update_status and disable - Verify disable sends no request body - LineItem/Transaction errors=None and errors-absent cases SubscriptionEvent: - Flat destroy/modify with external_id+data_source_uuid - Passthrough when caller already wraps in envelope (no double-wrap) - disable/enable with external_id+data_source_uuid identification Account: - Graceful handling when id field absent from response - Single include param Co-Authored-By: Claude Opus 4.6 --- test/api/test_account.py | 49 ++++++++ test/api/test_invoice.py | 185 ++++++++++++++++++++++++++++ test/api/test_subscription_event.py | 160 ++++++++++++++++++++++++ 3 files changed, 394 insertions(+) diff --git a/test/api/test_account.py b/test/api/test_account.py index 00844e7..9b29dd7 100644 --- a/test/api/test_account.py +++ b/test/api/test_account.py @@ -93,3 +93,52 @@ def test_retrieve_with_include(self, mock_requests): mock_requests.last_request.qs, {"include": ["churn_recognition,churn_when_zero_mrr"]}, ) + + @requests_mock.mock() + def test_retrieve_without_id_field(self, mock_requests): + """Old API responses without id field should not break deserialization.""" + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/account", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=jsonResponse, + ) + + config = Config("token") + account = Account.retrieve(config).get() + self.assertTrue(isinstance(account, Account)) + self.assertFalse(hasattr(account, "id")) + + @requests_mock.mock() + def test_retrieve_with_single_include(self, mock_requests): + singleIncludeResponse = { + "id": "acct_a1b2c3d4", + "name": "Example Test Company", + "currency": "EUR", + "time_zone": "Europe/Berlin", + "week_start_on": "sunday", + "churn_recognition": "immediate", + } + + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/account?include=churn_recognition", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=singleIncludeResponse, + ) + + config = Config("token") + account = Account.retrieve( + config, + include="churn_recognition" + ).get() + self.assertTrue(isinstance(account, Account)) + self.assertEqual(account.churn_recognition, "immediate") + self.assertFalse(hasattr(account, "churn_when_zero_mrr")) + self.assertEqual( + mock_requests.last_request.qs, + {"include": ["churn_recognition"]}, + ) diff --git a/test/api/test_invoice.py b/test/api/test_invoice.py index b8762ad..05a022f 100644 --- a/test/api/test_invoice.py +++ b/test/api/test_invoice.py @@ -722,3 +722,188 @@ def test_disable(self, mock_requests): self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertTrue(isinstance(result, Invoice)) self.assertTrue(result.disabled) + + @requests_mock.mock() + def test_update_status_verifies_request_body(self, mock_requests): + updatedInvoice = dict(retrieveInvoiceExample) + updatedInvoice["disabled"] = False + + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/invoices/inv_22910fc6-c931-48e7-ac12-90d2cb5f0059", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=updatedInvoice, + ) + + config = Config("token") + Invoice.update_status( + config, + uuid="inv_22910fc6-c931-48e7-ac12-90d2cb5f0059", + data={"disabled": False} + ).get() + + self.assertEqual( + mock_requests.last_request.json(), + {"disabled": False}, + ) + + @requests_mock.mock() + def test_update_status_not_found(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/invoices/inv_nonexistent", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=404, + json={"error": "Invoice not found"}, + ) + + config = Config("token") + with self.assertRaises(APIError): + Invoice.update_status( + config, + uuid="inv_nonexistent", + data={"disabled": False} + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + + @requests_mock.mock() + def test_disable_no_request_body(self, mock_requests): + disabledInvoice = dict(retrieveInvoiceExample) + disabledInvoice["disabled"] = True + + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/invoices/inv_22910fc6-c931-48e7-ac12-90d2cb5f0059/disable", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=disabledInvoice, + ) + + config = Config("token") + Invoice.disable( + config, + uuid="inv_22910fc6-c931-48e7-ac12-90d2cb5f0059", + ).get() + + self.assertIsNone(mock_requests.last_request.body) + + @requests_mock.mock() + def test_disable_not_found(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/invoices/inv_nonexistent/disable", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=404, + json={"error": "Invoice not found"}, + ) + + config = Config("token") + with self.assertRaises(APIError): + Invoice.disable(config, uuid="inv_nonexistent").get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + + @requests_mock.mock() + def test_line_item_errors_none(self, mock_requests): + responseWithNoneErrors = { + "uuid": "inv_test", + "external_id": "INV0001", + "date": "2015-11-01T00:00:00.000Z", + "due_date": "2015-11-15T00:00:00.000Z", + "currency": "USD", + "line_items": [ + { + "uuid": "li_test", + "external_id": None, + "type": "subscription", + "prorated": False, + "amount_in_cents": 5000, + "quantity": 1, + "discount_amount_in_cents": 0, + "tax_amount_in_cents": 0, + "transaction_fees_in_cents": 0, + "errors": None, + }, + ], + "transactions": [ + { + "uuid": "tr_test", + "external_id": None, + "type": "payment", + "date": "2015-11-05T00:04:03.000Z", + "result": "successful", + "errors": None, + }, + ], + } + + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/invoices/inv_test", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=responseWithNoneErrors, + ) + + config = Config("token") + result = Invoice.retrieve(config, uuid="inv_test").get() + + self.assertTrue(isinstance(result, Invoice)) + self.assertIsNone(result.line_items[0].errors) + self.assertIsNone(result.transactions[0].errors) + + @requests_mock.mock() + def test_line_item_errors_absent(self, mock_requests): + responseNoErrors = { + "uuid": "inv_test", + "external_id": "INV0001", + "date": "2015-11-01T00:00:00.000Z", + "due_date": "2015-11-15T00:00:00.000Z", + "currency": "USD", + "line_items": [ + { + "uuid": "li_test", + "external_id": None, + "type": "subscription", + "prorated": False, + "amount_in_cents": 5000, + "quantity": 1, + "discount_amount_in_cents": 0, + "tax_amount_in_cents": 0, + "transaction_fees_in_cents": 0, + }, + ], + "transactions": [ + { + "uuid": "tr_test", + "external_id": None, + "type": "payment", + "date": "2015-11-05T00:04:03.000Z", + "result": "successful", + }, + ], + } + + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/invoices/inv_test_no_errors", + request_headers={"Authorization": "Basic dG9rZW46"}, + headers={"Content-Type": "application/json"}, + status_code=200, + json=responseNoErrors, + ) + + config = Config("token") + result = Invoice.retrieve(config, uuid="inv_test_no_errors").get() + + self.assertTrue(isinstance(result, Invoice)) + # When errors field is absent from response, the attribute should not be set + self.assertFalse(hasattr(result.line_items[0], "errors")) + self.assertFalse(hasattr(result.transactions[0], "errors")) diff --git a/test/api/test_subscription_event.py b/test/api/test_subscription_event.py index 5eb839a..6e91fe7 100644 --- a/test/api/test_subscription_event.py +++ b/test/api/test_subscription_event.py @@ -303,6 +303,166 @@ def test_enable_subscription_event(self, mock_requests): self.assertEqual(body["subscription_event"]["id"], 7654321) self.assertFalse(body["subscription_event"]["disabled"]) + @requests_mock.mock() + def test_destroy_flat_with_external_id_and_ds_uuid(self, mock_requests): + mock_requests.register_uri( + "DELETE", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=204, + ) + + config = Config("token") + result = SubscriptionEvent.destroy( + config, + data={ + "external_id": "evnt_026", + "data_source_uuid": "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + } + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.json(), + { + "subscription_event": { + "external_id": "evnt_026", + "data_source_uuid": "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + } + }, + ) + self.assertTrue(result is None) + + @requests_mock.mock() + def test_modify_flat_with_external_id_and_ds_uuid(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.modify( + config, + data={ + "external_id": "evnt_026", + "data_source_uuid": "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + "amount_in_cents": 10, + } + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.json(), + { + "subscription_event": { + "external_id": "evnt_026", + "data_source_uuid": "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + "amount_in_cents": 10, + } + }, + ) + self.assertTrue(isinstance(sub_ev, SubscriptionEvent)) + + @requests_mock.mock() + def test_destroy_flat_passthrough_envelope(self, mock_requests): + """If caller already wraps in subscription_event, don't double-wrap.""" + mock_requests.register_uri( + "DELETE", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=204, + ) + + config = Config("token") + result = SubscriptionEvent.destroy( + config, + data={"subscription_event": {"id": 7654321}} + ).get() + + self.assertEqual( + mock_requests.last_request.json(), + {"subscription_event": {"id": 7654321}}, + ) + self.assertTrue(result is None) + + @requests_mock.mock() + def test_modify_flat_passthrough_envelope(self, mock_requests): + """If caller already wraps in subscription_event, don't double-wrap.""" + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.modify( + config, + data={"subscription_event": {"id": 7654321, "amount_in_cents": 10}} + ).get() + + self.assertEqual( + mock_requests.last_request.json(), + {"subscription_event": {"id": 7654321, "amount_in_cents": 10}}, + ) + self.assertTrue(isinstance(sub_ev, SubscriptionEvent)) + + @requests_mock.mock() + def test_disable_with_external_id_and_ds_uuid(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.disable( + config, + data={ + "external_id": "evnt_026", + "data_source_uuid": "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + } + ).get() + + body = mock_requests.last_request.json() + self.assertEqual(body["subscription_event"]["external_id"], "evnt_026") + self.assertEqual( + body["subscription_event"]["data_source_uuid"], + "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + ) + self.assertTrue(body["subscription_event"]["disabled"]) + self.assertTrue(isinstance(sub_ev, SubscriptionEvent)) + + @requests_mock.mock() + def test_enable_with_external_id_and_ds_uuid(self, mock_requests): + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + config = Config("token") + sub_ev = SubscriptionEvent.enable( + config, + data={ + "external_id": "evnt_026", + "data_source_uuid": "ds_1fm3eaac-62d0-31ec-clf4-4bf0mbe81aba", + } + ).get() + + body = mock_requests.last_request.json() + self.assertEqual(body["subscription_event"]["external_id"], "evnt_026") + self.assertFalse(body["subscription_event"]["disabled"]) + self.assertTrue(isinstance(sub_ev, SubscriptionEvent)) + @requests_mock.mock() def test_all_subscription_events_with_filters(self, mock_requests): mock_requests.register_uri( From 51a3bee207dbdfdc37d4493d8e29e20c029c69d2 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Wed, 18 Mar 2026 11:16:29 +0100 Subject: [PATCH 6/8] Address PR review comments - Invoice.disable: rename method from "patch" to "disable" and add "disable" to _validate_arguments uuid check so calling without uuid raises ArgumentMissingError instead of silently producing a bad URL - SubscriptionEvent._disable/_enable: copy caller dict before mutation to avoid side effects; when payload is already wrapped in a subscription_event envelope, set the disabled flag inside it rather than at the top level Co-Authored-By: Claude Sonnet 4.6 --- chartmogul/api/invoice.py | 2 +- chartmogul/api/subscription_event.py | 18 +++++--- chartmogul/resource.py | 2 +- test/api/test_invoice.py | 6 +++ test/api/test_subscription_event.py | 62 ++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/chartmogul/api/invoice.py b/chartmogul/api/invoice.py index 5173f80..d67244f 100644 --- a/chartmogul/api/invoice.py +++ b/chartmogul/api/invoice.py @@ -99,4 +99,4 @@ def all(cls, config, **kwargs): ) Invoice.retrieve = Invoice._method("retrieve", "get", "/invoices{/uuid}") Invoice.update_status = Invoice._method("modify", "patch", "/invoices{/uuid}") -Invoice.disable = Invoice._method("patch", "patch", "/invoices{/uuid}/disable") +Invoice.disable = Invoice._method("disable", "patch", "/invoices{/uuid}/disable") diff --git a/chartmogul/api/subscription_event.py b/chartmogul/api/subscription_event.py index c3f694b..185a413 100644 --- a/chartmogul/api/subscription_event.py +++ b/chartmogul/api/subscription_event.py @@ -69,9 +69,12 @@ def _modify(cls, config, **kwargs): @classmethod def _disable(cls, config, **kwargs): """Disable a subscription event by setting disabled to true.""" - data = kwargs.get("data", {}) - data["disabled"] = True - if "subscription_event" not in data: + data = dict(kwargs.get("data", {})) + if "subscription_event" in data: + data = {"subscription_event": dict(data["subscription_event"])} + data["subscription_event"]["disabled"] = True + else: + data["disabled"] = True data = {"subscription_event": data} return cls.modify_with_params(config, data=data) @@ -79,9 +82,12 @@ def _disable(cls, config, **kwargs): @classmethod def _enable(cls, config, **kwargs): """Enable a subscription event by setting disabled to false.""" - data = kwargs.get("data", {}) - data["disabled"] = False - if "subscription_event" not in data: + data = dict(kwargs.get("data", {})) + if "subscription_event" in data: + data = {"subscription_event": dict(data["subscription_event"])} + data["subscription_event"]["disabled"] = False + else: + data["disabled"] = False data = {"subscription_event": data} return cls.modify_with_params(config, data=data) diff --git a/chartmogul/resource.py b/chartmogul/resource.py index a9d6d61..ca2d381 100644 --- a/chartmogul/resource.py +++ b/chartmogul/resource.py @@ -174,7 +174,7 @@ def _expandPath(cls, path, kwargs): def _validate_arguments(cls, method, kwargs): # This enforces user to pass argument, otherwise we could call # wrong URL. - if method in ["destroy", "cancel", "retrieve", "modify", "update"] and "uuid" not in kwargs: + if method in ["destroy", "cancel", "retrieve", "modify", "update", "disable"] and "uuid" not in kwargs: raise ArgumentMissingError("Please pass 'uuid' parameter") if method in ["create", "modify"] and "data" not in kwargs: raise ArgumentMissingError("Please pass 'data' parameter") diff --git a/test/api/test_invoice.py b/test/api/test_invoice.py index 05a022f..0506104 100644 --- a/test/api/test_invoice.py +++ b/test/api/test_invoice.py @@ -8,6 +8,7 @@ from chartmogul import Config from chartmogul import APIError +from chartmogul import ArgumentMissingError from chartmogul import Invoice @@ -907,3 +908,8 @@ def test_line_item_errors_absent(self, mock_requests): # When errors field is absent from response, the attribute should not be set self.assertFalse(hasattr(result.line_items[0], "errors")) self.assertFalse(hasattr(result.transactions[0], "errors")) + + def test_disable_missing_uuid_raises(self): + config = Config("token") + with self.assertRaises(ArgumentMissingError): + Invoice.disable(config) diff --git a/test/api/test_subscription_event.py b/test/api/test_subscription_event.py index 6e91fe7..65516e2 100644 --- a/test/api/test_subscription_event.py +++ b/test/api/test_subscription_event.py @@ -504,3 +504,65 @@ def test_all_subscription_events_with_filters(self, mock_requests): sorted(expected.subscription_events[0].external_id), ) self.assertTrue(isinstance(subscription_events.subscription_events[0], SubscriptionEvent)) + + @requests_mock.mock() + def test_disable_passthrough_envelope_sets_flag_inside(self, mock_requests): + """When caller passes pre-wrapped envelope, disabled flag must go inside it.""" + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + caller_data = {"subscription_event": {"id": 7654321}} + config = Config("token") + SubscriptionEvent.disable(config, data=caller_data).get() + + body = mock_requests.last_request.json() + # disabled must be inside the envelope, not at top level + self.assertNotIn("disabled", body) + self.assertTrue(body["subscription_event"]["disabled"]) + self.assertEqual(body["subscription_event"]["id"], 7654321) + # caller's dict must not be mutated + self.assertNotIn("disabled", caller_data["subscription_event"]) + + @requests_mock.mock() + def test_enable_passthrough_envelope_sets_flag_inside(self, mock_requests): + """When caller passes pre-wrapped envelope, disabled=False must go inside it.""" + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + caller_data = {"subscription_event": {"id": 7654321}} + config = Config("token") + SubscriptionEvent.enable(config, data=caller_data).get() + + body = mock_requests.last_request.json() + self.assertNotIn("disabled", body) + self.assertFalse(body["subscription_event"]["disabled"]) + self.assertEqual(body["subscription_event"]["id"], 7654321) + self.assertNotIn("disabled", caller_data["subscription_event"]) + + @requests_mock.mock() + def test_disable_does_not_mutate_caller_dict(self, mock_requests): + """Flat-param disable must not mutate the caller's dict in-place.""" + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/subscription_events", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=expected_sub_ev, + ) + + caller_data = {"id": 7654321} + config = Config("token") + SubscriptionEvent.disable(config, data=caller_data).get() + + # caller's original dict should not have been modified + self.assertNotIn("disabled", caller_data) From 53f9aaf24711acdf13dced7a583fbdeb69efa6e7 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 24 Mar 2026 13:25:04 +0100 Subject: [PATCH 7/8] Refactor: move SubscriptionEvent methods into class body, add disabled field, clean up tests - Move destroy/modify/disable/enable from module-scope into SubscriptionEvent as proper classmethods - Add disabled field to SubscriptionEvent schema so API responses are fully deserialized - Defensive dict() copy in destroy/modify for consistency with disable/enable - Rename camelCase test fixtures to snake_case, reduce duplication via spread and helper Co-Authored-By: Claude Opus 4.6 (1M context) --- chartmogul/api/subscription_event.py | 91 +++++++++---------- test/api/test_account.py | 35 +++----- test/api/test_invoice.py | 128 ++++++++------------------- 3 files changed, 91 insertions(+), 163 deletions(-) diff --git a/chartmogul/api/subscription_event.py b/chartmogul/api/subscription_event.py index 185a413..132dd39 100644 --- a/chartmogul/api/subscription_event.py +++ b/chartmogul/api/subscription_event.py @@ -31,6 +31,7 @@ class _Schema(Schema): retracted_event_id = fields.String(allow_none=True) external_id = fields.String(allow_none=True) event_order = fields.Int(allow_none=True) + disabled = fields.Bool(allow_none=True) @post_load def make(self, data, **kwargs): @@ -38,6 +39,46 @@ def make(self, data, **kwargs): _schema = _Schema(unknown=EXCLUDE) + @classmethod + def destroy(cls, config, **kwargs): + """Accept flat params and wrap in subscription_event envelope for the API.""" + data = dict(kwargs.get("data", {})) + if "subscription_event" not in data: + data = {"subscription_event": data} + return cls.destroy_with_params(config, data=data) + + @classmethod + def modify(cls, config, **kwargs): + """Accept flat params and wrap in subscription_event envelope for the API.""" + data = dict(kwargs.get("data", {})) + if "subscription_event" not in data: + data = {"subscription_event": data} + return cls.modify_with_params(config, data=data) + + @classmethod + def disable(cls, config, **kwargs): + """Disable a subscription event by setting disabled to true.""" + data = dict(kwargs.get("data", {})) + if "subscription_event" in data: + data = {"subscription_event": dict(data["subscription_event"])} + data["subscription_event"]["disabled"] = True + else: + data["disabled"] = True + data = {"subscription_event": data} + return cls.modify_with_params(config, data=data) + + @classmethod + def enable(cls, config, **kwargs): + """Enable a subscription event by setting disabled to false.""" + data = dict(kwargs.get("data", {})) + if "subscription_event" in data: + data = {"subscription_event": dict(data["subscription_event"])} + data["subscription_event"]["disabled"] = False + else: + data["disabled"] = False + data = {"subscription_event": data} + return cls.modify_with_params(config, data=data) + SubscriptionEvent.all = SubscriptionEvent._method("all", "get", "/subscription_events") SubscriptionEvent.destroy_with_params = SubscriptionEvent._method( @@ -46,53 +87,3 @@ def make(self, data, **kwargs): SubscriptionEvent.modify_with_params = SubscriptionEvent._method( "modify_with_params", "patch", "/subscription_events" ) - - -@classmethod -def _destroy(cls, config, **kwargs): - """Accept flat params and wrap in subscription_event envelope for the API.""" - data = kwargs.get("data", {}) - if "subscription_event" not in data: - data = {"subscription_event": data} - return cls.destroy_with_params(config, data=data) - - -@classmethod -def _modify(cls, config, **kwargs): - """Accept flat params and wrap in subscription_event envelope for the API.""" - data = kwargs.get("data", {}) - if "subscription_event" not in data: - data = {"subscription_event": data} - return cls.modify_with_params(config, data=data) - - -@classmethod -def _disable(cls, config, **kwargs): - """Disable a subscription event by setting disabled to true.""" - data = dict(kwargs.get("data", {})) - if "subscription_event" in data: - data = {"subscription_event": dict(data["subscription_event"])} - data["subscription_event"]["disabled"] = True - else: - data["disabled"] = True - data = {"subscription_event": data} - return cls.modify_with_params(config, data=data) - - -@classmethod -def _enable(cls, config, **kwargs): - """Enable a subscription event by setting disabled to false.""" - data = dict(kwargs.get("data", {})) - if "subscription_event" in data: - data = {"subscription_event": dict(data["subscription_event"])} - data["subscription_event"]["disabled"] = False - else: - data["disabled"] = False - data = {"subscription_event": data} - return cls.modify_with_params(config, data=data) - - -SubscriptionEvent.destroy = _destroy -SubscriptionEvent.modify = _modify -SubscriptionEvent.disable = _disable -SubscriptionEvent.enable = _enable diff --git a/test/api/test_account.py b/test/api/test_account.py index 9b29dd7..46fbcb8 100644 --- a/test/api/test_account.py +++ b/test/api/test_account.py @@ -5,27 +5,20 @@ from chartmogul import Account, Config, APIError -jsonResponse = { +base_response = { "name": "Example Test Company", "currency": "EUR", "time_zone": "Europe/Berlin", "week_start_on": "sunday", } -jsonResponseWithId = { +response_with_id = { + **base_response, "id": "acct_a1b2c3d4", - "name": "Example Test Company", - "currency": "EUR", - "time_zone": "Europe/Berlin", - "week_start_on": "sunday", } -jsonResponseWithInclude = { - "id": "acct_a1b2c3d4", - "name": "Example Test Company", - "currency": "EUR", - "time_zone": "Europe/Berlin", - "week_start_on": "sunday", +response_with_include = { + **response_with_id, "churn_recognition": "immediate", "churn_when_zero_mrr": "ignore", } @@ -43,7 +36,7 @@ def test_retrieve(self, mock_requests): "https://api.chartmogul.com/v1/account", request_headers={"Authorization": "Basic dG9rZW46"}, status_code=200, - json=jsonResponse, + json=base_response, ) config = Config("token") # is actually checked in mock @@ -61,7 +54,7 @@ def test_retrieve_with_id(self, mock_requests): "https://api.chartmogul.com/v1/account", request_headers={"Authorization": "Basic dG9rZW46"}, status_code=200, - json=jsonResponseWithId, + json=response_with_id, ) config = Config("token") @@ -77,7 +70,7 @@ def test_retrieve_with_include(self, mock_requests): request_headers={"Authorization": "Basic dG9rZW46"}, headers={"Content-Type": "application/json"}, status_code=200, - json=jsonResponseWithInclude, + json=response_with_include, ) config = Config("token") @@ -102,7 +95,7 @@ def test_retrieve_without_id_field(self, mock_requests): "https://api.chartmogul.com/v1/account", request_headers={"Authorization": "Basic dG9rZW46"}, status_code=200, - json=jsonResponse, + json=base_response, ) config = Config("token") @@ -112,12 +105,8 @@ def test_retrieve_without_id_field(self, mock_requests): @requests_mock.mock() def test_retrieve_with_single_include(self, mock_requests): - singleIncludeResponse = { - "id": "acct_a1b2c3d4", - "name": "Example Test Company", - "currency": "EUR", - "time_zone": "Europe/Berlin", - "week_start_on": "sunday", + single_include_response = { + **response_with_id, "churn_recognition": "immediate", } @@ -127,7 +116,7 @@ def test_retrieve_with_single_include(self, mock_requests): request_headers={"Authorization": "Basic dG9rZW46"}, headers={"Content-Type": "application/json"}, status_code=200, - json=singleIncludeResponse, + json=single_include_response, ) config = Config("token") diff --git a/test/api/test_invoice.py b/test/api/test_invoice.py index 0506104..ba5e906 100644 --- a/test/api/test_invoice.py +++ b/test/api/test_invoice.py @@ -623,47 +623,53 @@ def test_all_invoices_with_all_params(self, mock_requests): self.assertTrue(isinstance(result, Invoice._many)) self.assertEqual(len(result.invoices), 1) - @requests_mock.mock() - def test_line_item_and_transaction_errors(self, mock_requests): - responseWithErrors = { + def _make_errors_response(self, li_errors=None, tx_errors=None, include_errors=True): + """Build a minimal invoice response with configurable errors fields.""" + li = { + "uuid": "li_test", + "external_id": None, + "type": "subscription", + "prorated": False, + "amount_in_cents": 5000, + "quantity": 1, + "discount_amount_in_cents": 0, + "tax_amount_in_cents": 0, + "transaction_fees_in_cents": 0, + } + tx = { + "uuid": "tr_test", + "external_id": None, + "type": "payment", + "date": "2015-11-05T00:04:03.000Z", + "result": "successful", + } + if include_errors: + li["errors"] = li_errors + tx["errors"] = tx_errors + return { "uuid": "inv_test", "external_id": "INV0001", "date": "2015-11-01T00:00:00.000Z", "due_date": "2015-11-15T00:00:00.000Z", "currency": "USD", - "line_items": [ - { - "uuid": "li_test", - "external_id": None, - "type": "subscription", - "prorated": False, - "amount_in_cents": 5000, - "quantity": 1, - "discount_amount_in_cents": 0, - "tax_amount_in_cents": 0, - "transaction_fees_in_cents": 0, - "errors": {"amount_in_cents": ["must be positive"]}, - }, - ], - "transactions": [ - { - "uuid": "tr_test", - "external_id": None, - "type": "payment", - "date": "2015-11-05T00:04:03.000Z", - "result": "successful", - "errors": {"date": ["is in the future"]}, - }, - ], + "line_items": [li], + "transactions": [tx], } + @requests_mock.mock() + def test_line_item_and_transaction_errors(self, mock_requests): + response = self._make_errors_response( + li_errors={"amount_in_cents": ["must be positive"]}, + tx_errors={"date": ["is in the future"]}, + ) + mock_requests.register_uri( "GET", "https://api.chartmogul.com/v1/invoices/inv_test", request_headers={"Authorization": "Basic dG9rZW46"}, headers={"Content-Type": "application/json"}, status_code=200, - json=responseWithErrors, + json=response, ) config = Config("token") @@ -812,37 +818,7 @@ def test_disable_not_found(self, mock_requests): @requests_mock.mock() def test_line_item_errors_none(self, mock_requests): - responseWithNoneErrors = { - "uuid": "inv_test", - "external_id": "INV0001", - "date": "2015-11-01T00:00:00.000Z", - "due_date": "2015-11-15T00:00:00.000Z", - "currency": "USD", - "line_items": [ - { - "uuid": "li_test", - "external_id": None, - "type": "subscription", - "prorated": False, - "amount_in_cents": 5000, - "quantity": 1, - "discount_amount_in_cents": 0, - "tax_amount_in_cents": 0, - "transaction_fees_in_cents": 0, - "errors": None, - }, - ], - "transactions": [ - { - "uuid": "tr_test", - "external_id": None, - "type": "payment", - "date": "2015-11-05T00:04:03.000Z", - "result": "successful", - "errors": None, - }, - ], - } + response = self._make_errors_response(li_errors=None, tx_errors=None) mock_requests.register_uri( "GET", @@ -850,7 +826,7 @@ def test_line_item_errors_none(self, mock_requests): request_headers={"Authorization": "Basic dG9rZW46"}, headers={"Content-Type": "application/json"}, status_code=200, - json=responseWithNoneErrors, + json=response, ) config = Config("token") @@ -862,35 +838,7 @@ def test_line_item_errors_none(self, mock_requests): @requests_mock.mock() def test_line_item_errors_absent(self, mock_requests): - responseNoErrors = { - "uuid": "inv_test", - "external_id": "INV0001", - "date": "2015-11-01T00:00:00.000Z", - "due_date": "2015-11-15T00:00:00.000Z", - "currency": "USD", - "line_items": [ - { - "uuid": "li_test", - "external_id": None, - "type": "subscription", - "prorated": False, - "amount_in_cents": 5000, - "quantity": 1, - "discount_amount_in_cents": 0, - "tax_amount_in_cents": 0, - "transaction_fees_in_cents": 0, - }, - ], - "transactions": [ - { - "uuid": "tr_test", - "external_id": None, - "type": "payment", - "date": "2015-11-05T00:04:03.000Z", - "result": "successful", - }, - ], - } + response = self._make_errors_response(include_errors=False) mock_requests.register_uri( "GET", @@ -898,7 +846,7 @@ def test_line_item_errors_absent(self, mock_requests): request_headers={"Authorization": "Basic dG9rZW46"}, headers={"Content-Type": "application/json"}, status_code=200, - json=responseNoErrors, + json=response, ) config = Config("token") From 53719fc98da3859f5e32b5d238f7da46abf1957d Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 24 Mar 2026 13:56:56 +0100 Subject: [PATCH 8/8] =?UTF-8?q?Fix=20Account.churn=5Fwhen=5Fzero=5Fmrr=20s?= =?UTF-8?q?chema=20type:=20String=20=E2=86=92=20Raw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API returns a boolean for churn_when_zero_mrr, not a string. Using fields.Raw allows any JSON type to be deserialized correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- chartmogul/api/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chartmogul/api/account.py b/chartmogul/api/account.py index 20120cc..8019ffb 100644 --- a/chartmogul/api/account.py +++ b/chartmogul/api/account.py @@ -16,7 +16,7 @@ class _Schema(Schema): time_zone = fields.String() week_start_on = fields.String() churn_recognition = fields.String(allow_none=True) - churn_when_zero_mrr = fields.String(allow_none=True) + churn_when_zero_mrr = fields.Raw(allow_none=True) @post_load def make(self, data, **kwargs):