diff --git a/tests/unit/api/test_billing.py b/tests/unit/api/test_billing.py index d26a66c1996a..ab27cd171d01 100644 --- a/tests/unit/api/test_billing.py +++ b/tests/unit/api/test_billing.py @@ -16,7 +16,7 @@ import pytest import stripe -from pyramid.httpexceptions import HTTPBadRequest, HTTPNoContent, HTTPNotFound +from pyramid.httpexceptions import HTTPBadRequest, HTTPNoContent from warehouse.api import billing @@ -238,7 +238,7 @@ def test_handle_billing_webhook_event_subscription_deleted_update( billing.handle_billing_webhook_event(db_request, event) - def test_handle_billing_webhook_event_subscription_deleted_not_found( + def test_handle_billing_webhook_event_subscription_deleted( self, db_request, subscription_service ): organization = OrganizationFactory.create() @@ -258,8 +258,7 @@ def test_handle_billing_webhook_event_subscription_deleted_not_found( }, } - with pytest.raises(HTTPNotFound): - billing.handle_billing_webhook_event(db_request, event) + billing.handle_billing_webhook_event(db_request, event) def test_handle_billing_webhook_event_subscription_deleted_invalid_status( self, db_request @@ -359,8 +358,7 @@ def test_handle_billing_webhook_event_subscription_updated_not_found( }, } - with pytest.raises(HTTPNotFound): - billing.handle_billing_webhook_event(db_request, event) + billing.handle_billing_webhook_event(db_request, event) def test_handle_billing_webhook_event_subscription_updated_no_change( self, db_request @@ -478,8 +476,7 @@ def test_handle_billing_webhook_event_customer_deleted_no_subscriptions( }, } - with pytest.raises(HTTPNotFound): - billing.handle_billing_webhook_event(db_request, event) + billing.handle_billing_webhook_event(db_request, event) def test_handle_billing_webhook_event_customer_deleted_invalid_customer( self, db_request diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index 56db7effc107..b675716a8928 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -553,17 +553,28 @@ def test_manage_organization( ), ] - @pytest.mark.parametrize("orgtype", list(OrganizationType)) + @pytest.mark.parametrize( + ["orgtype", "has_customer"], + [(orgtype, True) for orgtype in list(OrganizationType)] + + [(orgtype, False) for orgtype in list(OrganizationType)], + ) def test_save_organization( self, db_request, pyramid_user, orgtype, + has_customer, + billing_service, organization_service, enable_organizations, monkeypatch, ): organization = OrganizationFactory.create(orgtype=orgtype) + customer = StripeCustomerFactory.create() + if has_customer: + OrganizationStripeCustomerFactory.create( + organization=organization, customer=customer + ) db_request.POST = { "display_name": organization.display_name, "link_url": organization.link_url, @@ -571,11 +582,18 @@ def test_save_organization( "orgtype": organization.orgtype, } + db_request.registry.settings["site.name"] = "PiePeaEye" + monkeypatch.setattr( organization_service, "update_organization", pretend.call_recorder(lambda *a, **kw: None), ) + monkeypatch.setattr( + billing_service, + "update_customer", + pretend.call_recorder(lambda stripe_customer_id, name, description: None), + ) save_organization_obj = pretend.stub( validate=lambda: True, data=db_request.POST @@ -598,6 +616,20 @@ def test_save_organization( assert organization_service.update_organization.calls == [ pretend.call(organization.id, **db_request.POST) ] + assert billing_service.update_customer.calls == ( + [ + pretend.call( + customer.customer_id, + ( + f"PiePeaEye Organization - {organization.display_name} " + f"({organization.name})" + ), + organization.description, + ) + ] + if has_customer + else [] + ) assert send_email.calls == [ pretend.call( db_request, diff --git a/tests/unit/mock/test_billing.py b/tests/unit/mock/test_billing.py index 871ecd895d6a..c1e4ae199b3d 100644 --- a/tests/unit/mock/test_billing.py +++ b/tests/unit/mock/test_billing.py @@ -25,6 +25,7 @@ def organization(self): return OrganizationFactory.create() def test_disable_organizations(self, db_request, organization): + db_request.organization_access = False with pytest.raises(HTTPNotFound): billing.MockBillingViews(organization, db_request) diff --git a/tests/unit/organizations/test_models.py b/tests/unit/organizations/test_models.py index 8d633fc9dbef..8a8bf222aa71 100644 --- a/tests/unit/organizations/test_models.py +++ b/tests/unit/organizations/test_models.py @@ -66,6 +66,19 @@ def test_traversal_cant_find(self, db_request): class TestOrganization: + def test_customer_name(self, db_session): + organization = DBOrganizationFactory.create( + name="pypi", display_name="The Python Package Index" + ) + assert ( + organization.customer_name() + == "PyPI Organization - The Python Package Index (pypi)" + ) + assert ( + organization.customer_name("Test PyPI") + == "Test PyPI Organization - The Python Package Index (pypi)" + ) + def test_acl(self, db_session): organization = DBOrganizationFactory.create() owner1 = DBOrganizationRoleFactory.create(organization=organization) diff --git a/tests/unit/subscriptions/test_services.py b/tests/unit/subscriptions/test_services.py index ce98857b6f25..35eab6d9b8cf 100644 --- a/tests/unit/subscriptions/test_services.py +++ b/tests/unit/subscriptions/test_services.py @@ -153,6 +153,26 @@ def test_create_customer(self, billing_service, organization_service): assert customer is not None assert customer["id"] + def test_update_customer(self, billing_service, organization_service): + organization = OrganizationFactory.create() + + customer = billing_service.create_customer( + name=organization.name, + description=organization.description, + ) + + assert customer is not None + assert customer["name"] == organization.name + + customer = billing_service.update_customer( + customer_id=customer["id"], + name="wutangClan", + description=organization.description, + ) + + assert customer is not None + assert customer["name"] == "wutangClan" + def test_create_checkout_session(self, billing_service, subscription_service): subscription_price = StripeSubscriptionPriceFactory.create() success_url = "http://what.ever" diff --git a/warehouse/api/billing.py b/warehouse/api/billing.py index a899661a2299..63d92e73ecb0 100644 --- a/warehouse/api/billing.py +++ b/warehouse/api/billing.py @@ -12,7 +12,7 @@ import stripe -from pyramid.httpexceptions import HTTPBadRequest, HTTPNoContent, HTTPNotFound +from pyramid.httpexceptions import HTTPBadRequest, HTTPNoContent from pyramid.view import view_config from warehouse.subscriptions.interfaces import IBillingService, ISubscriptionService @@ -76,8 +76,6 @@ def handle_billing_webhook_event(request, event): subscription_service.update_subscription_status( id, StripeSubscriptionStatus.Canceled ) - else: - raise HTTPNotFound("Subscription not found") # Occurs whenever a subscription changes e.g. status changes. case "customer.subscription.updated": subscription = event["data"]["object"] @@ -94,8 +92,6 @@ def handle_billing_webhook_event(request, event): if id := subscription_service.find_subscriptionid(subscription_id): # Update subscription status. subscription_service.update_subscription_status(id, status) - else: - raise HTTPNotFound("Subscription not found") # Occurs whenever a customer is deleted. case "customer.deleted": customer = event["data"]["object"] @@ -105,8 +101,6 @@ def handle_billing_webhook_event(request, event): if subscription_service.get_subscriptions_by_customer(customer_id): # Delete the customer and all associated subscription data subscription_service.delete_customer(customer_id) - else: - raise HTTPNotFound("Customer subscription data not found") # Occurs whenever a customer is updated. case "customer.updated": customer = event["data"]["object"] diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 4fd465bdd33c..f5a83da948ea 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -387,12 +387,12 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2253 -#: warehouse/manage/views/organizations.py:890 +#: warehouse/manage/views/organizations.py:896 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2319 -#: warehouse/manage/views/organizations.py:955 +#: warehouse/manage/views/organizations.py:961 msgid "Invitation sent to '${username}'" msgstr "" @@ -405,30 +405,30 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2396 -#: warehouse/manage/views/organizations.py:1142 +#: warehouse/manage/views/organizations.py:1148 msgid "Invitation revoked from '${username}'." msgstr "" -#: warehouse/manage/views/organizations.py:866 +#: warehouse/manage/views/organizations.py:872 msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:877 +#: warehouse/manage/views/organizations.py:883 msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:1038 -#: warehouse/manage/views/organizations.py:1080 +#: warehouse/manage/views/organizations.py:1044 +#: warehouse/manage/views/organizations.py:1086 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1048 +#: warehouse/manage/views/organizations.py:1054 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1095 +#: warehouse/manage/views/organizations.py:1101 msgid "Expired invitation for '${username}' deleted." msgstr "" @@ -807,12 +807,13 @@ msgstr "" #: warehouse/templates/manage/manage_base.html:313 #: warehouse/templates/manage/manage_base.html:372 #: warehouse/templates/manage/organization/settings.html:194 -#: warehouse/templates/manage/organization/settings.html:249 +#: warehouse/templates/manage/organization/settings.html:250 +#: warehouse/templates/manage/organization/settings.html:256 #: warehouse/templates/manage/project/documentation.html:27 #: warehouse/templates/manage/project/release.html:182 #: warehouse/templates/manage/project/settings.html:126 #: warehouse/templates/manage/project/settings.html:175 -#: warehouse/templates/manage/project/settings.html:240 +#: warehouse/templates/manage/project/settings.html:247 #: warehouse/templates/manage/team/settings.html:77 msgid "Warning" msgstr "" @@ -3320,7 +3321,6 @@ msgstr[0] "" msgstr[1] "" #: warehouse/templates/manage/account.html:752 -#: warehouse/templates/manage/organization/settings.html:238 #, python-format msgid "" "transfer ownership or transfer ownership and remove project or delete project" +msgstr "" -#: warehouse/templates/manage/organization/settings.html:250 +#: warehouse/templates/manage/organization/settings.html:251 msgid "You will not be able to recover your organization after you delete it." msgstr "" +#: warehouse/templates/manage/organization/settings.html:258 +msgid "" +"Your subscription will be cancelled, and you will lose access to the " +"billing portal." +msgstr "" + +#: warehouse/templates/manage/organization/settings.html:260 +msgid "You will lose access to the billing portal." +msgstr "" + +#: warehouse/templates/manage/organization/settings.html:262 +#, python-format +msgid "" +"Please ensure you have retrieved all invoices from your billing portal before proceeding." +msgstr "" + #: warehouse/templates/manage/organization/teams.html:17 msgid "Organization teams" msgstr "" @@ -5673,7 +5704,6 @@ msgstr "" #: warehouse/templates/manage/project/settings.html:138 #: warehouse/templates/manage/project/settings.html:144 -#: warehouse/templates/manage/project/settings.html:199 msgid "" "Individual owners and maintainers of the project will retain their " "project permissions." @@ -5691,8 +5721,8 @@ msgid "Remove project" msgstr "" #: warehouse/templates/manage/project/settings.html:147 -#: warehouse/templates/manage/project/settings.html:211 -#: warehouse/templates/manage/project/settings.html:278 +#: warehouse/templates/manage/project/settings.html:218 +#: warehouse/templates/manage/project/settings.html:285 msgid "Project Name" msgstr "" @@ -5719,60 +5749,76 @@ msgstr "" msgid "Transferring this project will:" msgstr "" -#: warehouse/templates/manage/project/settings.html:189 -msgid "Add the project to another organization that you own." +#: warehouse/templates/manage/project/settings.html:188 +msgid "Revoke your direct Owner role on the project." msgstr "" #: warehouse/templates/manage/project/settings.html:191 +msgid "" +"You will retain Owner permissions on the project through your " +"organization role." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:196 +msgid "Add the project to another organization that you own." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:198 msgid "Add the project to an organization that you own." msgstr "" -#: warehouse/templates/manage/project/settings.html:195 +#: warehouse/templates/manage/project/settings.html:202 msgid "Grant full project permissions to owners of the organization." msgstr "" -#: warehouse/templates/manage/project/settings.html:211 +#: warehouse/templates/manage/project/settings.html:206 +msgid "" +"All other individual owners and maintainers of the project will retain " +"their project permissions." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:218 msgid "Transfer project" msgstr "" -#: warehouse/templates/manage/project/settings.html:217 +#: warehouse/templates/manage/project/settings.html:224 msgid "Cannot transfer project to another organization" msgstr "" -#: warehouse/templates/manage/project/settings.html:219 +#: warehouse/templates/manage/project/settings.html:226 msgid "Cannot transfer project to an organization" msgstr "" -#: warehouse/templates/manage/project/settings.html:224 +#: warehouse/templates/manage/project/settings.html:231 msgid "" "Organization owners can transfer the project to organizations that they " "own or manage." msgstr "" -#: warehouse/templates/manage/project/settings.html:225 +#: warehouse/templates/manage/project/settings.html:232 msgid "You are not an owner or manager of any other organizations." msgstr "" -#: warehouse/templates/manage/project/settings.html:227 +#: warehouse/templates/manage/project/settings.html:234 msgid "" "Project owners can transfer the project to organizations that they own or" " manage." msgstr "" -#: warehouse/templates/manage/project/settings.html:228 +#: warehouse/templates/manage/project/settings.html:235 msgid "You are not an owner or manager of any organizations." msgstr "" -#: warehouse/templates/manage/project/settings.html:238 -#: warehouse/templates/manage/project/settings.html:278 +#: warehouse/templates/manage/project/settings.html:245 +#: warehouse/templates/manage/project/settings.html:285 msgid "Delete project" msgstr "" -#: warehouse/templates/manage/project/settings.html:241 +#: warehouse/templates/manage/project/settings.html:248 msgid "Deleting this project will:" msgstr "" -#: warehouse/templates/manage/project/settings.html:246 +#: warehouse/templates/manage/project/settings.html:253 #, python-format msgid "" "Irreversibly delete the project along with %(count)s" @@ -5783,15 +5829,15 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/manage/project/settings.html:252 +#: warehouse/templates/manage/project/settings.html:259 msgid "Irreversibly delete the project" msgstr "" -#: warehouse/templates/manage/project/settings.html:256 +#: warehouse/templates/manage/project/settings.html:263 msgid "Make the project name available to any other PyPI user" msgstr "" -#: warehouse/templates/manage/project/settings.html:258 +#: warehouse/templates/manage/project/settings.html:265 msgid "" "This user will be able to make new releases under this project name, so " "long as the distribution filenames do not match filenames from a " diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index 7e0c4eceb925..5b0afc29dd24 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -341,6 +341,14 @@ def save_organization(self): previous_organization_description=previous_organization_description, previous_organization_orgtype=previous_organization_orgtype, ) + if self.organization.customer is not None: + self.billing_service.update_customer( + self.organization.customer.customer_id, + self.organization.customer_name( + self.request.registry.settings["site.name"] + ), + self.organization.description, + ) self.request.session.flash("Organization details updated", queue="success") @@ -485,10 +493,8 @@ def __init__(self, organization, request): def customer_id(self): if self.organization.customer is None: customer = self.billing_service.create_customer( - name=( + name=self.organization.customer_name( self.request.registry.settings["site.name"] - + " Organization - " - + self.organization.name ), description=self.organization.description, ) diff --git a/warehouse/mock/billing.py b/warehouse/mock/billing.py index 9f7ce8c5873e..6108847a0477 100644 --- a/warehouse/mock/billing.py +++ b/warehouse/mock/billing.py @@ -18,7 +18,6 @@ from pyramid.httpexceptions import HTTPNotFound, HTTPSeeOther from pyramid.view import view_config, view_defaults -from warehouse.admin.flags import AdminFlagValue from warehouse.api.billing import handle_billing_webhook_event from warehouse.organizations.models import Organization from warehouse.subscriptions.interfaces import IBillingService @@ -36,9 +35,9 @@ class MockBillingViews: def __init__(self, organization, request): billing_service = request.find_service(IBillingService, context=None) - if request.flags.enabled( - AdminFlagValue.DISABLE_ORGANIZATIONS - ) or not isinstance(billing_service, MockStripeBillingService): + if not request.organization_access or not isinstance( + billing_service, MockStripeBillingService + ): raise HTTPNotFound self.organization = organization self.request = request diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py index 521ca16e02e0..a83bbfeb20e4 100644 --- a/warehouse/organizations/models.py +++ b/warehouse/organizations/models.py @@ -417,6 +417,9 @@ def active_subscription(self): else: return None + def customer_name(self, site_name="PyPI"): + return f"{site_name} Organization - {self.display_name} ({self.name})" + class OrganizationNameCatalog(db.Model): diff --git a/warehouse/subscriptions/interfaces.py b/warehouse/subscriptions/interfaces.py index 10f7be48d0a0..2ff6c551b7a4 100644 --- a/warehouse/subscriptions/interfaces.py +++ b/warehouse/subscriptions/interfaces.py @@ -34,6 +34,11 @@ def create_customer(name, description): Create the Customer resource via Billing API with the given name and description """ + def update_customer(customer_id, name, description): + """ + Update a Customer resource via Billing API with the given name and description + """ + def create_checkout_session(customer_id, price_ids, success_url, cancel_url): """ # Create new Checkout Session for the order diff --git a/warehouse/subscriptions/services.py b/warehouse/subscriptions/services.py index bf9586bf2490..5253b1e9619c 100644 --- a/warehouse/subscriptions/services.py +++ b/warehouse/subscriptions/services.py @@ -78,6 +78,13 @@ def create_customer(self, name, description): description=description, ) + def update_customer(self, customer_id, name, description): + return self.api.Customer.modify( + customer_id, + name=name, + description=description, + ) + def create_checkout_session(self, customer_id, price_ids, success_url, cancel_url): """ # Create new Checkout Session for the order diff --git a/warehouse/templates/manage/organization/settings.html b/warehouse/templates/manage/organization/settings.html index 60742e83d1e4..d383b2c7277b 100644 --- a/warehouse/templates/manage/organization/settings.html +++ b/warehouse/templates/manage/organization/settings.html @@ -225,20 +225,21 @@

{% trans %}Cannot delete organization{% endtrans %}

{% pluralize %} Your organization currently owns {{ count }} projects. {% endtrans %} - {% trans count=active_projects|length %} - You must transfer ownership or delete this project before you can delete your organization. - {% pluralize %} - You must transfer ownership or delete these projects before you can delete your organization. - {% endtrans %} + {% trans %}For each project, you must either:{% endtrans %} + + {% trans %}Before you can delete your organization.{% endtrans %}

- {% trans %}Individual owners and maintainers of the project will retain their project permissions.{% endtrans %} + {% trans %}All other individual owners and maintainers of the project will retain their project permissions.{% endtrans %}

{% set action = request.route_path('manage.project.transfer_organization_project', project_name=project.normalized_name) %} {% set extra_fields %}