stripe: Send invoice to customer at the start of free trial.

We send customer an invoice at the start of free trial, if customer
pays we upgrade them to the active plan at the end of free trial,
else we downgrade them and show a custom message on the upgrade
page regarding the current status.
This commit is contained in:
Aman Agrawal 2024-04-10 08:31:15 +00:00 committed by Tim Abbott
parent 8715ead8bc
commit ed2de77895
1441 changed files with 542 additions and 60 deletions

View File

@ -685,6 +685,8 @@ class UpgradePageContext(TypedDict):
is_sponsorship_pending: bool is_sponsorship_pending: bool
sponsorship_plan_name: str sponsorship_plan_name: str
scheduled_upgrade_invoice_amount_due: Optional[str] scheduled_upgrade_invoice_amount_due: Optional[str]
is_free_trial_invoice_expired_notice: bool
free_trial_invoice_expired_notice_page_plan_name: Optional[str]
class SponsorshipRequestForm(forms.Form): class SponsorshipRequestForm(forms.Form):
@ -868,6 +870,9 @@ class BillingSession(ABC):
charge_automatically: bool, charge_automatically: bool,
invoice_period: Dict[str, int], invoice_period: Dict[str, int],
license_management: Optional[str] = None, license_management: Optional[str] = None,
days_until_due: Optional[int] = None,
on_free_trial: bool = False,
current_plan_id: Optional[int] = None,
) -> stripe.Invoice: ) -> stripe.Invoice:
plan_name = CustomerPlan.name_from_tier(plan_tier) plan_name = CustomerPlan.name_from_tier(plan_tier)
assert price_per_license is None or fixed_price is None assert price_per_license is None or fixed_price is None
@ -910,12 +915,12 @@ class BillingSession(ABC):
if charge_automatically: if charge_automatically:
collection_method = "charge_automatically" collection_method = "charge_automatically"
days_until_due = None
else: else:
collection_method = "send_invoice" collection_method = "send_invoice"
# days_until_due is required for `send_invoice` collection method. Since this is an invoice # days_until_due is required for `send_invoice` collection method. Since this is an invoice
# for upgrade, the due date is irrelevant since customer will upgrade once they pay the invoice # for upgrade, the due date is irrelevant since customer will upgrade once they pay the invoice
# regardless of the due date. Using `1` shows `Due today / tomorrow` which seems nice. # regardless of the due date. Using `1` shows `Due today / tomorrow` which seems nice.
if days_until_due is None:
days_until_due = 1 days_until_due = 1
metadata = { metadata = {
@ -923,6 +928,8 @@ class BillingSession(ABC):
"billing_schedule": billing_schedule, "billing_schedule": billing_schedule,
"licenses": licenses, "licenses": licenses,
"license_management": license_management, "license_management": license_management,
"on_free_trial": on_free_trial,
"current_plan_id": current_plan_id,
} }
if hasattr(self, "user"): if hasattr(self, "user"):
@ -1156,6 +1163,8 @@ class BillingSession(ABC):
assert stripe_customer.invoice_settings.default_payment_method is not None assert stripe_customer.invoice_settings.default_payment_method is not None
stripe_invoice = None stripe_invoice = None
try: try:
current_plan_id = metadata.get("current_plan_id")
on_free_trial = bool(metadata.get("on_free_trial"))
stripe_invoice = self.generate_invoice_for_upgrade( stripe_invoice = self.generate_invoice_for_upgrade(
customer, customer,
metadata["price_per_license"], metadata["price_per_license"],
@ -1166,13 +1175,20 @@ class BillingSession(ABC):
charge_automatically=charge_automatically, charge_automatically=charge_automatically,
license_management=metadata["license_management"], license_management=metadata["license_management"],
invoice_period=metadata["invoice_period"], invoice_period=metadata["invoice_period"],
days_until_due=metadata.get("days_until_due"),
on_free_trial=on_free_trial,
current_plan_id=current_plan_id,
) )
assert stripe_invoice.id is not None assert stripe_invoice.id is not None
invoice = Invoice.objects.create( invoice = Invoice.objects.create(
stripe_invoice_id=stripe_invoice.id, stripe_invoice_id=stripe_invoice.id,
customer=customer, customer=customer,
status=Invoice.SENT, status=Invoice.SENT,
plan_id=current_plan_id,
is_created_for_free_trial_upgrade=current_plan_id is not None and on_free_trial,
) )
if charge_automatically: if charge_automatically:
# Stripe takes its sweet hour to charge customers after creating an invoice. # Stripe takes its sweet hour to charge customers after creating an invoice.
# Since we want to charge customers immediately, we charge them manually. # Since we want to charge customers immediately, we charge them manually.
@ -1583,6 +1599,9 @@ class BillingSession(ABC):
license_management: str, license_management: str,
billing_schedule: int, billing_schedule: int,
billing_modality: str, billing_modality: str,
on_free_trial: bool = False,
days_until_due: Optional[int] = None,
current_plan_id: Optional[int] = None,
) -> str: ) -> str:
customer = self.update_or_create_stripe_customer() customer = self.update_or_create_stripe_customer()
assert customer is not None # for mypy assert customer is not None # for mypy
@ -1596,6 +1615,9 @@ class BillingSession(ABC):
"fixed_price": None, "fixed_price": None,
"type": "upgrade", "type": "upgrade",
"plan_tier": plan_tier, "plan_tier": plan_tier,
"on_free_trial": on_free_trial,
"days_until_due": days_until_due,
"current_plan_id": current_plan_id,
} }
discount_for_plan = customer.get_discount_for_plan_tier(plan_tier) discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
( (
@ -1607,9 +1629,7 @@ class BillingSession(ABC):
plan_tier, plan_tier,
billing_schedule, billing_schedule,
discount_for_plan, discount_for_plan,
# TODO: Use the correct value for free_trial when we switch behaviour to send invoice on_free_trial,
# at the start of free trial.
False,
None, None,
not isinstance(self, RealmBillingSession), not isinstance(self, RealmBillingSession),
) )
@ -1621,6 +1641,14 @@ class BillingSession(ABC):
invoice_period_start, CustomerPlan.FIXED_PRICE_PLAN_DURATION_MONTHS invoice_period_start, CustomerPlan.FIXED_PRICE_PLAN_DURATION_MONTHS
) )
if on_free_trial and billing_modality == "send_invoice":
# Paid plan starts at the end of free trial.
invoice_period_start = invoice_period_end
purchased_months = 1
if billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
purchased_months = 12
invoice_period_end = add_months(invoice_period_end, purchased_months)
general_metadata["invoice_period"] = { general_metadata["invoice_period"] = {
"start": datetime_to_timestamp(invoice_period_start), "start": datetime_to_timestamp(invoice_period_start),
"end": datetime_to_timestamp(invoice_period_end), "end": datetime_to_timestamp(invoice_period_end),
@ -1855,6 +1883,8 @@ class BillingSession(ABC):
if not stripe_invoice_paid and not ( if not stripe_invoice_paid and not (
free_trial or should_schedule_upgrade_for_legacy_remote_server free_trial or should_schedule_upgrade_for_legacy_remote_server
): ):
# We don't actually expect to ever reach here but this is just a safety net
# in case any future changes make this possible.
assert plan is not None assert plan is not None
self.generate_invoice_for_upgrade( self.generate_invoice_for_upgrade(
customer, customer,
@ -1869,6 +1899,22 @@ class BillingSession(ABC):
"end": datetime_to_timestamp(period_end), "end": datetime_to_timestamp(period_end),
}, },
) )
elif free_trial and not charge_automatically:
assert stripe_invoice_paid is False
assert plan is not None
assert plan.next_invoice_date is not None
# Send an invoice to the customer which expires at the end of free trial. If the customer
# fails to pay the invoice before expiration, we downgrade the customer.
self.generate_stripe_invoice(
plan_tier,
licenses=billed_licenses,
license_management="automatic" if automanage_licenses else "manual",
billing_schedule=billing_schedule,
billing_modality="send_invoice",
on_free_trial=True,
days_until_due=(plan.next_invoice_date - event_time).days,
current_plan_id=plan.id,
)
def do_upgrade(self, upgrade_request: UpgradeRequest) -> Dict[str, Any]: def do_upgrade(self, upgrade_request: UpgradeRequest) -> Dict[str, Any]:
customer = self.get_customer() customer = self.get_customer()
@ -1950,6 +1996,9 @@ class BillingSession(ABC):
return data return data
def do_change_schedule_after_free_trial(self, plan: CustomerPlan, schedule: int) -> None: def do_change_schedule_after_free_trial(self, plan: CustomerPlan, schedule: int) -> None:
# NOTE: Schedule change for free trial with invoice payments is not supported due to complication
# involving sending another invoice and handling payment difference if customer already paid.
assert plan.charge_automatically
# Change the billing frequency of the plan after the free trial ends. # Change the billing frequency of the plan after the free trial ends.
assert schedule in ( assert schedule in (
CustomerPlan.BILLING_SCHEDULE_MONTHLY, CustomerPlan.BILLING_SCHEDULE_MONTHLY,
@ -2074,13 +2123,28 @@ class BillingSession(ABC):
licenses_at_next_renewal=licenses_at_next_renewal, licenses_at_next_renewal=licenses_at_next_renewal,
) )
if plan.is_free_trial(): if plan.is_free_trial():
is_renewal = True
# Check if user has already paid for the plan by invoice.
if not plan.charge_automatically:
last_sent_invoice = Invoice.objects.filter(plan=plan).order_by("-id").first()
if last_sent_invoice and last_sent_invoice.status == Invoice.PAID:
# This will create invoice for any additional licenses that user has at the time of
# switching from free trial to paid plan since they already paid for the plan's this billing cycle.
is_renewal = False
else:
# We end the free trial since customer hasn't paid.
plan.status = CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
plan.save(update_fields=["status"])
self.make_end_of_cycle_updates_if_needed(plan, event_time)
return None, None
plan.invoiced_through = last_ledger_entry plan.invoiced_through = last_ledger_entry
plan.billing_cycle_anchor = next_billing_cycle.replace(microsecond=0) plan.billing_cycle_anchor = next_billing_cycle.replace(microsecond=0)
plan.status = CustomerPlan.ACTIVE plan.status = CustomerPlan.ACTIVE
plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"]) plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"])
return None, LicenseLedger.objects.create( return None, LicenseLedger.objects.create(
plan=plan, plan=plan,
is_renewal=True, is_renewal=is_renewal,
event_time=next_billing_cycle, event_time=next_billing_cycle,
licenses=licenses_at_next_renewal, licenses=licenses_at_next_renewal,
licenses_at_next_renewal=licenses_at_next_renewal, licenses_at_next_renewal=licenses_at_next_renewal,
@ -2245,6 +2309,7 @@ class BillingSession(ABC):
last_ledger_entry: LicenseLedger, last_ledger_entry: LicenseLedger,
now: datetime, now: datetime,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
is_self_hosted_billing = not isinstance(self, RealmBillingSession)
downgrade_at_end_of_cycle = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE downgrade_at_end_of_cycle = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
downgrade_at_end_of_free_trial = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL downgrade_at_end_of_free_trial = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
switch_to_annual_at_end_of_cycle = ( switch_to_annual_at_end_of_cycle = (
@ -2272,6 +2337,27 @@ class BillingSession(ABC):
dt=start_of_next_billing_cycle(plan, now) dt=start_of_next_billing_cycle(plan, now)
) )
has_paid_invoice_for_free_trial = False
free_trial_next_renewal_date_after_invoice_paid = None
if plan.is_free_trial() and not plan.charge_automatically:
last_sent_invoice = Invoice.objects.filter(plan=plan).order_by("-id").first()
# If the customer doesn't have any invoice, this likely means a bug and customer needs to be handled manually.
assert last_sent_invoice is not None
has_paid_invoice_for_free_trial = last_sent_invoice.status == Invoice.PAID
if has_paid_invoice_for_free_trial:
assert plan.next_invoice_date is not None
free_trial_days = get_free_trial_days(is_self_hosted_billing, plan.tier)
assert free_trial_days is not None
free_trial_next_renewal_date_after_invoice_paid = (
"{dt:%B} {dt.day}, {dt.year}".format(
dt=(
start_of_next_billing_cycle(plan, plan.next_invoice_date)
+ timedelta(days=free_trial_days)
)
)
)
billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule] billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule]
discount_for_current_plan = plan.discount discount_for_current_plan = plan.discount
@ -2328,7 +2414,6 @@ class BillingSession(ABC):
customer, status=CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END customer, status=CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
) )
legacy_remote_server_next_plan_name = self.get_legacy_remote_server_next_plan_name(customer) legacy_remote_server_next_plan_name = self.get_legacy_remote_server_next_plan_name(customer)
is_self_hosted_billing = not isinstance(self, RealmBillingSession)
context = { context = {
"plan_name": plan.name, "plan_name": plan.name,
"has_active_plan": True, "has_active_plan": True,
@ -2364,6 +2449,8 @@ class BillingSession(ABC):
"pre_discount_renewal_cents": cents_to_dollar_string(pre_discount_renewal_cents), "pre_discount_renewal_cents": cents_to_dollar_string(pre_discount_renewal_cents),
"flat_discount": format_money(customer.flat_discount), "flat_discount": format_money(customer.flat_discount),
"discounted_months_left": customer.flat_discounted_months, "discounted_months_left": customer.flat_discounted_months,
"has_paid_invoice_for_free_trial": has_paid_invoice_for_free_trial,
"free_trial_next_renewal_date_after_invoice_paid": free_trial_next_renewal_date_after_invoice_paid,
} }
return context return context
@ -2461,6 +2548,8 @@ class BillingSession(ABC):
fixed_price = None fixed_price = None
pay_by_invoice_payments_page = None pay_by_invoice_payments_page = None
scheduled_upgrade_invoice_amount_due = None scheduled_upgrade_invoice_amount_due = None
is_free_trial_invoice_expired_notice = False
free_trial_invoice_expired_notice_page_plan_name = None
if customer is not None: if customer is not None:
fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, tier) fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, tier)
if fixed_price_plan_offer: if fixed_price_plan_offer:
@ -2485,6 +2574,17 @@ class BillingSession(ABC):
scheduled_upgrade_invoice_amount_due = format_money(invoice.amount_due) scheduled_upgrade_invoice_amount_due = format_money(invoice.amount_due)
pay_by_invoice_payments_page = f"{self.billing_base_url}/invoices" pay_by_invoice_payments_page = f"{self.billing_base_url}/invoices"
if (
last_send_invoice.plan is not None
and last_send_invoice.is_created_for_free_trial_upgrade
):
# Automatic payment invoice would have been marked void already.
assert not last_send_invoice.plan.charge_automatically
is_free_trial_invoice_expired_notice = True
free_trial_invoice_expired_notice_page_plan_name = (
last_send_invoice.plan.name
)
percent_off = Decimal(0) percent_off = Decimal(0)
if customer is not None: if customer is not None:
discount_for_plan_tier = customer.get_discount_for_plan_tier(tier) discount_for_plan_tier = customer.get_discount_for_plan_tier(tier)
@ -2571,19 +2671,23 @@ class BillingSession(ABC):
customer, is_self_hosted_billing customer, is_self_hosted_billing
), ),
"scheduled_upgrade_invoice_amount_due": scheduled_upgrade_invoice_amount_due, "scheduled_upgrade_invoice_amount_due": scheduled_upgrade_invoice_amount_due,
"is_free_trial_invoice_expired_notice": is_free_trial_invoice_expired_notice,
"free_trial_invoice_expired_notice_page_plan_name": free_trial_invoice_expired_notice_page_plan_name,
} }
return None, context return None, context
def min_licenses_for_flat_discount_to_self_hosted_basic_plan( def min_licenses_for_flat_discount_to_self_hosted_basic_plan(
self, customer: Optional[Customer] self,
customer: Optional[Customer],
is_plan_free_trial_with_invoice_payment: bool = False,
) -> int: ) -> int:
# Since monthly and annual TIER_SELF_HOSTED_BASIC plans have same per user price we only need to do this calculation once. # Since monthly and annual TIER_SELF_HOSTED_BASIC plans have same per user price we only need to do this calculation once.
# If we decided to apply this for other tiers, then we will have to do this calculation based on billing schedule selected by the user. # If we decided to apply this for other tiers, then we will have to do this calculation based on billing schedule selected by the user.
price_per_license = get_price_per_license( price_per_license = get_price_per_license(
CustomerPlan.TIER_SELF_HOSTED_BASIC, CustomerPlan.BILLING_SCHEDULE_MONTHLY CustomerPlan.TIER_SELF_HOSTED_BASIC, CustomerPlan.BILLING_SCHEDULE_MONTHLY
) )
if customer is None: if customer is None or is_plan_free_trial_with_invoice_payment:
return ( return (
Customer._meta.get_field("flat_discount").get_default() // price_per_license Customer._meta.get_field("flat_discount").get_default() // price_per_license
) + 1 ) + 1
@ -2592,14 +2696,22 @@ class BillingSession(ABC):
# If flat discount is not applied. # If flat discount is not applied.
return 1 return 1
def min_licenses_for_plan(self, tier: int) -> int: def min_licenses_for_plan(
self, tier: int, is_plan_free_trial_with_invoice_payment: bool = False
) -> int:
customer = self.get_customer() customer = self.get_customer()
if customer is not None and customer.minimum_licenses: if customer is not None and customer.minimum_licenses:
assert customer.default_discount is not None assert customer.default_discount is not None
return customer.minimum_licenses return customer.minimum_licenses
if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC: if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC:
return min(self.min_licenses_for_flat_discount_to_self_hosted_basic_plan(customer), 10) return min(
self.min_licenses_for_flat_discount_to_self_hosted_basic_plan(
customer,
is_plan_free_trial_with_invoice_payment,
),
10,
)
if tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS: if tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS:
return 25 return 25
return 1 return 1
@ -2705,8 +2817,11 @@ class BillingSession(ABC):
self.downgrade_now_without_creating_additional_invoices(plan=plan) self.downgrade_now_without_creating_additional_invoices(plan=plan)
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL: elif status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL:
assert plan.is_free_trial() assert plan.is_free_trial()
# For payment by invoice, we don't allow changing plan schedule and status.
assert plan.charge_automatically
do_change_plan_status(plan, status) do_change_plan_status(plan, status)
elif status == CustomerPlan.FREE_TRIAL: elif status == CustomerPlan.FREE_TRIAL:
assert plan.charge_automatically
if update_plan_request.schedule is not None: if update_plan_request.schedule is not None:
self.do_change_schedule_after_free_trial(plan, update_plan_request.schedule) self.do_change_schedule_after_free_trial(plan, update_plan_request.schedule)
else: else:
@ -2769,13 +2884,44 @@ class BillingSession(ABC):
"Your plan is already scheduled to renew with {licenses_at_next_renewal} licenses." "Your plan is already scheduled to renew with {licenses_at_next_renewal} licenses."
).format(licenses_at_next_renewal=licenses_at_next_renewal) ).format(licenses_at_next_renewal=licenses_at_next_renewal)
) )
is_plan_free_trial_with_invoice_payment = (
plan.is_free_trial() and not plan.charge_automatically
)
validate_licenses( validate_licenses(
plan.charge_automatically, plan.charge_automatically,
licenses_at_next_renewal, licenses_at_next_renewal,
self.current_count_for_billed_licenses(), self.current_count_for_billed_licenses(),
plan.customer.exempt_from_license_number_check, plan.customer.exempt_from_license_number_check,
self.min_licenses_for_plan(plan.tier), self.min_licenses_for_plan(plan.tier, is_plan_free_trial_with_invoice_payment),
) )
# User is trying to change licenses while in free trial.
if is_plan_free_trial_with_invoice_payment: # nocoverage
invoice = Invoice.objects.filter(plan=plan).order_by("-id").first()
assert invoice is not None
# Don't allow customer to reduce licenses for next billing cycle if they have paid invoice.
if invoice.status == Invoice.PAID:
assert last_ledger_entry.licenses_at_next_renewal is not None
if last_ledger_entry.licenses_at_next_renewal > licenses_at_next_renewal:
raise JsonableError(
_(
"Youve already purchased {licenses_at_next_renewal} licenses for the next billing period."
).format(
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal
)
)
else:
# If customer has paid already, we will send them an invoice for additional
# licenses at the end of free trial.
self.update_license_ledger_for_manual_plan(
plan, timezone_now(), licenses_at_next_renewal=licenses_at_next_renewal
)
else:
# Discard the old invoice and create a new one with updated licenses.
self.update_free_trial_invoice_with_licenses(
plan, timezone_now(), licenses_at_next_renewal
)
else:
self.update_license_ledger_for_manual_plan( self.update_license_ledger_for_manual_plan(
plan, timezone_now(), licenses_at_next_renewal=licenses_at_next_renewal plan, timezone_now(), licenses_at_next_renewal=licenses_at_next_renewal
) )
@ -2897,11 +3043,14 @@ class BillingSession(ABC):
plan_renewal_or_end_date = get_plan_renewal_or_end_date( plan_renewal_or_end_date = get_plan_renewal_or_end_date(
plan, ledger_entry.event_time plan, ledger_entry.event_time
) )
proration_fraction = (plan_renewal_or_end_date - ledger_entry.event_time) / ( unit_amount = plan.price_per_license
billing_period_end - last_renewal if not plan.is_free_trial():
) proration_fraction = (
plan_renewal_or_end_date - ledger_entry.event_time
) / (billing_period_end - last_renewal)
unit_amount = int(plan.price_per_license * proration_fraction + 0.5)
price_args = { price_args = {
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5), "unit_amount": unit_amount,
"quantity": ledger_entry.licenses - licenses_base, "quantity": ledger_entry.licenses - licenses_base,
} }
description = "Additional license ({} - {})".format( description = "Additional license ({} - {})".format(
@ -3249,6 +3398,82 @@ class BillingSession(ABC):
return success_message return success_message
def update_free_trial_invoice_with_licenses(
self,
plan: CustomerPlan,
event_time: datetime,
licenses: int,
) -> None: # nocoverage
assert (
self.get_billable_licenses_for_customer(plan.customer, plan.tier, licenses) <= licenses
)
last_sent_invoice = Invoice.objects.filter(plan=plan).order_by("-id").first()
assert last_sent_invoice is not None
assert last_sent_invoice.status == Invoice.SENT
assert plan.automanage_licenses is False
assert plan.charge_automatically is False
assert plan.fixed_price is None
assert plan.is_free_trial()
# Create a new renewal invoice with updated licenses so that this becomes the last
# renewal invoice for customer which will be used for any future comparisons.
LicenseLedger.objects.create(
plan=plan,
is_renewal=True,
event_time=event_time,
licenses=licenses,
licenses_at_next_renewal=licenses,
)
# Update the last sent invoice with the new licenses. We just need to update `quantity` in
# the first invoice item. So, we void the current invoice and create a new copy of it with
# the updated quantity.
stripe_invoice = stripe.Invoice.retrieve(last_sent_invoice.stripe_invoice_id)
assert stripe_invoice.status == "open"
invoice_items = stripe_invoice.lines.data
# Stripe does something weird and puts the discount item first, so we need to reverse the order here.
invoice_items.reverse()
for invoice_item in invoice_items:
price_args = {}
# If amount is positive, this must be non-discount item we need to update.
if invoice_item.amount > 0:
assert invoice_item.price is not None
price_args = {
"quantity": licenses,
"unit_amount": invoice_item.price.unit_amount,
}
else:
price_args = {
"amount": invoice_item.amount,
}
stripe.InvoiceItem.create(
currency=invoice_item.currency,
customer=stripe_invoice.customer,
description=invoice_item.description,
period=invoice_item.period,
**price_args,
)
assert plan.next_invoice_date is not None
# Difference between end of free trial and event time
days_until_due = (plan.next_invoice_date - event_time).days
new_stripe_invoice = stripe.Invoice.create(
auto_advance=False,
collection_method="send_invoice",
customer=stripe_invoice.customer,
days_until_due=days_until_due,
statement_descriptor=stripe_invoice.statement_descriptor,
metadata=stripe_invoice.metadata,
)
new_stripe_invoice = stripe.Invoice.finalize_invoice(new_stripe_invoice)
last_sent_invoice.stripe_invoice_id = str(new_stripe_invoice.id)
last_sent_invoice.save(update_fields=["stripe_invoice_id"])
assert stripe_invoice.id is not None
stripe.Invoice.void_invoice(stripe_invoice.id)
def update_license_ledger_for_manual_plan( def update_license_ledger_for_manual_plan(
self, self,
plan: CustomerPlan, plan: CustomerPlan,
@ -4962,6 +5187,7 @@ def invoice_plans_as_needed(event_time: Optional[datetime] = None) -> None:
plan.save(update_fields=["reminder_to_review_plan_email_sent"]) plan.save(update_fields=["reminder_to_review_plan_email_sent"])
free_plan_with_no_next_plan = not plan.is_paid() and plan.status == CustomerPlan.ACTIVE free_plan_with_no_next_plan = not plan.is_paid() and plan.status == CustomerPlan.ACTIVE
free_trial_pay_by_invoice_plan = plan.is_free_trial() and not plan.charge_automatically
last_audit_log_update = remote_server.last_audit_log_update last_audit_log_update = remote_server.last_audit_log_update
if not free_plan_with_no_next_plan and ( if not free_plan_with_no_next_plan and (
last_audit_log_update is None or plan.next_invoice_date > last_audit_log_update last_audit_log_update is None or plan.next_invoice_date > last_audit_log_update
@ -4987,6 +5213,10 @@ def invoice_plans_as_needed(event_time: Optional[datetime] = None) -> None:
) )
plan.invoice_overdue_email_sent = True plan.invoice_overdue_email_sent = True
plan.save(update_fields=["invoice_overdue_email_sent"]) plan.save(update_fields=["invoice_overdue_email_sent"])
# We still process free trial plans so that we can directly downgrade them.
# Above emails can serve as a reminder to followup for additional feedback.
if not free_trial_pay_by_invoice_plan:
continue continue
while ( while (

View File

@ -12,7 +12,14 @@ from corporate.lib.stripe import (
RemoteServerBillingSession, RemoteServerBillingSession,
get_configured_fixed_price_plan_offer, get_configured_fixed_price_plan_offer,
) )
from corporate.models import Customer, CustomerPlan, Event, Invoice, Session from corporate.models import (
Customer,
CustomerPlan,
Event,
Invoice,
Session,
get_current_plan_by_customer,
)
from zerver.lib.send_email import FromAddress, send_email from zerver.lib.send_email import FromAddress, send_email
from zerver.models.users import get_active_user_profile_by_id_in_realm from zerver.models.users import get_active_user_profile_by_id_in_realm
@ -172,7 +179,20 @@ def handle_invoice_paid_event(stripe_invoice: stripe.Invoice, invoice: Invoice)
remote_server_legacy_plan=remote_server_legacy_plan, remote_server_legacy_plan=remote_server_legacy_plan,
stripe_invoice_paid=True, stripe_invoice_paid=True,
) )
else: return
elif metadata.get("on_free_trial") and invoice.is_created_for_free_trial_upgrade:
free_trial_plan = invoice.plan
assert free_trial_plan is not None
if free_trial_plan.is_free_trial():
# We don't need to do anything here. When the free trial ends we will
# check if user has paid the invoice, if not we downgrade the user.
return
# If customer paid after end of free trial, we just upgrade via default method below.
assert free_trial_plan.status == CustomerPlan.ENDED
# Also check if customer is not on any other active plan.
assert get_current_plan_by_customer(customer) is None
billing_session.process_initial_upgrade( billing_session.process_initial_upgrade(
plan_tier, plan_tier,
int(metadata["licenses"]), int(metadata["licenses"]),

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.11 on 2024-04-10 03:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0041_fix_plans_on_free_trial_with_changes_in_schedule"),
]
operations = [
migrations.AddField(
model_name="invoice",
name="is_created_for_free_trial_upgrade",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="invoice",
name="plan",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="corporate.customerplan",
),
),
]

View File

@ -5,7 +5,7 @@ from typing import Any, Dict, Optional, Union
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.db.models import CASCADE, Q from django.db.models import CASCADE, SET_NULL, Q
from typing_extensions import override from typing_extensions import override
from zerver.models import Realm, UserProfile from zerver.models import Realm, UserProfile
@ -218,6 +218,8 @@ class PaymentIntent(models.Model): # nocoverage
class Invoice(models.Model): class Invoice(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE) customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_invoice_id = models.CharField(max_length=255, unique=True) stripe_invoice_id = models.CharField(max_length=255, unique=True)
plan = models.ForeignKey("CustomerPlan", null=True, default=None, on_delete=SET_NULL)
is_created_for_free_trial_upgrade = models.BooleanField(default=False)
SENT = 1 SENT = 1
PAID = 2 PAID = 2

Some files were not shown because too many files have changed in this diff Show More