diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index dd33c70354..6779d522be 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -2478,7 +2478,8 @@ class BillingSession(ABC): stripe.Invoice.finalize_invoice(stripe_invoice) plan.next_invoice_date = next_invoice_date(plan) - plan.save(update_fields=["next_invoice_date"]) + plan.invoice_overdue_email_sent = False + plan.save(update_fields=["next_invoice_date", "invoice_overdue_email_sent"]) def do_change_plan_to_new_tier(self, new_plan_tier: int) -> str: customer = self.get_customer() @@ -4341,10 +4342,40 @@ def get_plan_renewal_or_end_date(plan: CustomerPlan, event_time: datetime) -> da def invoice_plans_as_needed(event_time: Optional[datetime] = None) -> None: if event_time is None: # nocoverage event_time = timezone_now() - # TODO: Add RemoteRealmBillingSession and RemoteServerBillingSession cases. + # TODO: Add RemoteServerBillingSession cases. for plan in CustomerPlan.objects.filter(next_invoice_date__lte=event_time): if plan.customer.realm is not None: RealmBillingSession(realm=plan.customer.realm).invoice_plan(plan, event_time) + elif plan.customer.remote_realm is not None: + remote_realm = plan.customer.remote_realm + remote_server = remote_realm.server + billing_session = RemoteRealmBillingSession(remote_realm=remote_realm) + + assert remote_server.last_audit_log_update is not None + assert plan.next_invoice_date is not None + if plan.next_invoice_date > remote_server.last_audit_log_update: + if ( + plan.next_invoice_date - remote_server.last_audit_log_update + >= timedelta(days=1) + and not plan.invoice_overdue_email_sent + ): + context = { + "support_url": billing_session.support_url(), + "last_audit_log_update": remote_server.last_audit_log_update.strftime( + "%Y-%m-%d" + ), + } + send_email( + "zerver/emails/invoice_overdue", + to_emails=[BILLING_SUPPORT_EMAIL], + from_address=FromAddress.tokenized_no_reply_address(), + context=context, + ) + plan.invoice_overdue_email_sent = True + plan.save(update_fields=["invoice_overdue_email_sent"]) + continue + + billing_session.invoice_plan(plan, event_time) # TODO: Assert that we never invoice legacy plans. diff --git a/corporate/migrations/0033_customerplan_invoice_overdue_email_sent.py b/corporate/migrations/0033_customerplan_invoice_overdue_email_sent.py new file mode 100644 index 0000000000..17fc8b64f6 --- /dev/null +++ b/corporate/migrations/0033_customerplan_invoice_overdue_email_sent.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.8 on 2024-01-10 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("corporate", "0032_customer_minimum_licenses"), + ] + + operations = [ + migrations.AddField( + model_name="customerplan", + name="invoice_overdue_email_sent", + field=models.BooleanField(default=False), + ), + ] diff --git a/corporate/models.py b/corporate/models.py index 7f933164c9..d70bd61607 100644 --- a/corporate/models.py +++ b/corporate/models.py @@ -249,6 +249,11 @@ class CustomerPlan(models.Model): # next_invoice_date. next_invoice_date = models.DateTimeField(db_index=True, null=True) + # Flag to track if an email has been sent to Zulip team for + # invoice overdue by >= one day. Helps to send an email only once + # and not every time when cron run. + invoice_overdue_email_sent = models.BooleanField(default=False) + # On next_invoice_date, we go through ledger entries that were # created after invoiced_through and process them by generating # invoices for any additional users and/or plan renewal. Once the diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.create.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.create.1.json new file mode 100644 index 0000000000..eb66e356bb Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.modify.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.modify.1.json new file mode 100644 index 0000000000..418747dfd4 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.modify.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.1.json new file mode 100644 index 0000000000..364fe0554b Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.2.json new file mode 100644 index 0000000000..364fe0554b Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.2.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.3.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.3.json new file mode 100644 index 0000000000..364fe0554b Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.3.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.4.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.4.json new file mode 100644 index 0000000000..364fe0554b Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Customer.retrieve.4.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.1.json new file mode 100644 index 0000000000..3770b6ff5c Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.2.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.2.json new file mode 100644 index 0000000000..e42447760e Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.2.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.3.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.3.json new file mode 100644 index 0000000000..9763ec087e Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.3.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.4.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.4.json new file mode 100644 index 0000000000..e1cc377c88 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.4.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.5.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.5.json new file mode 100644 index 0000000000..6d922067af Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Event.list.5.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.create.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.create.1.json new file mode 100644 index 0000000000..f07b172fd3 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.create.2.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.create.2.json new file mode 100644 index 0000000000..234942fd25 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.create.2.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.finalize_invoice.1.json new file mode 100644 index 0000000000..470ec1c8a2 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.finalize_invoice.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.finalize_invoice.2.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.finalize_invoice.2.json new file mode 100644 index 0000000000..65c7c18c9d Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.finalize_invoice.2.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.list.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.list.1.json new file mode 100644 index 0000000000..e39960ab72 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.list.2.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.list.2.json new file mode 100644 index 0000000000..c8dce9c1b9 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--Invoice.list.2.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.1.json new file mode 100644 index 0000000000..041633b56c Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.2.json new file mode 100644 index 0000000000..82e3bb8ef9 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.2.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.3.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.3.json new file mode 100644 index 0000000000..4ce7811522 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.3.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.4.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.4.json new file mode 100644 index 0000000000..c0d44abd08 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.4.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.5.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.5.json new file mode 100644 index 0000000000..54f069ed03 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.5.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.6.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.6.json new file mode 100644 index 0000000000..fba6930087 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--InvoiceItem.create.6.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--PaymentIntent.create.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--PaymentIntent.create.1.json new file mode 100644 index 0000000000..93ce7e2c91 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--PaymentIntent.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.create.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.create.1.json new file mode 100644 index 0000000000..1279fca04a Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.list.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.list.1.json new file mode 100644 index 0000000000..a0c24a5541 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.retrieve.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.retrieve.1.json new file mode 100644 index 0000000000..1279fca04a Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--SetupIntent.retrieve.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--checkout.Session.create.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--checkout.Session.create.1.json new file mode 100644 index 0000000000..57e1a4f829 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--checkout.Session.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/invoice_plans_as_needed--checkout.Session.list.1.json b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--checkout.Session.list.1.json new file mode 100644 index 0000000000..6d572a8fd5 Binary files /dev/null and b/corporate/tests/stripe_fixtures/invoice_plans_as_needed--checkout.Session.list.1.json differ diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 18311945d9..c89285c310 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -6378,6 +6378,105 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase): for key, value in invoice_item_params.items(): self.assertEqual(invoice_item2[key], value) + @responses.activate + @mock_stripe() + def test_invoice_plans_as_needed(self, *mocks: Mock) -> None: + self.login("hamlet") + hamlet = self.example_user("hamlet") + + self.add_mock_response() + with time_machine.travel(self.now, tick=False): + send_server_data_to_push_bouncer(consider_usage_statistics=False) + + self.execute_remote_billing_authentication_flow(hamlet) + with time_machine.travel(self.now, tick=False): + stripe_customer = self.add_card_and_upgrade( + tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly" + ) + + customer = Customer.objects.get(stripe_customer_id=stripe_customer.id) + plan = CustomerPlan.objects.get(customer=customer) + assert plan.customer.remote_realm is not None + self.assertEqual(plan.next_invoice_date, self.next_month) + + with time_machine.travel(self.now + timedelta(days=2), tick=False): + for count in range(5): + do_create_user( + f"email - {count}", + f"password {count}", + hamlet.realm, + "name", + role=UserProfile.ROLE_MEMBER, + acting_user=None, + ) + + # Data upload was 25 days before the invoice date. + last_audit_log_update = self.now + timedelta(days=5) + with time_machine.travel(last_audit_log_update, tick=False): + send_server_data_to_push_bouncer(consider_usage_statistics=False) + invoice_plans_as_needed(self.next_month) + plan.refresh_from_db() + self.assertEqual(plan.next_invoice_date, self.next_month) + self.assertTrue(plan.invoice_overdue_email_sent) + + from django.core.mail import outbox + + messages_count = len(outbox) + message = outbox[-1] + self.assert_length(message.to, 1) + self.assertEqual(message.to[0], "sales@zulip.com") + self.assertEqual(message.subject, "Invoice overdue due to stale data") + self.assertIn( + f"Support URL: {self.billing_session.support_url()}", + message.body, + ) + self.assertIn( + f"Last data upload: {last_audit_log_update.strftime('%Y-%m-%d')}", message.body + ) + + # Cron runs again, don't send another email to Zulip team. + invoice_plans_as_needed(self.next_month + timedelta(days=1)) + self.assert_length(outbox, messages_count) + + # Ledger is up-to-date. Plan invoiced. + with time_machine.travel(self.next_month, tick=False): + send_server_data_to_push_bouncer(consider_usage_statistics=False) + invoice_plans_as_needed(self.next_month) + plan.refresh_from_db() + self.assertEqual(plan.next_invoice_date, add_months(self.next_month, 1)) + self.assertFalse(plan.invoice_overdue_email_sent) + + assert customer.stripe_customer_id + [invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id)) + + [invoice_item0, invoice_item1, invoice_item2] = iter(invoice0.lines) + invoice_item_params = { + "amount": 16 * 3.5 * 100, + "description": "Zulip Basic - renewal", + "quantity": 16, + "period": { + "start": datetime_to_timestamp(self.next_month), + "end": datetime_to_timestamp(add_months(self.next_month, 1)), + }, + } + for key, value in invoice_item_params.items(): + self.assertEqual(invoice_item1[key], value) + + invoice_item_params = { + "description": "Additional license (Jan 4, 2012 - Feb 2, 2012)", + "quantity": 5, + "period": { + "start": datetime_to_timestamp(self.now + timedelta(days=2)), + "end": datetime_to_timestamp(self.next_month), + }, + } + for key, value in invoice_item_params.items(): + self.assertEqual(invoice_item2[key], value) + + # Verify Zulip team receives mail for the next cycle. + invoice_plans_as_needed(add_months(self.next_month, 1)) + self.assert_length(outbox, messages_count + 1) + @override_settings(PUSH_NOTIFICATION_BOUNCER_URL="https://push.zulip.org.example.com") class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase): diff --git a/templates/zerver/emails/invoice_overdue.html b/templates/zerver/emails/invoice_overdue.html new file mode 100644 index 0000000000..8e8b20124b --- /dev/null +++ b/templates/zerver/emails/invoice_overdue.html @@ -0,0 +1,10 @@ +{% extends "zerver/emails/email_base_default.html" %} + +{% block content %} +Support URL: {{ support_url }} + +

+ +Last data upload: {{ last_audit_log_update }} + +{% endblock %} diff --git a/templates/zerver/emails/invoice_overdue.subject.txt b/templates/zerver/emails/invoice_overdue.subject.txt new file mode 100644 index 0000000000..56ace1e4a2 --- /dev/null +++ b/templates/zerver/emails/invoice_overdue.subject.txt @@ -0,0 +1 @@ +Invoice overdue due to stale data diff --git a/templates/zerver/emails/invoice_overdue.txt b/templates/zerver/emails/invoice_overdue.txt new file mode 100644 index 0000000000..7c7633be97 --- /dev/null +++ b/templates/zerver/emails/invoice_overdue.txt @@ -0,0 +1,3 @@ +Support URL: {{ support_url }} + +Last data upload: {{ last_audit_log_update }}