support: Add support to configure fixed-price plan with pay-by-invoice.

* Manually create & send invoice
* Configure a fixed-price plan with sent invoice-id.
* When customer pays, upgrade them to concerned plan.
This commit is contained in:
Prakhar Pratyush 2024-02-02 17:34:41 +05:30 committed by Tim Abbott
parent 79a1b3b80e
commit 2055dfa83e
14 changed files with 476 additions and 15 deletions

View File

@ -30,6 +30,7 @@ from corporate.models import (
Customer,
CustomerPlan,
CustomerPlanOffer,
Invoice,
LicenseLedger,
PaymentIntent,
Session,
@ -592,6 +593,7 @@ class SupportViewRequest(TypedDict, total=False):
plan_end_date: Optional[str]
required_plan_tier: Optional[int]
fixed_price: Optional[int]
sent_invoice_id: Optional[str]
class AuditLogEventType(Enum):
@ -671,6 +673,7 @@ class UpgradePageContext(TypedDict):
payment_method: Optional[str]
plan: str
fixed_price_plan: bool
pay_by_invoice_payments_page: Optional[str]
remote_server_legacy_plan_end_date: Optional[str]
salt: str
seat_count: int
@ -1244,7 +1247,7 @@ class BillingSession(ABC):
plan_tier_name = CustomerPlan.name_from_tier(new_plan_tier)
return f"Required plan tier for {self.billing_entity_display_name} set to {plan_tier_name}."
def configure_fixed_price_plan(self, fixed_price: int) -> str:
def configure_fixed_price_plan(self, fixed_price: int, sent_invoice_id: Optional[str]) -> str:
customer = self.get_customer()
if customer is None:
customer = self.update_or_create_customer()
@ -1291,6 +1294,25 @@ class BillingSession(ABC):
current_plan.save(update_fields=["status", "next_invoice_date"])
return f"Fixed price {required_plan_tier_name} plan scheduled to start on {current_plan.end_date.date()}."
if sent_invoice_id is not None:
sent_invoice_id = sent_invoice_id.strip()
# Verify 'sent_invoice_id' before storing in database.
try:
invoice = stripe.Invoice.retrieve(sent_invoice_id)
if invoice.status != "open":
raise SupportRequestError(
"Invoice status should be open. Please verify sent_invoice_id."
)
except Exception as e:
raise SupportRequestError(str(e))
fixed_price_plan_params["sent_invoice_id"] = sent_invoice_id
Invoice.objects.create(
customer=customer,
stripe_invoice_id=sent_invoice_id,
status=Invoice.SENT,
)
fixed_price_plan_params["status"] = CustomerPlanOffer.CONFIGURED
CustomerPlanOffer.objects.create(
customer=customer,
@ -1449,10 +1471,13 @@ class BillingSession(ABC):
free_trial: bool,
remote_server_legacy_plan: Optional[CustomerPlan] = None,
should_schedule_upgrade_for_legacy_remote_server: bool = False,
stripe_invoice_paid: bool = False,
) -> None:
is_self_hosted_billing = not isinstance(self, RealmBillingSession)
if stripe_invoice_paid:
customer = self.update_or_create_customer()
else:
customer = self.update_or_create_stripe_customer()
assert customer.stripe_customer_id is not None # for mypy
self.ensure_current_plan_is_upgradable(customer, plan_tier)
billing_cycle_anchor = None
@ -1513,6 +1538,7 @@ class BillingSession(ABC):
if charge_automatically:
# Ensure free trial customers not paying via invoice have a default payment method set
assert customer.stripe_customer_id is not None # for mypy
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
if not stripe_customer_has_credit_card_as_default_payment_method(
stripe_customer
@ -1534,6 +1560,7 @@ class BillingSession(ABC):
assert remote_server_legacy_plan is not None
if charge_automatically:
# Ensure customers not paying via invoice have a default payment method set.
assert customer.stripe_customer_id is not None # for mypy
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
if not stripe_customer_has_credit_card_as_default_payment_method(
stripe_customer
@ -1599,7 +1626,9 @@ class BillingSession(ABC):
extra_data=plan_params,
)
if not (free_trial or should_schedule_upgrade_for_legacy_remote_server):
if not stripe_invoice_paid and not (
free_trial or should_schedule_upgrade_for_legacy_remote_server
):
assert plan is not None
price_args: PriceArgs = {}
if plan.fixed_price is None:
@ -1611,6 +1640,7 @@ class BillingSession(ABC):
assert plan.fixed_price is not None
amount_due = get_amount_due_fixed_price_plan(plan.fixed_price, billing_schedule)
price_args = {"amount": amount_due}
assert customer.stripe_customer_id is not None
stripe.InvoiceItem.create(
currency="usd",
customer=customer.stripe_customer_id,
@ -2248,12 +2278,17 @@ class BillingSession(ABC):
tier = initial_upgrade_request.tier
fixed_price = None
pay_by_invoice_payments_page = None
if customer is not None:
fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, tier)
if fixed_price_plan_offer:
assert fixed_price_plan_offer.fixed_price is not None
fixed_price = fixed_price_plan_offer.fixed_price
if fixed_price_plan_offer.sent_invoice_id is not None:
invoice = stripe.Invoice.retrieve(fixed_price_plan_offer.sent_invoice_id)
pay_by_invoice_payments_page = invoice.hosted_invoice_url
percent_off = Decimal(0)
if customer is not None:
discount_for_plan_tier = customer.get_discount_for_plan_tier(tier)
@ -2321,6 +2356,7 @@ class BillingSession(ABC):
"payment_method": current_payment_method,
"plan": CustomerPlan.name_from_tier(tier),
"fixed_price_plan": fixed_price is not None,
"pay_by_invoice_payments_page": pay_by_invoice_payments_page,
"salt": salt,
"seat_count": seat_count,
"signed_seat_count": signed_seat_count,
@ -2954,7 +2990,8 @@ class BillingSession(ABC):
elif support_type == SupportType.configure_fixed_price_plan:
assert support_request["fixed_price"] is not None
new_fixed_price = support_request["fixed_price"]
success_message = self.configure_fixed_price_plan(new_fixed_price)
sent_invoice_id = support_request["sent_invoice_id"]
success_message = self.configure_fixed_price_plan(new_fixed_price, sent_invoice_id)
elif support_type == SupportType.update_billing_modality:
assert support_request["billing_modality"] is not None
assert support_request["billing_modality"] in VALID_BILLING_MODALITY_VALUES

View File

@ -11,8 +11,9 @@ from corporate.lib.stripe import (
RemoteRealmBillingSession,
RemoteServerBillingSession,
UpgradeWithExistingPlanError,
get_configured_fixed_price_plan_offer,
)
from corporate.models import Customer, CustomerPlan, Event, PaymentIntent, Session
from corporate.models import Customer, CustomerPlan, Event, Invoice, PaymentIntent, Session
from zerver.models.users import get_active_user_profile_by_id_in_realm
billing_logger = logging.getLogger("corporate.stripe")
@ -20,9 +21,10 @@ billing_logger = logging.getLogger("corporate.stripe")
def error_handler(
func: Callable[[Any, Any], None],
) -> Callable[[Union[stripe.checkout.Session, stripe.PaymentIntent], Event], None]:
) -> Callable[[Union[stripe.checkout.Session, stripe.PaymentIntent, stripe.Invoice], Event], None]:
def wrapper(
stripe_object: Union[stripe.checkout.Session, stripe.PaymentIntent], event: Event
stripe_object: Union[stripe.checkout.Session, stripe.PaymentIntent, stripe.Invoice],
event: Event,
) -> None:
event.status = Event.EVENT_HANDLER_STARTED
event.save(update_fields=["status"])
@ -150,3 +152,34 @@ def handle_payment_intent_succeeded_event(
False,
billing_session.get_remote_server_legacy_plan(payment_intent.customer),
)
@error_handler
def handle_invoice_paid_event(stripe_invoice: stripe.Invoice, invoice: Invoice) -> None:
invoice.status = Invoice.PAID
invoice.save(update_fields=["status"])
customer = invoice.customer
configured_fixed_price_plan = None
if customer.required_plan_tier:
configured_fixed_price_plan = get_configured_fixed_price_plan_offer(
customer, customer.required_plan_tier
)
if stripe_invoice.collection_method == "send_invoice" and configured_fixed_price_plan:
billing_session = get_billing_session_for_stripe_webhook(customer, user_id=None)
remote_server_legacy_plan = billing_session.get_remote_server_legacy_plan(customer)
assert customer.required_plan_tier is not None
billing_session.process_initial_upgrade(
plan_tier=customer.required_plan_tier,
# TODO: Currently licenses don't play any role for fixed price plan.
# We plan to introduce max_licenses allowed soon.
licenses=0,
automanage_licenses=True,
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
charge_automatically=False,
free_trial=False,
remote_server_legacy_plan=remote_server_legacy_plan,
stripe_invoice_paid=True,
)

View File

@ -68,6 +68,7 @@ class PlanData:
next_billing_cycle_start: Optional[datetime] = None
is_legacy_plan: bool = False
has_fixed_price: bool = False
is_current_plan_billable: bool = False
warning: Optional[str] = None
annual_recurring_revenue: Optional[int] = None
estimated_next_plan_revenue: Optional[int] = None
@ -219,6 +220,9 @@ def get_current_plan_data_for_support_view(billing_session: BillingSession) -> P
plan_data.current_plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY
)
plan_data.has_fixed_price = plan_data.current_plan.fixed_price is not None
plan_data.is_current_plan_billable = billing_session.check_plan_tier_is_billable(
plan_tier=plan_data.current_plan.tier
)
annual_invoice_count = get_annual_invoice_count(plan_data.current_plan.billing_schedule)
if last_ledger_entry is not None:
plan_data.annual_recurring_revenue = (

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.9 on 2024-02-02 10:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0037_customerplanoffer"),
]
operations = [
migrations.AddField(
model_name="customerplanoffer",
name="sent_invoice_id",
field=models.CharField(max_length=255, null=True),
),
migrations.CreateModel(
name="Invoice",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_invoice_id", models.CharField(max_length=255, unique=True)),
("status", models.SmallIntegerField()),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
],
),
]

