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.
This commit is contained in:
Aman Agrawal
2025-07-10 19:34:04 +05:30
committed by Tim Abbott
parent ac0cc78327
commit c105bcc322
9 changed files with 82 additions and 37 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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: