|
| 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