From 0147578de6310d08e51b4021a760e13cc4b2cc10 Mon Sep 17 00:00:00 2001 From: Lauryn Menard Date: Wed, 11 Sep 2024 19:06:47 +0200 Subject: [PATCH] corporate: Make initial upgrade license checks more robust. In cases where the seat count for automated license management is stale when the upgrade process is initiated and the user has chosen automated license management, we should get the current billable license count when doing the initial payment/charge. Also, makes the post-payment check for inconsistencies more robust in that we check for both under and over billing cases. In the case where the customer may have been overbilled, an email is sent to the billing support team so that manual investigation can happen. --- corporate/lib/stripe.py | 94 +++++++++++++----- ...dated_lower_seat_count--Charge.list.1.json | Bin 0 -> 3462 bytes ...d_lower_seat_count--Customer.create.1.json | Bin 0 -> 775 bytes ...d_lower_seat_count--Customer.modify.1.json | Bin 0 -> 786 bytes ...lower_seat_count--Customer.retrieve.1.json | Bin 0 -> 2093 bytes ...lower_seat_count--Customer.retrieve.2.json | Bin 0 -> 2093 bytes ...tdated_lower_seat_count--Event.list.1.json | Bin 0 -> 1745 bytes ...tdated_lower_seat_count--Event.list.2.json | Bin 0 -> 39387 bytes ...tdated_lower_seat_count--Event.list.3.json | Bin 0 -> 20647 bytes ...tdated_lower_seat_count--Event.list.4.json | Bin 0 -> 81 bytes ...ed_lower_seat_count--Invoice.create.1.json | Bin 0 -> 4696 bytes ...eat_count--Invoice.finalize_invoice.1.json | Bin 0 -> 4857 bytes ...ated_lower_seat_count--Invoice.list.1.json | Bin 0 -> 83 bytes ...ated_lower_seat_count--Invoice.list.2.json | Bin 0 -> 5660 bytes ...dated_lower_seat_count--Invoice.pay.1.json | Bin 0 -> 4872 bytes ...ower_seat_count--InvoiceItem.create.1.json | Bin 0 -> 1163 bytes ...er_seat_count--PaymentMethod.create.1.json | Bin 0 -> 1078 bytes ...ower_seat_count--SetupIntent.create.1.json | Bin 0 -> 884 bytes ..._lower_seat_count--SetupIntent.list.1.json | Bin 0 -> 1097 bytes ...er_seat_count--SetupIntent.retrieve.1.json | Bin 0 -> 884 bytes ...seat_count--checkout.Session.create.1.json | Bin 0 -> 2139 bytes ...r_seat_count--checkout.Session.list.1.json | Bin 0 -> 2554 bytes corporate/tests/test_stripe.py | 66 +++++++++++- .../emails/internal_billing_notice.html | 4 + .../internal_billing_notice.subject.txt | 2 + .../zerver/emails/internal_billing_notice.txt | 5 + 26 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Charge.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.modify.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.retrieve.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.retrieve.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.3.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.4.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.finalize_invoice.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.list.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.pay.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--InvoiceItem.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--PaymentMethod.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--SetupIntent.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--SetupIntent.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--SetupIntent.retrieve.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--checkout.Session.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--checkout.Session.list.1.json diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 628a3b98e1..884e0579ee 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -1704,6 +1704,15 @@ class BillingSession(ABC): ) return self.create_stripe_invoice_and_charge(updated_metadata) + def stale_seat_count_check(self, request_seat_count: int, tier: int) -> int: + current_seat_count = self.current_count_for_billed_licenses() + minimum_seat_count = self.min_licenses_for_plan(tier) + if request_seat_count == minimum_seat_count and current_seat_count < minimum_seat_count: + # Continue to use the minimum licenses for the plan tier. + return request_seat_count + # Otherwise, use a current count for billed licenses. + return current_seat_count + def ensure_current_plan_is_upgradable(self, customer: Customer, new_plan_tier: int) -> None: # Upgrade for customers with an existing plan is only supported for remote realm / server right now. if isinstance(self, RealmBillingSession): @@ -1785,13 +1794,18 @@ class BillingSession(ABC): # TODO: The correctness of this relies on user creation, deactivation, etc being # in a transaction.atomic() with the relevant RealmAuditLog entries with transaction.atomic(): - # billed_licenses can be greater than licenses if users are added between the start of - # this function (process_initial_upgrade) and now + # We get the current license count here in case the number of billable + # licenses has changed since the upgrade process began. current_licenses_count = self.get_billable_licenses_for_customer( customer, plan_tier, licenses ) - # In case user wants more licenses for the plan. (manual license management) - billed_licenses = max(current_licenses_count, licenses) + if current_licenses_count != licenses and not automanage_licenses: + # With manual license management, the user may want to purchase more + # licenses than are currently in use. + billable_licenses = max(current_licenses_count, licenses) + else: + billable_licenses = current_licenses_count + plan_params = { "automanage_licenses": automanage_licenses, "charge_automatically": charge_automatically, @@ -1862,7 +1876,7 @@ class BillingSession(ABC): ) # Update license_at_next_renewal as per new plan. assert last_ledger_entry is not None - last_ledger_entry.licenses_at_next_renewal = billed_licenses + last_ledger_entry.licenses_at_next_renewal = billable_licenses last_ledger_entry.save(update_fields=["licenses_at_next_renewal"]) remote_server_legacy_plan.status = CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END remote_server_legacy_plan.save(update_fields=["status"]) @@ -1910,21 +1924,47 @@ class BillingSession(ABC): # TODO: Do a check for max licenses for fixed price plans here after we add that. if ( stripe_invoice_paid - and billed_licenses != licenses + and billable_licenses != licenses and not customer.exempt_from_license_number_check and not fixed_price_plan_offer ): - # Customer paid for less licenses than they have. - # We need to create a new ledger entry to track the additional licenses. - LicenseLedger.objects.create( - plan=plan, - is_renewal=False, - event_time=event_time, - licenses=billed_licenses, - licenses_at_next_renewal=billed_licenses, - ) - # Creates due today invoice for additional licenses. - self.invoice_plan(plan, event_time) + # Billable licenses in use do not match what was paid/invoiced by customer. + if billable_licenses > licenses: + # Customer paid for less licenses than they have in use. + # We need to create a new ledger entry to track the additional licenses. + LicenseLedger.objects.create( + plan=plan, + is_renewal=False, + event_time=event_time, + licenses=billable_licenses, + licenses_at_next_renewal=billable_licenses, + ) + # Creates due today invoice for additional licenses. + self.invoice_plan(plan, event_time) + else: + # Customer paid for more licenses than they have in use. + # We need to create a new ledger entry to track the reduced renewal licenses. + LicenseLedger.objects.create( + plan=plan, + is_renewal=False, + event_time=event_time, + licenses=licenses, + licenses_at_next_renewal=billable_licenses, + ) + # Send internal billing notice about license discrepancy. + context = { + "billing_entity": self.billing_entity_display_name, + "support_url": self.support_url(), + "paid_licenses": licenses, + "current_licenses": billable_licenses, + "notice_reason": "license_discrepancy", + } + send_email( + "zerver/emails/internal_billing_notice", + to_emails=[BILLING_SUPPORT_EMAIL], + from_address=FromAddress.tokenized_no_reply_address(), + context=context, + ) if not stripe_invoice_paid and not ( free_trial or should_schedule_upgrade_for_legacy_remote_server @@ -1936,7 +1976,7 @@ class BillingSession(ABC): customer, price_per_license=price_per_license, fixed_price=plan.fixed_price, - licenses=billed_licenses, + licenses=billable_licenses, plan_tier=plan.tier, billing_schedule=billing_schedule, charge_automatically=False, @@ -1953,7 +1993,7 @@ class BillingSession(ABC): # fails to pay the invoice before expiration, we downgrade the customer. self.generate_stripe_invoice( plan_tier, - licenses=billed_licenses, + licenses=billable_licenses, license_management="automatic" if automanage_licenses else "manual", billing_schedule=billing_schedule, billing_modality="send_invoice", @@ -1968,15 +2008,21 @@ class BillingSession(ABC): self.ensure_current_plan_is_upgradable(customer, upgrade_request.tier) billing_modality = upgrade_request.billing_modality schedule = upgrade_request.schedule - license_management = upgrade_request.license_management - licenses = upgrade_request.licenses - seat_count = unsign_seat_count(upgrade_request.signed_seat_count, upgrade_request.salt) - if billing_modality == "charge_automatically" and license_management == "automatic": - licenses = seat_count + license_management = upgrade_request.license_management if billing_modality == "send_invoice": license_management = "manual" + licenses = upgrade_request.licenses + request_seat_count = unsign_seat_count( + upgrade_request.signed_seat_count, upgrade_request.salt + ) + # For automated license management, we check for changes to the + # billable licenses count made after the billing portal was loaded. + seat_count = self.stale_seat_count_check(request_seat_count, upgrade_request.tier) + if billing_modality == "charge_automatically" and license_management == "automatic": + licenses = seat_count + exempt_from_license_number_check = ( customer is not None and customer.exempt_from_license_number_check ) diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Charge.list.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Charge.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..edc79871f77b01e09a04d9ffe0062a66aaf2bb38 GIT binary patch literal 3462 zcmbtX+iu)85PkPo2z+jKy;}!p0u-%bw}1=hg1CN3Q4m~;vKUh&!OPl?;eXGNk|=Ve zZ3Dy)hQS%l^~{;^^GQ{qnmB^qR{y}?RrMTy!z<~Omk!>~emOlo{i%G$lxXJ-tB=VI zYrL$ZJo{E_!6|WEN%&5BAttkv2se!Vj~J)m3L`rrh@=cVL!>2i@Ppi=#P2_RdUt+( z_4$uWlzi^E5M18jnmNLS1)pQ`A^o~G%$ASvOu2gkMRY@C^(VNY+)|ZHh5{lQ>Sm(W z?M*2HcWe_r-)w)b-VrXV8#mY39%LV_YGoY}n5sGy2faDloW3qU1-Xl|i*qz|tEyhc zY#}h#L7Fq$NRlpo_Lpj9b*Wfp73o&Vu^EHSWD!v!s2AY0#?g1jq=RBwYPfdFgfKtf zUSEBzF0Mb^URF1MpTEC6|CBW#1CcYHjiDlHSg;BPHUPc;XA*j2n5413-qtBvM7?#Y zW6>}Cz1Z?Gx*mb(BY6rzSB)~&mE0>%r|c2vIxZK{T63#?EOvpv;W-4}5Mif;8YnzW z3tk3jVPjMs8oem%0GQ-%>`SUlV~DSFG*7MKAtvPYogp=Z@dWd0YW%=mi0e0jVC-?OfVYgvsB=564d=P5+B z`@(488$_eq$(53L$66v9tQwFPB~hl19WNifqksg^%?4UB_n^#ugsKm7jfmDp?IClh z#~@8kVO#)&A>-0vc<1&W3$|xrmXK7YBck{&$bl712q2Lkd}x^vP~oY234ai+7R<3# zZpS_8U;z>DT4nfG7>L;jY7#qJOpoqCjk$Dy?O;Lk#bFo~p&zY^0MXrdDCP)DJOgB4 zf{Cf%kZ{k^I7{GQn?z*A;t zt3iO)M;r&+nQF$e|NQ~o(*uQ6ih^`Qc*pkvd(^m7(zOuRn>-{GBu|V4GTWYRUmwVz zv9McHVMmV2iw}+iDfdVRVj?>+0Vw30CNCDCfknuSHUOGXqlI=eyxD8t$`L z(}kHU!0}!6cdcOz!=h&t7THn<9-uuefG1Qr<&dEajf57dV$eu(pj*n|ZouP}f?Cpn z!Qa1n!SxClGIcenO5g3~>^~avCK7b(oVIV*Yhb&Tb#R)nU~D~T+4ZoMEf-mTj*?3A&Q`UQqE$%{>@tX>S|8MBdo9MzDq^nGL0Sq!EmL^Ov{ai zbVs8T;Y>fGikdq6W#AInV#@>%l5JFKnafEQxlFqa1Qt;{Blz+7A;;tlgh}tnWHwnv z+>Dk`cI}h^<^wdUE9h_UAFuZ>&-16p%Z>;sDZ4C|@6c{ETo5j66liU?ORdcHO2 z4l|HiSC$`?Vo&2-f^b>GTqFdfVUFw@V3BZ7`)aiY<77$s38_bDNGN$Nfw1r!&K$Of69-s`W`_|bDm|`p7-&SG~ oTtm_G`9;04RoYuE$DYkP_`*jHqWnAK*=!C9wpn>R^WMDo zyc-OM6_+wVjMeSXX02~ZY0fpb0(baK`Z;6!6w9NxQ;*s5~2voC*>^GtDmfuA70JqH-*)8)pe;TT&B?@AQ-N6nQ6Jv zknU)-7S41ds;H^5Uj#0J&9_YOAlX`_mbsi{k;}AOLtqiLGlCzD9&$|1K&U%M>Pfwf zxEU>>?8+$t%m--Hrl7yRy}#T)Kg}MemmLvOQg%@+-=W=TxFB5CDA4je3B2m<^n7d3 z9cCc4j+Xa^{b9iN#<>Imk$WSsh7m~!$jBTy*)t&bo>tdt1;)t|_{n-C;^+fEZHccy z8`e2DAxQ*YiZK<=E}!a%Yq=?y{VJ7(O=7)I5lY_z!lV466Iq*$bHB uFyO;02??&D==u1h-qKllYo4D@&a literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.retrieve.1.json new file mode 100644 index 0000000000000000000000000000000000000000..03644507538f12842831fd44ff8ea2694dee8444 GIT binary patch literal 2093 zcmb_dOHUgy5We#(BA;5RAdIH->$DU*jUfZ?3At~a&cV;&G2&G)w zy+oPEH#6Tn{IEBf2xUxyoW;9|Z=8EWqPcR)Ye3N<|JMmr0fYaihuzmInv}rn&Dhz% zLN%^P{emusCNX{=(txx6X9K<<)v+?^4^w8hNc)QSGsTyNoK5FGW`Dj zS(8hsAsw`g983>xGiJs~SicHZgNz9lc1`GCzkR%=(F%&09)5GIkSJW5Qot+A1k^CO1740Xw3E0IJUe)aOIaGdf9$Yju?dqm|Kex^}kJ2>*M<-j`1I`~pyx+g=Xg5(=e3B|L++3`rTz(Io)ljWs+ zx(V9`EgEmojyS-5GIl&0VhJ7+ui+x$(mov#;Z!b10A(OwatcKpxF#@PROFB6nxVFlxh$+*t0pl+AEzxu4e zJE!mrD&@HB3~#-~B80-^3tz6^bSVQrGTre- zM|Km> E2FBk@8~^|S literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Customer.retrieve.2.json new file mode 100644 index 0000000000000000000000000000000000000000..03644507538f12842831fd44ff8ea2694dee8444 GIT binary patch literal 2093 zcmb_dOHUgy5We#(BA;5RAdIH->$DU*jUfZ?3At~a&cV;&G2&G)w zy+oPEH#6Tn{IEBf2xUxyoW;9|Z=8EWqPcR)Ye3N<|JMmr0fYaihuzmInv}rn&Dhz% zLN%^P{emusCNX{=(txx6X9K<<)v+?^4^w8hNc)QSGsTyNoK5FGW`Dj zS(8hsAsw`g983>xGiJs~SicHZgNz9lc1`GCzkR%=(F%&09)5GIkSJW5Qot+A1k^CO1740Xw3E0IJUe)aOIaGdf9$Yju?dqm|Kex^}kJ2>*M<-j`1I`~pyx+g=Xg5(=e3B|L++3`rTz(Io)ljWs+ zx(V9`EgEmojyS-5GIl&0VhJ7+ui+x$(mov#;Z!b10A(OwatcKpxF#@PROFB6nxVFlxh$+*t0pl+AEzxu4e zJE!mrD&@HB3~#-~B80-^3tz6^bSVQrGTre- zM|Km> E2FBk@8~^|S literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..99118709f53beb2af0fad465c12e8ccc9d61092f GIT binary patch literal 1745 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 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.3.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.3.json new file mode 100644 index 0000000000000000000000000000000000000000..a60c6229bfdf3fec0d1ce5329e592ecaa3844f51 GIT binary patch literal 20647 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 SeS^UKn*nS;6ZiQ)eDNP$iY@s7 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.4.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Event.list.4.json new file mode 100644 index 0000000000000000000000000000000000000000..6d922067afeefefcba3c644d2dd8510c1d8984a9 GIT binary patch literal 81 zcmb>CQczGzNi0cJvQmhS)dBG`5{u(=^NUh}B58>^#i?My{G_bZ Xv?vE8pkHRFpIVlhS5mCRRm%kck@XgY literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..6af2e2ed1363b33aef36006e142caee3e0748761 GIT binary patch literal 4696 zcmb_g+in~;41Ld6EcBfClGth5rxtAr1Wi+*L0=LSf_6shRjkoWblKPm^6x#=ZAP=} zqCn#Z!x&K{MIIiK{_<$EVWR8CU_6h%?LGTp!~S~99xsoK7$klFO_<(_&1hZn#X5}E1QRe2<1-Dnzl+N;{Y0|0_ zUX7+PJAdXoDY=MlG!ov+Val(beMny=d9@ST$>eSDBNzQan2w&&9t<$vaXcL-Ul24z zR2s3>tqy$EN^F%@o+4c0gU`+jA4<${|7$fFH4Clp`A!+3)o1Cr@Qd3F`ZS`!trRW> zfDOU~p;KDAy|6draIwa^QES;zu=pVTexzrzB*q0ESuJJ-(CgznE|IwO6TRz#wbFDE zE5Y?yIyh*6uTr-d=;DF2(O7W&%^!dM{?qGM?_U10yeKU~+*|2NEV3n1iZeomCJlQr zXz73a9JHFwx^Y<7nCZ!rmj{INOe^Qlo}PUmz{hxJ>wM~WjSc5Q^fecy zzn|#!%t^gtwa&C~zLZyF5iYGA398juO?mLBv4e6ennCf!6lD`|T;S&W$O4e0Kfa#rM}QuCC_`sXtg!c=_z<_0@OJuCA`D zx~1U>#R{o{+G5Y9x@^peSo9EHB^Sm@Lvh_+rj`$nc`(W^Fa){ofzFWM34s$GVsRKd z1*tvm%vI{!BMOc`oKiX*$J;RRTiLeg9Lon%j(bLRx2wKWUoit>in(ZVJK3=h2`=>K zX&AzQl@!l3_p}+tA@z!_+>3*Xyv2fXlk61w@UrkpP)X}}2K2ESD~#KXcHkd_Fn&F! zB%R}EFS!Peh+hDyQJ1OVlnKAy^px?xO=Cku@b$^Y>|K#2AAd)-_aCx46bLaoNJ)fuc z2w!7PPu3n=R#kje!$EzBU5H&4_Y<#RxB}f_*C6=3DKi;Dfn7ivCppGoaRUZ3k&5eR zTeM^`N*sG$K4K`|S?mhls)Wm5X#psw(I8b2-Kp*b2YO6yTt*D1ml}CD&wnEA6u4Iq z#7r2r?Hn~Ht8_ie41-Tp+Iq*wX1K|VQ>_U`A82-h90s@8PnL&i5>>{hh*U?)jOwKK zpqIIZSea1~$<^Elx5~9N+VU|@rKQ9&q*gKn{UT8jk=h~8{jaPTX|u~`SdUWsTb*LQ ztP^&sbZ#)5VnZscURGn}f0FwcaC?~}aIVC-&5v`1)u%@vj~i-YoHE8y;bR~NR@x52 z(rjAj3Gus;++m?3FcCTv{blN7w3*V>xo`(TFJJiqG(HcaHJH_{6Zv3e$3f+T3l>nU z2Q!OSbi~-Vt3V1W?X0o7z`cngQ|ozf_?)*~hJPUJ5*y`@C^&Q;MT^Y>e`V$E+vNmt z7=*nAs|QoISxl-&La`p2wFWn_rS!O2KiAMb!1PTAIBGLX>Y|V>wMCXinCl!*suK0C ziG3DHqkoNl+&VfvcDKuX9cxZ%%N}?%*JsB67qhV=5Omk=jK$3CKyi80t-gJd+dYOQ QhoI0zg-*MO>eomA0N`VVCjbBd literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.finalize_invoice.1.json new file mode 100644 index 0000000000000000000000000000000000000000..df1d655abb01e80c4ced1d09f663ca27f0507ecf GIT binary patch literal 4857 zcmb_f+iny`5Pi>Atoj+eHrPOrq9jU)ghGfElowFc>YeHFHtxC5m&FG0?>W`C>6u+8 zk%AvAd#by-E~idaeg0;%VWMe9KR6zKn>+S=!~T539@L^i^pd{+Du&jG%`3694>m7{ z=4^S)iCeB(i<5)bI%?>H?$*=&%V+23=k@1Fs21NB-;C^q8q{!o>Vn;?X^OIRX_~ZZ zgj3_Ncy|8GJ1MycW-LxPC;KURJ=+k!O0udG+RA9HcOw_=K@1JKu@v-pyy0*ilFtYl zA}R;5(@P!rs+HI&tsF%-#|NLS6VB(D;r^HMWY8?MzT=%5gjS!V<-%3B8T4sHgBvMq z2mtHF;DwH9Y4*Zglfn5l){I)qhJwX=>GmVJ$>K3C;AD9*DS%!V{J22k;!m)y@y5uZ z30U#AO{cwu2G}a~5(7;TNE06OmcRb}kKcZL_44hDpX-a#AjGYaHpe2pL`oqeRA|z$ z7rmD5hfiLs>8u(1m5v0}+2!;&pZ4Rh3`UkzpgO)rn3lH$6?%Li9ZSPz(7*dDNLtwm zuN|MnT`y5rCDKKCK4C|10-}y2q|_kx6pNzPHbzZrq_@h3!o|T^OZryd_L8?ySRa#J zM_@RhJjl6Kj^)r2w01%#*vEqF#?ggTme)FitgGQLs--Xc(E>bK!o7*hEp9zJ|=Ktj&6vhM8B*?TIPkg;S7pV|&_VEbeu61Xrgy7+HKZlR?%)LI|nmAJv( zO_T&?rS4cMcP-#(s`5Z)TpBY{#gs5>f`db;ACz6O9XKE$B|QP}1#Z3zWVA`76o{$N zU}0kVOg$jdw7hM!4{48lwmx>M?vsXA*6?I+sXRA7>Cv~FHx6wB=?THk#}h;oGtWp} z$%#neOQGDA_~_o(#6@~*hW1y&+fpxAXhehvr7GyxvC=5fer!+00o)=|x<7};k#vu{ z_b6@BO4x``5Y&@+l12sckBCWMQSrEO@%ZZL`L|ckF0bYbu}zu45|1BUT|Rw$d3jaT z)elc#3nr@0&g)t*hw}D<3L^bbWllSc>?sY8Y2eVji%=xycpbi zTuMR6EQ)r_odQG_{wy<^ht3jcKib z>yEI9(wE>yn0k=cy`Zd`6-}`jdDM4PqL>?<(6KEeBp2~@mS%+Q;UdomcKg43{5G~n z*cx(rvi4YARl%zT2em$QA$F0?kGz5z4|Io>hv4%%kA?^ZRuVZ_$uW(KYcSXlsJKkn zSxY)biDOUlN6hA(!ODWUDB|)L+C#E5JV+HpcZxaQf*zw87Xic3(?QSOk21ty8)6$m>@%6sfBScR)b5CnT}c? zG{i$zItRv?%Zlac8Nppmw6HK=i$gab!@QiUE}?97hN>Z-C?JU)lBbrKm!CKSCm9yE z*b!G!jJ;*{kHyai%*@!`@)DZV82O*heek$lM;kOOu?X;zzLEzlWi48J*qtK@<;jk? z0ILGvvur9X&b*ni5Wj25Emlp!7xpZg^+P=*K4i2t(j(4X7qHid(HM z@GoLrCVWyANqJ5A^O3j|loW)0q|sw@Qzz|Mb5xrb)uV|&^@4wRHuMTY@Ve2lke?3Z amxkbC+ef+EV~ny03e8&R!BteheDgPz!?iX5 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.list.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..e39960ab7254f5b071e40ef45d6574c984929f3a GIT binary patch literal 83 zcmb>CQczGzNi0cJvQmhS)dBG`5{u(=^NUh}B58>^#i?My{G_bZ Zv?vE8pkHRFpP5&dpP8Imti)Bz1pvYK7f%2H literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.list.2.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.list.2.json new file mode 100644 index 0000000000000000000000000000000000000000..ee6407444a345d1efe318ba180c4cda8f53796d7 GIT binary patch literal 5660 zcmcIo+l~}B5Pjz>M*R#syRZucDN3S*NGOCzL3sg1mZ#gZW7d7amw^TG?>Xg5x4YZ3 zL<-?y#cq|$<*HMss`~T2&8BL&<<+yzTl{~s`RsqRTvOf4#2mFU1{i z1<8KnU8DKtCEuC*n-^VuwpO+LBa@ARcIT9uEVcfg#*v-Nr|0MAOVPk(b4sQ~%Vj6K zYbdM^nu8pMm24Q4thtqaw{m3?vAqzCd)HQ4){1tBk%91H@;68Xd26*3lfc=Y@#e_8 znqIl*%FKl={HYlt1ZheEk{~+1Q{LR{2qa4KomA3NV)Af8WGuHXg*oQy%ghyj8&~R< z?PbT6{3HzHc44W4eTCpLEx7Tqs#@MTuBOb^2d=Mu`s)LG^~ayT|Mc?3o9Dk|3cXUI zrckq1*h5c&slqA75tGr8>c}BLijg2UpuWuS9>%%(`2Wdfi*lws5QcBFBIBe z?o8R!sDmsuT9_2R>}I+^XhwKbWa+?LCG3x%oRY&?-M33;pc{)L&rg+{VZ)*CD0XFO z2Hmm(Rg?HgKqO~6vbA=0D!I3RkJ3i$xl@)6{I(US@G i;|Y+PepiENcbbx@hgL$^y#zSYu;?aV@u$5a7sLi0ZMUe8}e=y(kVM>{2 zxXKOJnm2;&ZduKB!#+rRV7;>xfrhF613+a^AAyx0Ja~RY0?(8*_Uz%=JF3z#%h4w` zGo(+sp`)b8JiX#A#a6J7op>vaNaVBrG83YhYUMm+0u7f`Pw`K*)QBAR)f=LQ z&CjZL&E~a5gG5Tl1f?Uf9AdATqko}8y#Uc#v;tD9%GDFke`{KuVPUI150NHFcl2u)sTG9fs+C>bZTi?-mgBzrDq5)?Lz7f+Em6+X@$h?i&|iNB=zyZCs`W#XJnLYh*xyTJTOB_)&T; ztz2gYQA>Zic^9?8ljU7Bg8WfT{YcvbW z;Is-Znu3e1vGFD2}iI3Bm&%<&G8#ywo&b7N>xwODdNl$}u!dwNv ziFVYU^)zA5jK+<9wgc1OXqJUKhH~FJXLVbpeMELP268BN{ZY{fYe9_j%C6Glf}9i< z2P7@s6ZRg~`!yvMe|p|pkLWIGt*{q{1QsWPw6{(hN0ehV4+B9#0~^M;k_BF39o7_V z=IJX$W=@NLt^3}=$}&OdDgyf%8?!%4A9m~pQ;+|sGI3^{qUkFI!@LPpuKQ;d){W_q zkK?sq1|Jy+zF3)Q^%~1?1S=HjvkFcB3uiM4JEDC}OTj7)qvI#CTBWrbYy}Qmpmr2a z9b*<2pS@E%C4F`A41j(U+CXT>cP;_bw;G@lb)3)_{|-(-GnTYQFF>BY+cc-vUj(h? z`Ub7db#?p-gY{wkna3vxm3d-~h8PQR13TjfqE*!IXneD#SnR|(J5#hv)_sFPwq`3C znGT(2+3V(NVVj#J(l3~`>g$^&1j;vsYtoKYT-9O|{E92SQBwVsrhv+4dk;pB+se%6 l4`O$~+-eX+8gMbVEK}wqM{V6V^kOS7HStoDuI_!g_Yc;3CMW;^ literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.pay.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--Invoice.pay.1.json new file mode 100644 index 0000000000000000000000000000000000000000..6e5ae3d46c7f1be2e6dfdcd398f257d9fd64b5ee GIT binary patch literal 4872 zcmb_f+iny`5Pi>Atoj+eHrPOrq9jU)ghGfElowFc>YeHFHtxC5m&FG0?>W`C>6u+8 zk%AvAd#by-E~idaeg0;%VWMe9KR6zKn>+S=!~T539@L^i^pd{+Du&jG%`3694>m7{ z=4^S)iCeB(i<5)bI%?>H?$*=&%V+23=k@1FsP@!%BYU9+HC#iuxM25cnik)uNvlRU zH4cks=g+*8l8a!*;)HXupOV)QTtvI#S4mbm<7NEl-N;3I5JN*=bVA!%(Btif!*fVJ zBW4Jw9K=os73CFpD?1fWPJO zbR18(;L-(B7=MDHjW(Z8OQcj9 zPT0_-VJ~_u-4CCsUzWmbU~NdVC-qOT%XP zzxyjlTGf2uO z77FWQvg-&82NVi9x5}{`T0+@Q=u`o*B)f5RA(h4E&LHb*IE-rP%YL)~PnK|RV)~q$ z?DOq5T{yE2PDx7SR&7B_u7bxF0jU4~-hL;h82-Q@%um-ww36@ccq2^9KPtE9qj%(K z%k}sXfUv-DWVweAUmTE-Gp(#Udvx}mswZSD*}|u`Lmb#XnTP}~%!@Amn~_^+sSUN( zhj=BLaCcLvPAhfCO66+-M^nKETI14~kt(KySrZ%_s{WwtiuXVV0V(MTcrS4CUErln z;-|n-g%ArT(`V`ik+9`$qkTwwq_*|3Q+1y-w6caLdrRfH`ALty-Mn#VAV^OLc0Qh9 zn>cz#>Pk+W3STNpr3363;pyJj#6?zFp#7Ebw$#fNS`xudsS5gatTalrAKO!L0Jn&g z?$4ozB;DigJxZIj5;o!!1ob4Iq)~zVBVy85R6K56JidB*{_WMX%d7c9>{uqS#N$U- zmroyGUS1V-^}`d`0!lJ!^Lr+8!{Rv+xCe(<$;D&2#o1;r;|v0exgV6P;(~L{Jq;@P zi32AZRV54!i_{*E3gy(-M-&XdKcyrb#~VNK8@U`BQY`ODIqnUrxmmTp;>8SzDdb|2 z+sTZrk8q(sPs0!o7)kMrgV-{|5K_z9$-OwJz+21@c9M<4I8YZp9aPd-o&bHU#)!do zqv`nvF9x@soKg@ni=rKKrvQFRzcQv&xBNoCQ+53n?kB5lp8q3j3 zeJ>DD^633q8kC@FngXINtQN-mhw0UG{c#tZH?i6#p1wBSHE&_(5r-Qtm z$50WM9NfzYVuSB@QPX-+n3I+VJHiKpZB*jA<0DNm;rwxfFo(s>t$_o$%&bkc%N z)loB2v~pDd%zJrzYHdO=dSPxr_kL*7wv7>XxxxpaqC!rrT@YcBYH`0T{UwU`G=fEg zIO;+*vr+VFZGnUl@iOLsZ1-k*=e&7dSAEG3N|o0~dy$C{(sJhvZ>{iz}R!?U4b k5RTW~j)g3BV8Ap97u!C{-5#@)MNnwqLL;uC`sJIy0R{NCTL1t6 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--InvoiceItem.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..e3d4a77d232b389d795516c386291f4d2ddcef1d GIT binary patch literal 1163 zcmb_b%Wm5+5WMFr44o6hj_oGxtw?hy&(nX6FK#93yzMXRyl2Ry~*~G!O+eIjL{oe|-J;<@4R` zr><@_0oUeC(*4RQ3&})m34@n|ciCY(xp5)e$xqVW>e$m;Oh$z0+6m2Cy96CWUr3Dp z`duKj!j;?ms4b{^uME?uN2HAknM8D03*&-*l{31(~-6iF^I|o`^k=imyh{kbv1jx zx?Zm2Jj01}6oUEUa<#l#ESD?BJMz|PaqOt6jh)DzP#a>A+;B9C>1uQi{k=+N1C10b z)}gXTDyq1{L^cG9mLaARxs=`ry}%NCAyLOs8B}roe`djSZ&@#J2;1DMEyzrcZhsXD z_j)v2ugm*Kou{xmC;J527;NrBl)o>hn&~2lGi>Ygn@RswHEi?_Ls^OJQws*y$nN&s zgbc^9HE zTsj4rc(-m>w-*Yv!qcJm=vf8 zs|IYT!~DtiI*5rU2avyKrc|GJX_J?DCM8?`R+~##Q|7Dg{+loN@>+b>@-@#{oj1SQ z0sQfNbT$rD<=tc)VMh>1P`B!CE85du0D9Cm=8nF6{q}VK`QwlGkCPdFvWjwYQ06V0 z#>#eJ0cl8z_vl87U#8292yvY$>u%jmN*3b5;XlUiX_{hd@|b?s&7dVL)2N@n&Xp4C z5qdx>qCPe=DGmt06%Qa20#8BgvgmovL=MS&hg2?s5fjYF2f8B22FzClA@@gcyPAHv z7$#GQZiH;*)HKs&jgsG?P{6lGo)?WDbR4)#Q5B=;QUR$#5ozt|j&5!qs?ri6qJ}*z z+vRg`ws^*j-otplk6xGnaG;6m2?GT_JK)baF2wLLVN_CPdQE?2z|GlKHPVby}Ff59% z`0yotTCLXxa}M4j`xMQGb!;KL>*2;}l&FP_$B`n#NW4$3&XX)gOCfYI6vDcE!v?Ry zO12P-&V2p${mb3w`=6ganqEZW$mHbe_Wk8EU9>9Ysi?@d3fDqM)fS(xp@)zT;Cvyg zHw#p+RZPpff{tK&1dGMN!SfzcW2YD(YAS+@3^ujMH#a|sTfUDhZEPmk&F$t<+w7PD ztF|XOK&2$beKAm6A-P3n8guVn66N%&m*AghOpZi6V5rnz9x*D@{6X;_C593^oqJNG zXDQHoohPR4qZl9&Goks;KM|)0TiRn7APsZPa>%O9<15l-l6VvcziVY0zY{zk7M@?_ zr#>&uILU^|>gfnxPICU&H2hDzxY^Sq@gbGpGwdEN^rxxQye~xHblqA}=L}wU*z|g5 z`JRls0^?&Q#rieFlAjOVf&U487-97&@=%a@}pfb||o|KzG|P2#iF>>SW22sAfTs|2|SloY?7_Ltyan z<2~|ezi1j|fk1s|e)4zI?CU>AVHmtKKrRLbL-GFI2o;JwBSsIf>oJh-k-BK}JkdZf z=zVRX6GFc2WxSIH8BIdsdiCw`>FdWY4?jMCQsY3zfY36$dVl_1z>=8`CMh;0>(?4s z%qF?vH8HJ^TWwuJBj}9;CsS1!HVG{1^-dc|R&O13Iu_#9q1D_06H5TnCYC^Vci)l9 z@DPZlExpUMTrHnP{7BkE7SWugF{LQvCoATiA!h&)_JizYinWQ~e-dAYdrlg}q zEc0~!<_6P_I@UW%*4eCVS2@ch!m-mft_rTVC~j8)X&ck;8OsVK{8Qux)Fni1#iAw2 zY89OQuTC$bX%45a%XVj^X*ZxyKEAR#pi(~7{Ti>5UD$ZkC8Pe&H8TMf>7|4sJ2%hX z;pI*x!=_S>ZOa03me*WI3gJ3bb&z!*tFqBd>S6$~Z*8bb@-p=PWp#h;imVog#Xsjo BL^J>Z literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--SetupIntent.retrieve.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--SetupIntent.retrieve.1.json new file mode 100644 index 0000000000000000000000000000000000000000..5f0a76c3e57b4691c4434845f5d7cfa58f364808 GIT binary patch literal 884 zcmZvbJ#QN^42F0A3PW>aG;6m2?GT_JK)baF2wLLVN_CPdQE?2z|GlKHPVby}Ff59% z`0yotTCLXxa}M4j`xMQGb!;KL>*2;}l&FP_$B`n#NW4$3&XX)gOCfYI6vDcE!v?Ry zO12P-&V2p${mb3w`=6ganqEZW$mHbe_Wk8EU9>9Ysi?@d3fDqM)fS(xp@)zT;Cvyg zHw#p+RZPpff{tK&1dGMN!SfzcW2YD(YAS+@3^ujMH#a|sTfUDhZEPmk&F$t<+w7PD ztF|XOK&2$beKAm6A-P3n8guVn66N%&m*AghOpZi6V5rnz9x*D@{6X;_C593^oqJNG zXDQHoohPR4qZl9&Goks;KM|)0TiRn7APsZPa>%O9<15l-l6VvcziVY0zY{zk7M@?_ zr#>&uILU^|>gfnxPICU&H2hDzxY^Sq@gbGpGwdEN^rxxQye~xHblqA}=L}wU*z|g5 z`JRls0^?&Q#rieFlAjOVfjN5WV+TR5@^h4ZR{&fS?i;AC)LvKxpK3W*dWJ2Y+n4RQ>NfJ8?GIE@E#x zGntR~-i+^0PERYb3G9gPdgVk=)>JP}P1O2n_GxWzsdu)s$wksO>=yx@jV6$f>tKV> zM_iBXG1v~3Bn5GY5AIP2ZLkq*%?%72p*^SauxcgNN~^G&Wro=Y5n^cskq^0BL~3Pj zNHmRO@6oN+EXSED$G@X;sE}i&Fp@PzmwK++5c(H&{U>VGuLAqv*=^a?vA=Pm;reAK zOca{5l59K^{)!v!*o5=k?USI`ou7@VwSt2jZ`d)akV;ccn99NJSvn32hec|0%(R?j zpUJTZ8M3qIt&*b+xeP}02TaT8K?`jZ>m~vF{l}Oon*-mazDBAB#enlp z&AUy!w5soC6wJm;-XmW?dz~k{p@cZlB_SpEA+a-lJkdL}$PRW)&RfK;kaOy})nPa-8cAUmt88kI`x9ZBIDx>cbi8$**6LfB&9 z;sFcj$HG2)CR>)j;9p&1Q=o-a28~x|tDhb>Ad|1m)`F55GSz503b9sO?rpX%&+H6b;YsXT|+56#;3PwaY5Q%3W{ zXoJ5OyR@&QOgLDw;=%3sh3_ZOiyaNOwps3ACG@S>`DN-U&}`IA9Iok1Z~sbc`*fma zicC06=s;QNa^WUXXdN>(;1SE55fkN>CTMU5Cx8xmPYCC&&S4qo^u@5_C3ZThm9bQe z%d43+P9}Eiv}W`3di;t{{Wz8uR|itu;4zt_?8O$=Qf`EB56J{trh3m_qFy~Z0pF-s2V`W(A#X6&x=8_%d1ax`SxKDX?}-~sWB=l>(pAG i-jlrivyMET#_3)RMOQe5wX8u~Ej^_dbI!%d!^vOQI<#&8 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--checkout.Session.list.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_lower_seat_count--checkout.Session.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..9916555c1c30b15bba997b6f3aa5de98a3be7b08 GIT binary patch literal 2554 zcmai0O>Y}F5WV|X2t4G}XrpTYBP|jGG1A6B-CP<31jQYBnQ)g|e#lk~|Mxy}m-{Ip zJ_r_P$eDTb=8c}OHXBw7FWB47SNy-(Jf}~bii0Q1>8Vv#cx4)RG(qd%2D{egk+;^= zCYrf4CApPTZ9?O@3wv+8&=<_-ZNZxwktO%y2^Vu>NR1k?*Hq&0Ahe^L5-eJYz0%5` z7Lj0bUigp`?2CBGR~c?;)jV)fmX@4DHCmJG>t>ez3(6wm;4eiZN%LT1O|0_1eOnYi zgI4XS6-35-c6p@3Px_}K$Z}R zt61#}f7!V>l9v3+Xb`5kbUd$a{;DP66STWG`EM*b*qF@{TqgT-yxX8`cZMx8IJA== z6zMWH;p?}_tI6c)DdszAbH_k%r&h~KVm~X@wv(1EAc!6pZi3gfoz zj`okN=+UK57IswY_~*O7|N7(4+kbC9OnR0|#LS=}XD22ONIu`OC1sfKvr-b_xIHd5%-b2XaRE9Rto{?tZKmI?}OEM-Ux zjR>GxH^XfAhOJQT!!t)dw8K4S_TQmF>Ddf{>mV3?T5*ax!d2tX41Bgi+kPx@$u<;J znR2cPOz1~(a_caIb8}D+q1y}-KF`>&2w>VMrwi6cypY(4B*2Pl3k~JIvP8TD7a%YR zU6FS}hH$faS%J9-8#|K}r*}kv6tw?3>QKHnEDF{bo)6a>Ak7wULcNb8KV27NG0RQ0 z%GHuIP)yMNfJM6OkP}B(atb~4BaAb!6Lm%wL@KGXGL?066>OoW^P1+dvg?@3M9*^o z4tA`jX9U-@G@0E_t9d_0^Jro3q*aMMtE2;@8|(TPd$`~31s&t4bU767oX4vKdL>oC zcwRrh|9JEM)7_VU`2EfO{q5ane*58N9D?*a None: hamlet = self.example_user("hamlet") self.login_user(hamlet) + # Higher than original seat count new_seat_count = 23 initial_upgrade_request = InitialUpgradeRequest( manual_license_management=False, @@ -1972,8 +1973,12 @@ class StripeTest(StripeTestCase): _, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context( initial_upgrade_request ) - # Change the seat count while the user is going through the upgrade flow + # Change the seat count in upgrade flow: after do_upgrade, during process_initial_upgrade with ( + patch( + "corporate.lib.stripe.BillingSession.stale_seat_count_check", + return_value=self.seat_count, + ), patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count), patch( "corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context", @@ -2006,6 +2011,65 @@ class StripeTest(StripeTestCase): self.assertEqual(ledger_entry.licenses, new_seat_count) self.assertEqual(ledger_entry.licenses_at_next_renewal, new_seat_count) + @mock_stripe() + def test_upgrade_by_card_with_outdated_lower_seat_count(self, *mocks: Mock) -> None: + hamlet = self.example_user("hamlet") + self.login_user(hamlet) + new_seat_count = self.seat_count - 1 + initial_upgrade_request = InitialUpgradeRequest( + manual_license_management=False, + tier=CustomerPlan.TIER_CLOUD_STANDARD, + billing_modality="charge_automatically", + ) + billing_session = RealmBillingSession(hamlet) + _, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context( + initial_upgrade_request + ) + # Change the seat count in upgrade flow: after do_upgrade, during process_initial_upgrade + with ( + patch( + "corporate.lib.stripe.BillingSession.stale_seat_count_check", + return_value=self.seat_count, + ), + patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count), + patch( + "corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context", + return_value=(_, context_when_upgrade_page_is_rendered), + ), + ): + self.add_card_and_upgrade(hamlet) + + customer = Customer.objects.first() + assert customer is not None + stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id) + # Check that the Charge used the old quantity, not new_seat_count + [charge] = iter(stripe.Charge.list(customer=stripe_customer_id)) + self.assertEqual(8000 * self.seat_count, charge.amount) + [upgrade_invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id)) + self.assertEqual( + [8000 * self.seat_count], + [item.amount for item in upgrade_invoice.lines], + ) + # Check LicenseLedger has the reduced license count at renewal + ledger_entry = LicenseLedger.objects.last() + assert ledger_entry is not None + self.assertEqual(ledger_entry.licenses, self.seat_count) + self.assertEqual(ledger_entry.licenses_at_next_renewal, new_seat_count) + + # Check that we informed the support team about the potential billing error. + from django.core.mail import outbox + + self.assert_length(outbox, 1) + + for message in outbox: + self.assert_length(message.to, 1) + self.assertEqual(message.to[0], "sales@zulip.com") + self.assertEqual( + message.subject, + f"Check initial licenses invoiced for {billing_session.billing_entity_display_name}", + ) + self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS) + def test_upgrade_with_tampered_seat_count(self) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) diff --git a/templates/zerver/emails/internal_billing_notice.html b/templates/zerver/emails/internal_billing_notice.html index 7974db47b2..2d72ad0e69 100644 --- a/templates/zerver/emails/internal_billing_notice.html +++ b/templates/zerver/emails/internal_billing_notice.html @@ -11,6 +11,10 @@ Last data upload: {{ last_audit_log_update }} {% elif notice_reason == "locally_deleted_realm_on_paid_plan" %}

