diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 723f69b174..afc41b58ba 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -319,6 +319,7 @@ def do_replace_payment_source( ) -> stripe.Customer: customer = get_customer_by_realm(user.realm) assert customer is not None # for mypy + assert customer.stripe_customer_id is not None # for mypy stripe_customer = stripe_get_customer(customer.stripe_customer_id) stripe_customer.source = stripe_token @@ -533,6 +534,8 @@ def process_initial_upgrade( ) -> None: realm = user.realm customer = update_or_create_stripe_customer(user, stripe_token=stripe_token) + assert customer.stripe_customer_id is not None # for mypy + charge_automatically = stripe_token is not None free_trial = is_free_trial_offer_enabled() @@ -710,6 +713,11 @@ def update_license_ledger_if_needed(realm: Realm, event_time: datetime) -> None: def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None: if plan.invoicing_status == CustomerPlan.STARTED: raise NotImplementedError("Plan with invoicing_status==STARTED needs manual resolution.") + if not plan.customer.stripe_customer_id: + raise BillingError( + f"Realm {plan.customer.realm.string_id} has a paid plan without a Stripe customer." + ) + make_end_of_cycle_updates_if_needed(plan, event_time) if plan.invoicing_status == CustomerPlan.INITIAL_INVOICE_TO_BE_SENT: @@ -957,6 +965,8 @@ def void_all_open_invoices(realm: Realm) -> int: customer = get_customer_by_realm(realm) if customer is None: return 0 + if customer.stripe_customer_id is None: + return 0 invoices = stripe.Invoice.list(customer=customer.stripe_customer_id) voided_invoices_count = 0 for invoice in invoices: diff --git a/corporate/models.py b/corporate/models.py index 5ed704d0a6..46570f98b9 100644 --- a/corporate/models.py +++ b/corporate/models.py @@ -16,7 +16,7 @@ class Customer(models.Model): """ realm: Realm = models.OneToOneField(Realm, on_delete=CASCADE) - stripe_customer_id: str = models.CharField(max_length=255, null=True, unique=True) + stripe_customer_id: Optional[str] = models.CharField(max_length=255, null=True, unique=True) sponsorship_pending: bool = models.BooleanField(default=False) # A percentage, like 85. default_discount: Optional[Decimal] = models.DecimalField( diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Customer.create.2.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Customer.create.2.json new file mode 100644 index 0000000000..bedc055ea8 Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Customer.create.2.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.1.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.1.json index 9f107b7be5..6fb605ccdc 100644 Binary files a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.1.json and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.2.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.2.json new file mode 100644 index 0000000000..a541f9754a Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.create.2.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.1.json index 475bef780b..0bd2da21ba 100644 Binary files a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.1.json and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.1.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.2.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.2.json new file mode 100644 index 0000000000..276da7c658 Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.finalize_invoice.2.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.1.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.1.json index 3ea16e20d1..bcc7a44eb6 100644 Binary files a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.1.json and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.2.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.2.json index 01d93a468b..d938173b2a 100644 Binary files a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.2.json and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.2.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.3.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.3.json new file mode 100644 index 0000000000..0ccddad514 Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.3.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.4.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.4.json new file mode 100644 index 0000000000..b526043a90 Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.list.4.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.1.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.1.json index 39f3ca9acf..3632aaaa9d 100644 Binary files a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.1.json and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.1.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.2.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.2.json new file mode 100644 index 0000000000..c72adf7ad6 Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--Invoice.void_invoice.2.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.1.json index 3a4c338d1c..cddc8744f5 100644 Binary files a/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.1.json and b/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.2.json new file mode 100644 index 0000000000..54b979e06a Binary files /dev/null and b/corporate/tests/stripe_fixtures/void_all_open_invoices--InvoiceItem.create.2.json differ diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 9e28210e4a..ec0a5251d8 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -1990,6 +1990,7 @@ class StripeTest(StripeTestCase): monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.next_invoice_date, None) + assert customer.stripe_customer_id [invoice0, invoice1, invoice2] = stripe.Invoice.list(customer=customer.stripe_customer_id) [invoice_item0, invoice_item1] = invoice0.get("lines") @@ -2154,6 +2155,7 @@ class StripeTest(StripeTestCase): self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 12)) self.assertEqual(annual_plan.invoicing_status, CustomerPlan.DONE) + assert customer.stripe_customer_id [invoice0, invoice1] = stripe.Invoice.list(customer=customer.stripe_customer_id) [invoice_item] = invoice0.get("lines") @@ -2632,12 +2634,17 @@ class StripeTest(StripeTestCase): @mock_stripe() def test_void_all_open_invoices(self, *mock: Mock) -> None: iago = self.example_user("iago") - self.assertEqual(void_all_open_invoices(iago.realm), 0) - customer = update_or_create_stripe_customer(iago) + king = self.lear_user("king") + self.assertEqual(void_all_open_invoices(iago.realm), 0) + + zulip_customer = update_or_create_stripe_customer(iago) + lear_customer = update_or_create_stripe_customer(king) + + assert zulip_customer.stripe_customer_id stripe.InvoiceItem.create( currency="usd", - customer=customer.stripe_customer_id, + customer=zulip_customer.stripe_customer_id, description="Zulip standard upgrade", discountable=False, unit_amount=800, @@ -2646,14 +2653,45 @@ class StripeTest(StripeTestCase): stripe_invoice = stripe.Invoice.create( auto_advance=True, billing="send_invoice", - customer=customer.stripe_customer_id, + customer=zulip_customer.stripe_customer_id, + days_until_due=30, + statement_descriptor="Zulip Standard", + ) + stripe.Invoice.finalize_invoice(stripe_invoice) + + assert lear_customer.stripe_customer_id + stripe.InvoiceItem.create( + currency="usd", + customer=lear_customer.stripe_customer_id, + description="Zulip standard upgrade", + discountable=False, + unit_amount=800, + quantity=8, + ) + stripe_invoice = stripe.Invoice.create( + auto_advance=True, + billing="send_invoice", + customer=lear_customer.stripe_customer_id, days_until_due=30, statement_descriptor="Zulip Standard", ) stripe.Invoice.finalize_invoice(stripe_invoice) self.assertEqual(void_all_open_invoices(iago.realm), 1) - invoices = stripe.Invoice.list(customer=customer.stripe_customer_id) + invoices = stripe.Invoice.list(customer=zulip_customer.stripe_customer_id) + self.assert_length(invoices, 1) + for invoice in invoices: + self.assertEqual(invoice.status, "void") + + lear_stripe_customer_id = lear_customer.stripe_customer_id + lear_customer.stripe_customer_id = None + lear_customer.save(update_fields=["stripe_customer_id"]) + self.assertEqual(void_all_open_invoices(king.realm), 0) + + lear_customer.stripe_customer_id = lear_stripe_customer_id + lear_customer.save(update_fields=["stripe_customer_id"]) + self.assertEqual(void_all_open_invoices(king.realm), 1) + invoices = stripe.Invoice.list(customer=lear_customer.stripe_customer_id) self.assert_length(invoices, 1) for invoice in invoices: self.assertEqual(invoice.status, "void") @@ -3207,6 +3245,17 @@ class InvoiceTest(StripeTestCase): with self.assertRaises(NotImplementedError): invoice_plan(CustomerPlan.objects.first(), self.now) + def test_invoice_plan_without_stripe_customer(self) -> None: + self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL) + plan = get_current_plan_by_realm(get_realm("zulip")) + assert plan and plan.customer + plan.customer.stripe_customer_id = None + plan.customer.save(update_fields=["stripe_customer_id"]) + with self.assertRaisesRegex( + BillingError, "Realm zulip has a paid plan without a Stripe customer" + ): + invoice_plan(plan, timezone_now()) + @mock_stripe() def test_invoice_plan(self, *mocks: Mock) -> None: user = self.example_user("hamlet") diff --git a/corporate/views.py b/corporate/views.py index 7f5754702a..887c5ba4ff 100644 --- a/corporate/views.py +++ b/corporate/views.py @@ -317,6 +317,7 @@ def billing_home(request: HttpRequest) -> HttpResponse: ) renewal_cents = renewal_amount(plan, now) charge_automatically = plan.charge_automatically + assert customer.stripe_customer_id is not None # for mypy stripe_customer = stripe_get_customer(customer.stripe_customer_id) if charge_automatically: payment_method = payment_method_string(stripe_customer)