Skip to content

Commit 4ce76b8

Browse files
committed
test(payment): add unit tests for promo code currency and minimum amount handling (#23300, #23299)
1 parent 5438e0d commit 4ce76b8

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Unit tests for promo code minimum_amount and multi-currency / trial resilience.
2+
3+
Regression tests for:
4+
- Issue #23300: Unlimited first-year promo code rejected with 'minimum amount' in GBP / trial checkout
5+
- Issue #23299: Unlimited membership promo codes carry a USD $269 minimum, rejected for customers paying in other currencies
6+
"""
7+
8+
import sys
9+
from types import SimpleNamespace
10+
from unittest.mock import MagicMock, patch
11+
import pytest
12+
from fastapi import HTTPException
13+
14+
import stripe
15+
from models.users import PlanType, Subscription, SubscriptionStatus
16+
from utils.subscription import get_plan_limits
17+
import utils.stripe as stripe_utils
18+
from routers.payment import (
19+
create_checkout_session_endpoint,
20+
upgrade_subscription_endpoint,
21+
CreateCheckoutRequest,
22+
UpgradeSubscriptionRequest,
23+
)
24+
25+
26+
def test_checkout_suppresses_trial_when_promo_provided():
27+
"""When a promo code is provided in checkout, trial_days is omitted to prevent $0 minimum_amount conflict."""
28+
captured = {}
29+
30+
def mock_create_session(uid, price_id, idempotency_key, customer_id=None, promotion_code_id=None, trial_period_days=None):
31+
captured['promotion_code_id'] = promotion_code_id
32+
captured['trial_period_days'] = trial_period_days
33+
return SimpleNamespace(url='https://checkout.stripe.com/pay/cs_test', id='cs_test')
34+
35+
promo_mock = MagicMock()
36+
promo_mock.id = 'promo_unlimited_year'
37+
promo_list_mock = MagicMock(data=[promo_mock])
38+
39+
with (
40+
patch('routers.payment._validate_price_id'),
41+
patch('routers.payment.subscription_utils.can_user_make_payment', return_value=(True, '')),
42+
patch('routers.payment.stripe.PromotionCode.list', return_value=promo_list_mock),
43+
patch('routers.payment._try_reactivate_subscription', return_value=None),
44+
patch('routers.payment.users_db.get_stripe_customer_id', return_value=None),
45+
patch('routers.payment.stripe_utils.create_subscription_checkout_session', side_effect=mock_create_session),
46+
):
47+
req = CreateCheckoutRequest(price_id='price_unlimited_annual', promotion_code='OMI-4FHY-W6SR', trial_days=7)
48+
resp = create_checkout_session_endpoint(req, uid='uid_test')
49+
50+
assert resp['session_id'] == 'cs_test'
51+
assert captured['promotion_code_id'] == 'promo_unlimited_year'
52+
assert captured['trial_period_days'] is None
53+
54+
55+
def test_create_subscription_checkout_session_disables_adaptive_pricing_and_trial():
56+
"""Verify create_subscription_checkout_session omits trial and disables adaptive pricing when promo provided."""
57+
with patch('utils.stripe.stripe.checkout.Session.create') as mock_session_create:
58+
mock_session_create.return_value = SimpleNamespace(url='https://checkout.stripe.com/test', id='cs_123')
59+
60+
stripe_utils.create_subscription_checkout_session(
61+
uid='uid_1',
62+
price_id='price_unlimited',
63+
idempotency_key='idem_1',
64+
promotion_code_id='promo_123',
65+
trial_period_days=7,
66+
)
67+
68+
mock_session_create.assert_called_once()
69+
params = mock_session_create.call_args[1]
70+
assert 'trial_period_days' not in params.get('subscription_data', {})
71+
assert params.get('discounts') == [{'promotion_code': 'promo_123'}]
72+
assert params.get('adaptive_pricing') == {'enabled': False}
73+
74+
75+
def test_create_subscription_checkout_session_minimum_amount_fallback_to_coupon():
76+
"""If Stripe rejects Session.create with minimum amount error, fallback to coupon discount."""
77+
min_amount_error = stripe.error.InvalidRequestError(
78+
message="This code is valid, but your order doesn't meet the minimum amount.",
79+
param="discounts",
80+
)
81+
82+
promo_obj = MagicMock()
83+
promo_obj.coupon.id = 'coup_100_percent'
84+
85+
with (
86+
patch('utils.stripe.stripe.checkout.Session.create', side_effect=[min_amount_error, SimpleNamespace(url='https://checkout', id='cs_ok')]) as mock_session_create,
87+
patch('utils.stripe.stripe.PromotionCode.retrieve', return_value=promo_obj) as mock_retrieve,
88+
):
89+
session = stripe_utils.create_subscription_checkout_session(
90+
uid='uid_1',
91+
price_id='price_unlimited',
92+
idempotency_key='idem_1',
93+
promotion_code_id='promo_min_error',
94+
)
95+
96+
assert session is not None
97+
assert session.id == 'cs_ok'
98+
assert mock_session_create.call_count == 2
99+
retry_params = mock_session_create.call_args[1]
100+
assert retry_params['discounts'] == [{'coupon': 'coup_100_percent'}]
101+
assert 'adaptive_pricing' not in retry_params
102+
103+
104+
def test_upgrade_subscription_endpoint_minimum_amount_fallback_to_coupon():
105+
"""If Stripe Subscription.modify fails with minimum_amount in EUR/GBP, fallback to coupon and deactivate single-use code."""
106+
uid = 'test_user_eur'
107+
current_sub = Subscription(
108+
plan=PlanType.plus,
109+
status=SubscriptionStatus.active,
110+
current_period_start=1000,
111+
current_period_end=2000,
112+
stripe_subscription_id='sub_eur_1',
113+
)
114+
115+
mock_stripe_sub = MagicMock()
116+
mock_stripe_sub.to_dict.return_value = {
117+
'id': 'sub_eur_1',
118+
'cancel_at_period_end': False,
119+
'items': {'data': [{'id': 'si_1', 'price': {'id': 'price_plus_monthly'}}]},
120+
'current_period_start': 1000,
121+
'current_period_end': 2000,
122+
}
123+
124+
target_price_mock = MagicMock()
125+
target_price_mock.recurring.interval = 'year'
126+
127+
min_amount_error = stripe.error.InvalidRequestError(
128+
message="The promotion code's minimum amount (269.00 USD) was not met. The invoice total was 249.00 EUR.",
129+
param="discounts",
130+
)
131+
132+
promo_obj = MagicMock()
133+
promo_obj.id = 'promo_skypilot_1'
134+
promo_obj.max_redemptions = 1
135+
promo_obj.coupon.id = 'coup_unlimited_year'
136+
137+
updated_sub_dict = {
138+
'id': 'sub_eur_1',
139+
'status': 'active',
140+
'current_period_start': 2000,
141+
'current_period_end': 2000 + 86400 * 365,
142+
'items': {'data': [{'id': 'si_1', 'price': {'id': 'price_unlimited_annual'}}]},
143+
}
144+
mock_updated_sub = MagicMock()
145+
mock_updated_sub.to_dict.return_value = updated_sub_dict
146+
147+
with (
148+
patch('routers.payment._validate_price_id'),
149+
patch('routers.payment.get_basic_plan_limits', side_effect=lambda: get_plan_limits(PlanType.basic)),
150+
patch('routers.payment.get_plan_limits', side_effect=get_plan_limits),
151+
patch('routers.payment.users_db.get_user_subscription', return_value=current_sub),
152+
patch('routers.payment.stripe.Subscription.retrieve', return_value=mock_stripe_sub),
153+
patch('routers.payment.get_plan_type_from_price_id', side_effect=lambda pid: PlanType.unlimited if 'unlimited' in pid else PlanType.plus),
154+
patch('routers.payment.stripe.Price.retrieve', return_value=target_price_mock),
155+
patch('routers.payment.stripe.PromotionCode.list', return_value=MagicMock(data=[promo_obj])),
156+
patch('routers.payment.stripe.PromotionCode.retrieve', return_value=promo_obj),
157+
patch('routers.payment.stripe.PromotionCode.modify') as mock_promo_modify,
158+
patch('routers.payment._release_attached_schedules'),
159+
patch('routers.payment.stripe.Subscription.modify', side_effect=[min_amount_error, mock_updated_sub]) as mock_sub_modify,
160+
patch('routers.payment.users_db.update_user_subscription') as mock_update_db,
161+
patch('routers.payment.conversations_db.unlock_all_conversations'),
162+
patch('routers.payment.memories_db.unlock_all_memories'),
163+
patch('routers.payment.action_items_db.unlock_all_action_items'),
164+
patch('routers.payment.clear_fair_use_on_upgrade'),
165+
patch('routers.payment.set_credits_invalidation_signal'),
166+
patch('routers.payment.clear_trial_paywall_cache'),
167+
patch('routers.payment.funded_processing_enabled', return_value=False),
168+
):
169+
req = UpgradeSubscriptionRequest(price_id='price_unlimited_annual', promotion_code='OMI-EUR-PROMO')
170+
resp = upgrade_subscription_endpoint(req, uid=uid)
171+
172+
assert resp['status'] == 'success'
173+
assert mock_sub_modify.call_count == 2
174+
# Verify second call used coupon fallback
175+
retry_modify_call = mock_sub_modify.call_args_list[1][1]
176+
assert retry_modify_call['discounts'] == [{'coupon': 'coup_unlimited_year'}]
177+
# Verify single use promo code was deactivated
178+
mock_promo_modify.assert_called_once_with('promo_skypilot_1', active=False)
179+
mock_update_db.assert_called_once()

0 commit comments

Comments
 (0)