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 %}Warning{% endtrans %} + {% if organization.active_subscription %} + {% trans %}Your subscription will be cancelled, and you will lose access to the billing portal.{% endtrans %} + {% else %} + {% trans %}You will lose access to the billing portal.{% endtrans %} + {% endif %} + {% trans billing_href=request.route_path('manage.organization.subscription', organization_name=organization.normalized_name, _query={'next': request.current_route_path(organization_name=organization.normalized_name)})%} + Please ensure you have retrieved all invoices from your billing portal before proceeding. + {% endtrans %} +
+ {% endif %} + {% endif %} {{ confirm_button(gettext("Delete organization"), gettext("Organization Name"), "organization_name", organization.name) }} {% endif %} diff --git a/warehouse/templates/manage/project/settings.html b/warehouse/templates/manage/project/settings.html index 98403161f51d..00b57edde66c 100644 --- a/warehouse/templates/manage/project/settings.html +++ b/warehouse/templates/manage/project/settings.html @@ -183,6 +183,13 @@- {% 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 %}