mirror of https://github.com/zulip/zulip.git
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:
parent
79a1b3b80e
commit
2055dfa83e
|
@ -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)
|
||||
customer = self.update_or_create_stripe_customer()
|
||||
assert customer.stripe_customer_id is not None # for mypy
|
||||
if stripe_invoice_paid:
|
||||
customer = self.update_or_create_customer()
|
||||
else:
|
||||
customer = self.update_or_create_stripe_customer()
|
||||
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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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()})"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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' %}
|
||||
|
|
|
@ -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' %}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue