stripe: Add cron-based plan invoicing to remote realm billing system.

This commit is contained in:
Prakhar Pratyush 2024-01-08 17:58:06 +05:30 committed by Tim Abbott
parent 6088186223
commit 11908c4c2e
36 changed files with 168 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
{% extends "zerver/emails/email_base_default.html" %}
{% block content %}
<b>Support URL</b>: <a href="{{ support_url }}">{{ support_url }}</a>
<br /><br />
<b>Last data upload</b>: {{ last_audit_log_update }}
{% endblock %}

View File

@ -0,0 +1 @@
Invoice overdue due to stale data

View File

@ -0,0 +1,3 @@
Support URL: {{ support_url }}
Last data upload: {{ last_audit_log_update }}