View File

@ -215,6 +215,15 @@ class PaymentIntent(models.Model):
return payment_intent_dict
class Invoice(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_invoice_id = models.CharField(max_length=255, unique=True)
SENT = 1
PAID = 2
status = models.SmallIntegerField()
class AbstractCustomerPlan(models.Model):
# A customer can only have one ACTIVE / CONFIGURED plan,
# but old, inactive / processed plans are preserved to allow
@ -249,6 +258,9 @@ class CustomerPlanOffer(AbstractCustomerPlan):
PROCESSED = 2
status = models.SmallIntegerField()
# ID of invoice sent when chose to 'Pay by invoice'.
sent_invoice_id = models.CharField(max_length=255, null=True)
@override
def __str__(self) -> str:
return f"{self.name} (status: {self.get_plan_status_as_text()})"

View File

@ -25,7 +25,7 @@ from typing import (
cast,
)
from unittest import mock
from unittest.mock import Mock, patch
from unittest.mock import MagicMock, Mock, patch
import orjson
import responses
@ -84,6 +84,7 @@ from corporate.models import (
CustomerPlan,
CustomerPlanOffer,
Event,
Invoice,
LicenseLedger,
PaymentIntent,
ZulipSponsorshipRequest,
@ -4188,6 +4189,56 @@ class StripeWebhookEndpointTest(ZulipTestCase):
self.assertEqual(result.status_code, 200)
m.assert_not_called()
def test_stripe_webhook_for_invoice_paid_events(self) -> None:
customer = Customer.objects.create(realm=get_realm("zulip"))
stripe_event_id = "stripe_event_id"
stripe_invoice_id = "stripe_invoice_id"
valid_invoice_paid_event_data = {
"id": stripe_event_id,
"type": "invoice.paid",
"api_version": STRIPE_API_VERSION,
"data": {"object": {"object": "invoice", "id": stripe_invoice_id}},
}
with patch("corporate.views.webhook.handle_invoice_paid_event") as m:
result = self.client_post(
"/stripe/webhook/",
valid_invoice_paid_event_data,
content_type="application/json",
)
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0)
self.assertEqual(result.status_code, 200)
m.assert_not_called()
Invoice.objects.create(
stripe_invoice_id=stripe_invoice_id,
customer=customer,
status=Invoice.SENT,
)
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0)
with patch("corporate.views.webhook.handle_invoice_paid_event") as m:
result = self.client_post(
"/stripe/webhook/",
valid_invoice_paid_event_data,
content_type="application/json",
)
[event] = Event.objects.filter(stripe_event_id=stripe_event_id)
self.assertEqual(result.status_code, 200)
strip_event = stripe.Event.construct_from(valid_invoice_paid_event_data, stripe.api_key)
m.assert_called_once_with(strip_event.data.object, event)
with patch("corporate.views.webhook.handle_invoice_paid_event") as m:
result = self.client_post(
"/stripe/webhook/",
valid_invoice_paid_event_data,
content_type="application/json",
)
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 1)
self.assertEqual(result.status_code, 200)
m.assert_not_called()
class EventStatusTest(StripeTestCase):
def test_event_status_json_endpoint_errors(self) -> None:
@ -6311,6 +6362,143 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
]:
self.assert_in_response(substring, response)
@responses.activate
@mock_stripe()
def test_upgrade_user_to_fixed_price_plan_pay_by_invoice(self, *mocks: Mock) -> None:
self.login("iago")
self.add_mock_response()
with time_machine.travel(self.now, tick=False):
send_server_data_to_push_bouncer(consider_usage_statistics=False)
self.assertFalse(CustomerPlanOffer.objects.exists())
# Configure required_plan_tier.
result = self.client_post(
"/activity/remote/support",
{
"remote_realm_id": f"{self.remote_realm.id}",
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
},
)
self.assert_in_success_response(
["Required plan tier for Zulip Dev set to Zulip Basic."], result
)
# Configure fixed-price plan with ID of manually sent invoice.
# Invalid 'sent_invoice_id' entered.
annual_fixed_price = 1200
with mock.patch("stripe.Invoice.retrieve", side_effect=Exception):
result = self.client_post(
"/activity/remote/support",
{
"remote_realm_id": f"{self.remote_realm.id}",
"fixed_price": annual_fixed_price,
"sent_invoice_id": "invalid_sent_invoice_id",
},
)
self.assert_not_in_success_response(
["Customer can now buy a fixed price Zulip Basic plan."], result
)
# Invoice status is not 'open'.
mock_invoice = MagicMock()
mock_invoice.status = "paid"
mock_invoice.sent_invoice_id = "paid_invoice_id"
with mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice):
result = self.client_post(
"/activity/remote/support",
{
"remote_realm_id": f"{self.remote_realm.id}",
"fixed_price": annual_fixed_price,
"sent_invoice_id": mock_invoice.sent_invoice_id,
},
)
self.assert_in_success_response(
["Invoice status should be open. Please verify sent_invoice_id."], result
)
sent_invoice_id = "test_sent_invoice_id"
mock_invoice = MagicMock()
mock_invoice.status = "open"
mock_invoice.sent_invoice_id = sent_invoice_id
with mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice):
result = self.client_post(
"/activity/remote/support",
{
"remote_realm_id": f"{self.remote_realm.id}",
"fixed_price": annual_fixed_price,
"sent_invoice_id": sent_invoice_id,
},
)
self.assert_in_success_response(
["Customer can now buy a fixed price Zulip Basic plan."], result
)
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
status=CustomerPlanOffer.CONFIGURED
).first()
assert fixed_price_plan_offer is not None
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
self.assertEqual(fixed_price_plan_offer.sent_invoice_id, sent_invoice_id)
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
invoice = Invoice.objects.get(stripe_invoice_id=sent_invoice_id)
self.assertEqual(invoice.status, Invoice.SENT)
self.logout()
self.login("hamlet")
hamlet = self.example_user("hamlet")
# Customer don't need to visit /upgrade to buy plan.
# In case they visit, we inform them about the mail to which
# invoice was sent and also display the link for payment.
self.execute_remote_billing_authentication_flow(hamlet)
mock_invoice = MagicMock()
mock_invoice.hosted_invoice_url = "payments_page_url"
with time_machine.travel(self.now, tick=False), mock.patch(
"stripe.Invoice.retrieve", return_value=mock_invoice
):
result = self.client_get(
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
subdomain="selfhosting",
)
self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["payments_page_url", hamlet.delivery_email], result)
# When customer makes a payment, 'stripe_webhook' handles 'invoice.paid' event.
stripe_event_id = "stripe_event_id"
valid_invoice_paid_event_data = {
"id": stripe_event_id,
"type": "invoice.paid",
"api_version": STRIPE_API_VERSION,
"data": {
"object": {
"object": "invoice",
"id": sent_invoice_id,
"collection_method": "send_invoice",
}
},
}
with time_machine.travel(self.now, tick=False):
result = self.client_post(
"/stripe/webhook/",
valid_invoice_paid_event_data,
content_type="application/json",
)
self.assertEqual(result.status_code, 200)
# Verify that the customer is upgraded after payment.
customer = self.billing_session.get_customer()
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
self.assertEqual(current_plan.fixed_price, annual_fixed_price * 100)
self.assertIsNone(current_plan.price_per_license)
invoice.refresh_from_db()
fixed_price_plan_offer.refresh_from_db()
self.assertEqual(invoice.status, Invoice.PAID)
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
@responses.activate
@mock_stripe()
def test_schedule_upgrade_to_fixed_price_annual_business_plan(self, *mocks: Mock) -> None:
@ -7683,6 +7871,112 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
]:
self.assert_in_response(substring, response)
@responses.activate
@mock_stripe()
def test_upgrade_server_to_fixed_price_plan_pay_by_invoice(self, *mocks: Mock) -> None:
self.login("iago")
self.add_mock_response()
with time_machine.travel(self.now, tick=False):
send_server_data_to_push_bouncer(consider_usage_statistics=False)
self.assertFalse(CustomerPlanOffer.objects.exists())
# Configure required_plan_tier.
result = self.client_post(
"/activity/remote/support",
{
"remote_server_id": f"{self.remote_server.id}",
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
},
)
self.assert_in_success_response(
["Required plan tier for demo.example.com set to Zulip Basic."], result
)
# Configure fixed-price plan with ID of manually sent invoice.
sent_invoice_id = "test_sent_invoice_id"
annual_fixed_price = 1200
mock_invoice = MagicMock()
mock_invoice.status = "open"
mock_invoice.sent_invoice_id = sent_invoice_id
with mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice):
result = self.client_post(
"/activity/remote/support",
{
"remote_server_id": f"{self.remote_server.id}",
"fixed_price": annual_fixed_price,
"sent_invoice_id": sent_invoice_id,
},
)
self.assert_in_success_response(
["Customer can now buy a fixed price Zulip Basic plan."], result
)
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
status=CustomerPlanOffer.CONFIGURED
).first()
assert fixed_price_plan_offer is not None
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
self.assertEqual(fixed_price_plan_offer.sent_invoice_id, sent_invoice_id)
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
invoice = Invoice.objects.get(stripe_invoice_id=sent_invoice_id)
self.assertEqual(invoice.status, Invoice.SENT)
self.logout()
self.login("hamlet")
hamlet = self.example_user("hamlet")
# Customer don't need to visit /upgrade to buy plan.
# In case they visit, we inform them about the mail to which
# invoice was sent and also display the link for payment.
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
mock_invoice = MagicMock()
mock_invoice.hosted_invoice_url = "payments_page_url"
with time_machine.travel(self.now, tick=False), mock.patch(
"stripe.Invoice.retrieve", return_value=mock_invoice
):
result = self.client_get(
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
subdomain="selfhosting",
)
self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["payments_page_url", hamlet.delivery_email], result)
# When customer makes a payment, 'stripe_webhook' handles 'invoice.paid' event.
stripe_event_id = "stripe_event_id"
valid_invoice_paid_event_data = {
"id": stripe_event_id,
"type": "invoice.paid",
"api_version": STRIPE_API_VERSION,
"data": {
"object": {
"object": "invoice",
"id": sent_invoice_id,
"collection_method": "send_invoice",
}
},
}
with time_machine.travel(self.now, tick=False):
result = self.client_post(
"/stripe/webhook/",
valid_invoice_paid_event_data,
content_type="application/json",
)
self.assertEqual(result.status_code, 200)
# Verify that the customer is upgraded after payment.
customer = self.billing_session.get_customer()
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
self.assertEqual(current_plan.fixed_price, annual_fixed_price * 100)
self.assertIsNone(current_plan.price_per_license)
invoice.refresh_from_db()
fixed_price_plan_offer.refresh_from_db()
self.assertEqual(invoice.status, Invoice.PAID)
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
@responses.activate
@mock_stripe()
def test_schedule_server_upgrade_to_fixed_price_business_plan(self, *mocks: Mock) -> None:

View File

@ -58,6 +58,7 @@ from zerver.lib.subdomains import get_subdomain_from_hostname
from zerver.lib.validator import (
check_bool,
check_date,
check_string,
check_string_in,
to_decimal,
to_non_negative_int,
@ -492,6 +493,7 @@ def remote_servers_support(
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
fixed_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
sent_invoice_id: Optional[str] = REQ(default=None, str_validator=check_string),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
billing_modality: Optional[str] = REQ(
@ -511,14 +513,9 @@ def remote_servers_support(
acting_user = request.user
assert isinstance(acting_user, UserProfile)
if settings.BILLING_ENABLED and request.method == "POST":
# We check that request.POST only has two keys in it:
# either the remote_server_id or a remote_realm_id,
# and a field to change.
keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
raise JsonableError(_("Invalid parameters"))
if remote_realm_id is not None:
remote_realm_support_request = True
@ -553,9 +550,13 @@ def remote_servers_support(
required_plan_tier=required_plan_tier,
)
elif fixed_price is not None:
# Treat empty field submitted as None.
if sent_invoice_id is not None and sent_invoice_id.strip() == "":
sent_invoice_id = None
support_view_request = SupportViewRequest(
support_type=SupportType.configure_fixed_price_plan,
fixed_price=fixed_price,
sent_invoice_id=sent_invoice_id,
)
elif billing_modality is not None:
support_view_request = SupportViewRequest(

View File

@ -10,9 +10,10 @@ from django.views.decorators.csrf import csrf_exempt
from corporate.lib.stripe import STRIPE_API_VERSION
from corporate.lib.stripe_event_handler import (
handle_checkout_session_completed_event,
handle_invoice_paid_event,
handle_payment_intent_succeeded_event,
)
from corporate.models import Event, PaymentIntent, Session
from corporate.models import Event, Invoice, PaymentIntent, Session
from zproject.config import get_secret
billing_logger = logging.getLogger("corporate.stripe")
@ -50,6 +51,7 @@ def stripe_webhook(request: HttpRequest) -> HttpResponse:
if stripe_event.type not in [
"checkout.session.completed",
"payment_intent.succeeded",
"invoice.paid",
]:
return HttpResponse(status=200)
@ -84,6 +86,17 @@ def stripe_webhook(request: HttpRequest) -> HttpResponse:
event.object_id = payment_intent.id
event.save()
handle_payment_intent_succeeded_event(stripe_payment_intent, event)
elif stripe_event.type == "invoice.paid":
stripe_invoice = stripe_event.data.object
assert isinstance(stripe_invoice, stripe.Invoice)
try:
invoice = Invoice.objects.get(stripe_invoice_id=stripe_invoice.id)
except Invoice.DoesNotExist:
return HttpResponse(status=200)
event.content_type = ContentType.objects.get_for_model(Invoice)
event.object_id = invoice.id
event.save()
handle_invoice_paid_event(stripe_invoice, event)
# We don't need to process failed payments via webhooks since we directly charge users
# when they click on "Purchase" button and immediately provide feedback for failed payments.
# If the feedback is not immediate, our event_status handler checks for payment status and informs the user.

View File

@ -36,6 +36,13 @@
</div>
<div class="white-box">
<div id="upgrade-page-details">
{% if pay_by_invoice_payments_page %}
<div id="pay-by-invoice-details">
<a href="{{ pay_by_invoice_payments_page }}" target="_blank" rel="noopener noreferrer">An invoice</a>
for $<span class="due-today-price"></span> has been sent to <b>{{email}}</b>.
To complete the plan upgrade process, please pay the amount due.
</div>
{% else %}
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}" />
<form id="autopay-form">
<input type="hidden" name="seat_count" value="{{ seat_count }}" />
@ -244,9 +251,14 @@
{% endif %}
<input type="hidden" name="tier" value="{{ page_params.tier }}" />
</form>
{% endif %}
<div class="input-box upgrade-page-field">
<div class="support-link not-editable-realm-field">
{% if pay_by_invoice_payments_page %}
If you have questions or need to make any changes, please contact <a href="mailto:sales@zulip.com">sales@zulip.com</a>.
{% else %}
To pay by invoice or for any other questions, contact <a href="mailto:sales@zulip.com">sales@zulip.com</a>.
{% endif %}
</div>
</div>
</div>

View File

@ -11,5 +11,8 @@
<b>Estimated billed licenses</b>: {{ plan_data.current_plan.licenses_at_next_renewal() }}<br />
{% elif plan_data.next_plan.fixed_price %}
<b>Plan has a fixed price.</b><br />
{% if plan_data.next_plan.sent_invoice_id %}
<b>Payment pending for Invoice ID</b>: {{plan_data.next_plan.sent_invoice_id}}<br />
{% endif %}
{% endif %}
<b>Estimated annual revenue</b>: ${{ dollar_amount(plan_data.estimated_next_plan_revenue) }}<br />

View File

@ -1,8 +1,14 @@
<h4>⏱️ Schedule fixed price plan:</h4>
<form method="POST" class="remote-form">
<b>Fixed price</b><br />
{% if not is_current_plan_billable %}
<i>Enter Invoice ID only if the customer chose to pay by invoice.</i><br />
{% endif %}
{{ csrf_input }}
<input type="hidden" name="{{ remote_type }}" value="{{ remote_id }}" />
<input type="number" name="fixed_price" placeholder="Annual amount in dollars" required />
{% if not is_current_plan_billable %}
<input type="text" name="sent_invoice_id" placeholder="Sent invoice ID" />
{% endif %}
<button type="submit" class="support-submit-button">Schedule</button>
</form>

View File

@ -68,6 +68,7 @@
</div>
{% else %}
{% with %}
{% set is_current_plan_billable = support_data[remote_realm.id].plan_data.is_current_plan_billable %}
{% set remote_id = remote_realm.id %}
{% set remote_type = "remote_realm_id" %}
{% include 'corporate/support/next_plan_forms_support.html' %}

View File

@ -108,6 +108,7 @@
</div>
{% else %}
{% with %}
{% set is_current_plan_billable = remote_servers_support_data[remote_server.id].plan_data.is_current_plan_billable %}
{% set remote_id = remote_server.id %}
{% set remote_type = "remote_server_id" %}
{% include 'corporate/support/next_plan_forms_support.html' %}

View File

@ -734,3 +734,9 @@ input[name="licenses"] {
.flat-discount-separator {
border-bottom: 1px solid hsl(0deg 0% 0%);
}
#upgrade-page-details #pay-by-invoice-details {
width: 450px;
font-weight: normal;
padding-left: 15px;
}