Investigate why remote realm is marked as locally deleted when it's on a paid plan.

+{% elif notice_reason == "license_discrepancy" %} +

Discrepancy in licenses when upgraded to current plan.

+Licenses paid for: {{ paid_licenses }}. +Reported licenses in use: {{ current_licenses }}. {% endif %}

diff --git a/templates/zerver/emails/internal_billing_notice.subject.txt b/templates/zerver/emails/internal_billing_notice.subject.txt index fccafcf395..e7e8864585 100644 --- a/templates/zerver/emails/internal_billing_notice.subject.txt +++ b/templates/zerver/emails/internal_billing_notice.subject.txt @@ -4,4 +4,6 @@ Fixed-price plan for {{ billing_entity }} ends on {{ end_date }} Invoice overdue for {{ billing_entity }} due to stale data {% elif notice_reason == "locally_deleted_realm_on_paid_plan" %} {{ billing_entity }} on paid plan marked as locally deleted +{% elif notice_reason == "license_discrepancy" %} +Check initial licenses invoiced for {{ billing_entity }} {% endif %} diff --git a/templates/zerver/emails/internal_billing_notice.txt b/templates/zerver/emails/internal_billing_notice.txt index b08c566969..3cdf9b219c 100644 --- a/templates/zerver/emails/internal_billing_notice.txt +++ b/templates/zerver/emails/internal_billing_notice.txt @@ -8,6 +8,11 @@ Recent invoice is overdue for payment. Last data upload: {{ last_audit_log_update }} {% elif notice_reason == "locally_deleted_realm_on_paid_plan" %} Investigate why remote realm is marked as locally deleted when it's on a paid plan. +{% elif notice_reason == "license_discrepancy" %} +Discrepancy in licenses when upgraded to current plan. + +Licenses paid for: {{ paid_licenses }}. +Reported licenses in use: {{ current_licenses }}. {% endif %} Support URL: {{ support_url }}