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