From c105bcc32258334b592303c93c0925fb5a3faf93 Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Thu, 10 Jul 2025 19:34:04 +0530 Subject: [PATCH] stripe: Fix free trial pay by invoice customer billed twice. This was a result of us moving `billing_cycle_anchor` ahead in time of the `LicenseLedger` entry the customer paid for. Thus, this confused our logic in thinking that customer hasn't paid for the current billing cycle. --- corporate/lib/stripe.py | 18 +++++++ ...rial_upgrade_by_invoice--Event.list.1.json | 48 +++++++++++-------- ...rial_upgrade_by_invoice--Event.list.2.json | 4 +- ...rial_upgrade_by_invoice--Event.list.3.json | 14 +++--- ..._upgrade_by_invoice--Invoice.create.1.json | 4 +- ...y_invoice--Invoice.finalize_invoice.1.json | 4 +- ...al_upgrade_by_invoice--Invoice.list.1.json | 4 +- ...ial_upgrade_by_invoice--Invoice.pay.1.json | 4 +- corporate/tests/test_stripe.py | 19 ++++++++ 9 files changed, 82 insertions(+), 37 deletions(-) diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index eaccedaffc..9c26489317 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -2301,6 +2301,24 @@ class BillingSession(ABC): # This will create invoice for any additional licenses that user has at the time of # switching from free trial to paid plan since they already paid for the plan's this billing cycle. is_renewal = False + + # Since we need to move the `billing_cycle_anchor` forward below, we also + # need to update the `event_time` of the last renewal ledger entry to avoid + # our logic from thinking that licenses for the current billing cycle hasn't + # been paid for. + last_renewal_ledger_entry = ( + LicenseLedger.objects.filter( + plan=plan, + is_renewal=True, + ) + .order_by("-id") + .first() + ) + assert last_renewal_ledger_entry is not None + last_renewal_ledger_entry.event_time = next_billing_cycle.replace( + microsecond=0 + ) + last_renewal_ledger_entry.save(update_fields=["event_time"]) else: # We end the free trial since customer hasn't paid. plan.status = CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.1.json index bfba9ba135..92ab8dddb8 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.1.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.1.json @@ -8,15 +8,15 @@ "account_country": "US", "account_name": "NORMALIZED", "account_tax_ids": null, - "amount_due": 152000, + "amount_due": 984000, "amount_overpaid": 0, - "amount_paid": 0, - "amount_remaining": 152000, + "amount_paid": 984000, + "amount_remaining": 0, "amount_shipping": 0, "application": null, "attempt_count": 0, "attempted": false, - "auto_advance": true, + "auto_advance": false, "automatic_tax": { "disabled_reason": null, "enabled": false, @@ -26,7 +26,7 @@ }, "automatically_finalizes_at": null, "billing_reason": "manual", - "collection_method": "charge_automatically", + "collection_method": "send_invoice", "created": 1000000000, "currency": "usd", "custom_fields": null, @@ -43,7 +43,7 @@ "default_tax_rates": [], "description": null, "discounts": [], - "due_date": null, + "due_date": 1000000000, "effective_at": 1000000000, "ending_balance": 0, "footer": null, @@ -59,9 +59,9 @@ "lines": { "data": [ { - "amount": 152000, + "amount": 984000, "currency": "usd", - "description": "Zulip Cloud Standard - renewal", + "description": "Zulip Cloud Standard", "discount_amounts": [], "discountable": false, "discounts": [], @@ -83,8 +83,8 @@ "type": "invoice_item_details" }, "period": { - "end": 1393729445, - "start": 1362193445 + "end": 1362193445, + "start": 1330657445 }, "pretax_credit_amounts": [], "pricing": { @@ -95,7 +95,7 @@ "type": "price_details", "unit_amount_decimal": "8000" }, - "quantity": 19, + "quantity": 123, "taxes": [] } ], @@ -105,8 +105,16 @@ "url": "/v1/invoices/free_trial_upgrade_by_invoice--Event.list.1.json/lines" }, "livemode": false, - "metadata": {}, - "next_payment_attempt": 1000000000, + "metadata": { + "billing_schedule": "1", + "current_plan_id": "1", + "license_management": "manual", + "licenses": "123", + "on_free_trial": "True", + "plan_tier": "1", + "user_id": "10" + }, + "next_payment_attempt": null, "number": "NORMALIZED", "object": "invoice", "on_behalf_of": null, @@ -133,19 +141,19 @@ "shipping_details": null, "starting_balance": 0, "statement_descriptor": "Zulip Cloud Standard", - "status": "open", + "status": "paid", "status_transitions": { "finalized_at": 1000000000, "marked_uncollectible_at": null, - "paid_at": null, + "paid_at": 1000000000, "voided_at": null }, - "subtotal": 152000, - "subtotal_excluding_tax": 152000, + "subtotal": 984000, + "subtotal_excluding_tax": 984000, "test_clock": null, - "total": 152000, + "total": 984000, "total_discount_amounts": [], - "total_excluding_tax": 152000, + "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], "webhooks_delivered_at": 1000000000 @@ -159,7 +167,7 @@ "id": "free_trial_upgrade_by_invoice--Event.list.1.json", "idempotency_key": "00000000-0000-0000-0000-000000000000" }, - "type": "invoice.finalized" + "type": "invoice.paid" } ], "has_more": true, diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.2.json index 01a5304f9c..a89d7a4cd3 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.2.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.2.json @@ -107,7 +107,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -156,7 +156,7 @@ "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null } }, "id": "free_trial_upgrade_by_invoice--Event.list.2.json", diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.3.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.3.json index ced5f1a915..9eea036dfb 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.3.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Event.list.3.json @@ -107,7 +107,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -156,13 +156,13 @@ "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null } }, "id": "free_trial_upgrade_by_invoice--Event.list.3.json", "livemode": false, "object": "event", - "pending_webhooks": 0, + "pending_webhooks": 2, "request": { "id": "free_trial_upgrade_by_invoice--Event.list.3.json", "idempotency_key": "00000000-0000-0000-0000-000000000000" @@ -276,7 +276,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -325,7 +325,7 @@ "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null }, "previous_attributes": { "amount_paid": 0, @@ -340,7 +340,7 @@ "id": "free_trial_upgrade_by_invoice--Event.list.3.json", "livemode": false, "object": "event", - "pending_webhooks": 0, + "pending_webhooks": 2, "request": { "id": "free_trial_upgrade_by_invoice--Event.list.3.json", "idempotency_key": "00000000-0000-0000-0000-000000000000" @@ -411,7 +411,7 @@ "id": "free_trial_upgrade_by_invoice--Event.list.3.json", "livemode": false, "object": "event", - "pending_webhooks": 0, + "pending_webhooks": 2, "request": { "id": "free_trial_upgrade_by_invoice--Event.list.3.json", "idempotency_key": "00000000-0000-0000-0000-000000000000" diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json index ada821f14f..a577e71cfc 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json @@ -60,7 +60,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -109,5 +109,5 @@ "total_excluding_tax": 0, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null } diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json index 4342d16d41..0bf274bb73 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json @@ -101,7 +101,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -150,5 +150,5 @@ "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null } diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json index dddc05f052..4188b9abf4 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json @@ -103,7 +103,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -152,7 +152,7 @@ "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null } ], "has_more": false, diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.pay.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.pay.1.json index 29e4c5d2b8..8409b09583 100644 --- a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.pay.1.json +++ b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.pay.1.json @@ -101,7 +101,7 @@ "livemode": false, "metadata": { "billing_schedule": "1", - "current_plan_id": "39", + "current_plan_id": "1", "license_management": "manual", "licenses": "123", "on_free_trial": "True", @@ -150,5 +150,5 @@ "total_excluding_tax": 984000, "total_pretax_credit_amounts": [], "total_taxes": [], - "webhooks_delivered_at": 1000000000 + "webhooks_delivered_at": null } diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index c1ea01c3d2..05105f69b0 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -1795,6 +1795,15 @@ class StripeTest(StripeTestCase): self.assertEqual(customer_plan.next_invoice_date, free_trial_end_date) [last_event] = iter(stripe.Event.list(limit=1)) + last_renewal_ledger = ( + LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first() + ) + assert last_renewal_ledger is not None + self.assertEqual( + last_renewal_ledger.event_time, + self.now, + ) + # Customer pays the invoice assert invoice.id is not None stripe.Invoice.pay(invoice.id, paid_out_of_band=True) @@ -1806,11 +1815,21 @@ class StripeTest(StripeTestCase): self.assert_in_success_response(["You have no outstanding invoices."], response) invoice_plans_as_needed(free_trial_end_date) + last_renewal_ledger.refresh_from_db() customer_plan.refresh_from_db() realm.refresh_from_db() + plan.refresh_from_db() self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE) self.assertEqual(customer_plan.next_invoice_date, add_months(free_trial_end_date, 1)) self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) + self.assertEqual(last_renewal_ledger.event_time, free_trial_end_date) + self.assertEqual(customer_plan.billing_cycle_anchor, free_trial_end_date) + + before_ledger_count = LicenseLedger.objects.filter(plan=plan).count() + self.billing_session.make_end_of_cycle_updates_if_needed(plan, free_trial_end_date) + after_ledger_count = LicenseLedger.objects.filter(plan=plan).count() + # No additional ledger entries are created. + self.assertEqual(before_ledger_count, after_ledger_count) @mock_stripe() def test_free_trial_upgrade_by_invoice_customer_fails_to_pay(self, *mocks: Mock) -> None: