diff --git a/config/urls.py b/config/urls.py index 97258ce96..9627ac2af 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,7 +19,7 @@ OrganizationViewSet as FEOrganizationViewSet, ) from squarelet.organizations.viewsets import ChargeViewSet, OrganizationViewSet -from squarelet.payments.views import PlanDetailView, PlanRedirectView +from squarelet.payments.views import PaymentsHubView, PlanDetailView, PlanRedirectView from squarelet.users.fe_api.viewsets import UserViewSet as FEUserViewSet from squarelet.users.views import ( LoginView, @@ -66,6 +66,7 @@ def redirect_erh(request, path=""): path("plans//", PlanRedirectView.as_view(), name="plan_detail_id"), path("plans//", PlanRedirectView.as_view(), name="plan_detail_slug"), path("selectplan/", SelectPlanView.as_view(), name="select_plan"), + path("payments/", PaymentsHubView.as_view(), name="payments"), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), # User management diff --git a/e2e/organizations.spec.ts b/e2e/organizations.spec.ts index df56e6197..770dc16b0 100644 --- a/e2e/organizations.spec.ts +++ b/e2e/organizations.spec.ts @@ -91,7 +91,7 @@ test.describe("Organization Viewing", () => { test("does NOT see plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toHaveCount(0); + await expect(page.locator("section#billing")).toHaveCount(0); }); }); @@ -112,7 +112,7 @@ test.describe("Organization Viewing", () => { test("does NOT see plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toHaveCount(0); + await expect(page.locator("section#billing")).toHaveCount(0); }); test("sees only admins in member list", async ({ page }) => { @@ -141,7 +141,7 @@ test.describe("Organization Viewing", () => { test("sees plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toBeVisible(); + await expect(page.locator("section#billing")).toBeVisible(); }); test("sees all members in user list (not just admins)", async ({ @@ -204,13 +204,13 @@ test.describe("Organization Viewing", () => { test("sees plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toBeVisible(); + await expect(page.locator("section#billing")).toBeVisible(); }); - test("sees button to change plans", async ({ page }) => { + test("sees button to upgrade plan", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); await expect( - page.locator('a[href$="/organizations/e2e-public-org/payment/"]'), + page.locator('a.btn.premium[href*="/plans/"][href$="-organization/"]'), ).toBeVisible(); }); }); @@ -253,13 +253,13 @@ test.describe("Organization Viewing", () => { test("sees plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toBeVisible(); + await expect(page.locator("section#billing")).toBeVisible(); }); - test("sees button to change plans", async ({ page }) => { + test("sees button to upgrade plan", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); await expect( - page.locator('a[href$="/organizations/e2e-public-org/payment/"]'), + page.locator('a.btn.premium[href*="/plans/"][href$="-organization/"]'), ).toBeVisible(); }); diff --git a/frontend/css/manage_subscriptions.css b/frontend/css/manage_subscriptions.css new file mode 100644 index 000000000..2bb1086c7 --- /dev/null +++ b/frontend/css/manage_subscriptions.css @@ -0,0 +1,96 @@ +.subscription-card { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.subscription-card h3 { + font-family: var(--font-sans, "Source Sans Pro"); + font-size: var(--font-md, 1rem); + font-style: normal; + font-weight: 600; + line-height: var(--font-xl, 1.5rem); + margin: 0 0 auto; +} + +.subscription-card .professional { + color: var(--blue-4); +} + +.subscription-card .organization { + color: var(--purple-4); +} + +.subscription-frequency { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; +} + +.subscription-row { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0; + justify-content: space-between; + align-items: center; +} + +.subscription-group { + display: flex; + gap: 1rem; +} + +@media (max-width: 28rem) { + .subscription-group { + flex: 1 0 100%; + } +} + +.subscription-detail { + flex: 0 1 auto; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--gray-5); + fill: var(--gray-4); +} + +.subscription-detail .icon { + display: inline-flex; + align-items: center; + width: var(--font-md, 0.75em); + height: var(--font-md, 0.75em); +} + +.subscription-detail .emails :not(:last-child):after { + content: ","; +} + +.account-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.remove-card-form { + margin: 0; +} + +.account-card { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.account-card .icon { + display: flex; + align-items: center; +} + +.account-card-brand { + text-transform: capitalize; +} \ No newline at end of file diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css new file mode 100644 index 000000000..27ba0aea6 --- /dev/null +++ b/frontend/css/modal_layout.css @@ -0,0 +1,108 @@ +.modal-layout { + box-sizing: border-box; +} + +.modal-layout header { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.25rem 1rem; + margin: 0.5rem 0 2rem; +} + +.modal-layout header .arrow { + fill: var(--gray-4, #5c717c); + width: var(--font-md, 1rem); + height: var(--font-md, 1rem); + align-self: center; +} + +.modal-layout main { + box-sizing: border-box; + width: 100%; + border-radius: 0.5rem; + border: 1px solid var(--gray-2, #d8dee2); + background: var(--white, #fff); + box-shadow: var(--shadow-1); + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.modal-layout :is(h1, h2, h3) { + margin: 0; +} + +.modal-layout h1 { + font-size: var(--font-xl, 24px); + font-weight: 400; +} + +.modal-layout h2 { + font-size: var(--font-xl, 24px); +} + +.modal-layout h3 { + font-size: var(--font-md, 16px); +} + +.modal-layout .info { + flex: 0 1 auto; + font-size: var(--font-sm); + opacity: 0.7; + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--gray-5); +} + +.modal-layout .info .icon { + display: inline-flex; + align-items: center; + width: var(--font-md, 0.75em); + height: var(--font-md, 0.75em); +} + +.modal-layout .help { + font-weight: var(--font-regular, 400); + font-size: var(--font-sm, 0.875em); +} + +.modal-layout .plan.professional { + color: var(--blue-4); +} + +.modal-layout .plan.organization { + color: var(--purple-4); +} + +.modal-layout p { + margin: 0; +} + +.modal-layout form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.modal-layout .buttons { + display: flex; + gap: 1rem; + align-items: center; + margin-top: 0.5rem; +} + +.modal-layout .footer { + margin-top: 2rem; + font-weight: 600; + font-family: var(--font-sans, "Source Sans Pro"); + font-size: var(--font-md, 16px); +} + +.modal-layout .footer a { + color: var(--gray-5, #233944); + text-decoration-color: var(--gray-5, #233944); +} diff --git a/frontend/css/payments_hub.css b/frontend/css/payments_hub.css new file mode 100644 index 000000000..1b32ba440 --- /dev/null +++ b/frontend/css/payments_hub.css @@ -0,0 +1,5 @@ +header .title { + display: flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css index 96f27d82f..f64f4f2f5 100644 --- a/frontend/css/plan_card.css +++ b/frontend/css/plan_card.css @@ -19,7 +19,7 @@ flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 0.5rem 1rem; + gap: 1rem 1rem; font-size: var(--font-md, 1rem); } @@ -35,6 +35,14 @@ font-weight: var(--font-semibold, 600); } +.plan-name.professional { + color: var(--blue-4); +} + +.plan-name.organization { + color: var(--purple-4); +} + a.plan-name { color: var(--blue-3); } @@ -56,27 +64,68 @@ a.plan-name { } .plan-detail { - flex: 0 1 auto; + flex: 1 0 100%; padding: 0; display: flex; + flex-wrap: wrap; align-items: center; - gap: 0.5rem; + gap: 0.25rem 1rem; margin: 0.25rem 0; } -.plan-detail .icon { +.plan-card h3 { + font-weight: 600; + font-size: var(--font-md, 1rem); + color: var(--gray-5, #233944); + margin: 0; + white-space: nowrap; +} + +.plan-card .caption { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0; + flex: 1 0 0; +} + +.plan-card .caption .icon { display: inline-flex; align-items: center; color: var(--gray-4, #5c717c); - width: var(--font-sm, 1rem); - height: var(--font-sm, 1rem); + width: var(--font-md, 1em); + height: var(--font-md, 1em); +} + +.plan-list { + margin: 0; + padding: 0; + list-style-type: none; + display: flex; + gap: 0.5rem 1.5rem; + flex: 1 0 100%; +} + +.plan-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + flex: 1 0 100%; +} + +.plan-info { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; } /* Plan benefits checklist */ .plan-benefits.checklist { column-count: 2; column-gap: 0.5rem; - margin: 0; + margin: 0.5rem 0 0; padding: 0.5rem; border: 1px solid var(--gray-2, #d8dee2); border-radius: 0.5rem; @@ -86,6 +135,10 @@ a.plan-name { .plan-benefits.checklist { column-count: 1; } + + .plan-card .caption { + flex: 1 0 auto; + } } .plan-benefits .checklist-item { @@ -113,14 +166,31 @@ a.plan-name { } .plan-upgrade p { - margin: 0 0 0.5rem; -} - -.plan-upgrade .plan-benefits { - border: none; - padding: 0; + margin: 0; } .plan-upgrade .plan-benefits .checklist-item .icon svg { fill: var(--green-3); } + +.account-row { + display: flex; + justify-contents: space-between; + align-items: center; + gap: 1rem; +} + +.account-card { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.account-card .icon { + display: flex; + align-items: center; +} + +.account-card-brand { + text-transform: capitalize; +} \ No newline at end of file diff --git a/frontend/css/receipts_card.css b/frontend/css/receipts_card.css new file mode 100644 index 000000000..2241300ae --- /dev/null +++ b/frontend/css/receipts_card.css @@ -0,0 +1,58 @@ +.receipts-card { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.receipts-card table { + font-size: var(--font-sm); + border-spacing: 0; +} + +/* Visually hidden table header for accessibility */ +.receipts-card thead { + width: 1cm; + height: 1cm; + overflow: hidden; + position: absolute; + clip: rect(0 0 0 0); + clip-path: inset(50%); +} + +.receipts-card td { + padding: 0; +} + +.receipts-card tr:not(:last-child) td { + padding-bottom: 1rem; +} + +.receipts-card time { + font-weight: 600; + white-space: nowrap; +} + +.receipts-card .description { + padding-inline: 0.5rem; +} + +.receipts-card .amount { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.receipts-card .actions { + display: flex; + justify-content: flex-end; +} + +.receipts-card nav { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; +} diff --git a/frontend/css/sidebar_layout.css b/frontend/css/sidebar_layout.css index 0a841a1f3..44d833563 100644 --- a/frontend/css/sidebar_layout.css +++ b/frontend/css/sidebar_layout.css @@ -128,6 +128,16 @@ flex-shrink: 0; } +.sidebar-layout > .sidebar .nav-item.active, +.sidebar-layout > .sidebar a.nav-item.active { + background: var(--blue-1, #eef3f9); + color: var(--blue-5, #053775); +} + +.sidebar-layout > .sidebar .nav-item.active svg { + fill: var(--blue-5, #053775); +} + /* Content area */ .sidebar-layout > .content { diff --git a/frontend/icons/tag.svg b/frontend/icons/tag.svg new file mode 100644 index 000000000..c7ff5f067 --- /dev/null +++ b/frontend/icons/tag.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/views/manage_subscriptions.ts b/frontend/views/manage_subscriptions.ts new file mode 100644 index 000000000..d8e9d17ee --- /dev/null +++ b/frontend/views/manage_subscriptions.ts @@ -0,0 +1,2 @@ +import "@/css/sidebar_layout.css"; +import "@/css/manage_subscriptions.css"; diff --git a/frontend/views/modal_layout.ts b/frontend/views/modal_layout.ts new file mode 100644 index 000000000..e10328153 --- /dev/null +++ b/frontend/views/modal_layout.ts @@ -0,0 +1 @@ +import "@/css/modal_layout.css"; diff --git a/frontend/views/payments.ts b/frontend/views/payments.ts new file mode 100644 index 000000000..97f47e957 --- /dev/null +++ b/frontend/views/payments.ts @@ -0,0 +1 @@ +import "@/css/sidebar_layout.css"; diff --git a/frontend/views/payments_hub.ts b/frontend/views/payments_hub.ts new file mode 100644 index 000000000..1b43da1ca --- /dev/null +++ b/frontend/views/payments_hub.ts @@ -0,0 +1,2 @@ +import "@/css/sidebar_layout.css"; +import "@/css/payments_hub.css"; diff --git a/frontend/views/receipts_card.ts b/frontend/views/receipts_card.ts new file mode 100644 index 000000000..627a87194 --- /dev/null +++ b/frontend/views/receipts_card.ts @@ -0,0 +1 @@ +import "@/css/receipts_card.css"; diff --git a/frontend/views/update_card.ts b/frontend/views/update_card.ts new file mode 100644 index 000000000..871c50aab --- /dev/null +++ b/frontend/views/update_card.ts @@ -0,0 +1,100 @@ +import { d, exists, on } from "../util"; + +const Stripe = window["Stripe"] as any; + +const STRIPE_STYLE = { + base: { + color: "#3F3F3F", + fontSize: "18px", + fontFamily: "system-ui, sans-serif", + fontSmoothing: "antialiased", + "::placeholder": { + color: "#899194", + }, + }, + invalid: { + color: "#e5424d", + ":focus": { + color: "#303238", + }, + }, +}; + +export class UpdateCardView { + readonly stripePkInput = d("id_stripe_pk") as HTMLInputElement; + readonly tokenInput = d("id_stripe_token") as HTMLInputElement; + readonly errorElem = d("card-errors"); + readonly form = d("stripe-form") as HTMLFormElement; + + constructor() { + this.initUpdateCardForm(); + } + + initUpdateCardForm(): void { + const stripePk = this.stripePkInput.value; + if (!stripePk || !Stripe) return; + + const stripe = Stripe(stripePk); + const elements = stripe.elements(); + const card = elements.create("card", { style: STRIPE_STYLE }); + card.mount("#card-element"); + + card.addEventListener( + "change", + (event: { error?: { message: string } }) => { + this.errorElem.textContent = event.error?.message ?? ""; + }, + ); + + // We don't want the browser to fill this in with old values + this.tokenInput.value = ""; + + this.form.addEventListener("submit", (event) => { + event.preventDefault(); + + // Create token for new card, then AJAX submit + stripe.createToken(card).then((result) => { + if (result.error) { + this.errorElem.textContent = result.error.message; + } else { + ( + document.getElementById("id_stripe_token") as HTMLInputElement + ).value = result.token.id; + this.submitViaAjax(); + } + }); + }); + } + + submitViaAjax() { + fetch(this.form.action || window.location.href, { + method: "POST", + headers: { "X-Requested-With": "XMLHttpRequest" }, + body: new FormData(this.form), + }) + .then((response) => + response.json().then((data) => ({ status: response.status, data })), + ) + .then(({ status, data }) => { + if (status >= 200 && status < 300) { + window.location.href = data.redirect; + } else { + if (this.errorElem) { + this.errorElem.textContent = + data.error || "An error occurred. Please try again."; + } + } + }) + .catch(() => { + if (this.errorElem) { + this.errorElem.textContent = + "A network error occurred. Please try again."; + } + }); + } +} + +/* Load the UpdateCardView */ +if (exists("id_stripe_pk")) { + new UpdateCardView(); +} diff --git a/squarelet/organizations/mixins.py b/squarelet/organizations/mixins.py index dda15cfe6..b9ce5de9e 100644 --- a/squarelet/organizations/mixins.py +++ b/squarelet/organizations/mixins.py @@ -1,6 +1,9 @@ # Django from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin +# Squarelet +from squarelet.organizations.models.organization import Organization + class OrganizationAdminMixin(UserPassesTestMixin): """Only allow access to organization admins""" @@ -23,6 +26,8 @@ class OrganizationPermissionMixin(PermissionRequiredMixin): def has_permission(self): user = self.request.user obj = self.get_object() + assert isinstance(obj, Organization) + perms = self.get_permission_required() return all(user.has_perm(perm, obj) for perm in perms) diff --git a/squarelet/organizations/tests/test_permissions.py b/squarelet/organizations/tests/test_permissions.py index f6a94f4cb..4235e030b 100644 --- a/squarelet/organizations/tests/test_permissions.py +++ b/squarelet/organizations/tests/test_permissions.py @@ -245,6 +245,11 @@ class TestDetailPermissionContext(ViewTestMixin): view = views.Detail url = "/organizations/{slug}/" + @pytest.fixture(autouse=True) + def _mock_card(self, mocker): + """Avoid hitting Stripe when the view fetches the customer's card.""" + mocker.patch("squarelet.organizations.models.Customer.card", None) + def test_detail_admin_sees_manage_members_link( self, rf, organization_factory, user_factory ): diff --git a/squarelet/organizations/tests/test_views.py b/squarelet/organizations/tests/test_views.py index 20cf34aeb..aef904c12 100644 --- a/squarelet/organizations/tests/test_views.py +++ b/squarelet/organizations/tests/test_views.py @@ -51,6 +51,11 @@ def _setup_plan(self): slug="organization", defaults={"name": "Organization"} ) + @pytest.fixture(autouse=True) + def _mock_card(self, mocker): + """Avoid hitting Stripe when the view fetches the customer's card.""" + mocker.patch("squarelet.organizations.models.Customer.card", None) + def test_get_anonymous(self, rf, organization_factory, user_factory): user = user_factory() admin = user_factory() @@ -540,6 +545,11 @@ def _setup_plan(self): slug="organization", defaults={"name": "Organization"} ) + @pytest.fixture(autouse=True) + def _mock_card(self, mocker): + """Avoid hitting Stripe when the view fetches the customer's card.""" + mocker.patch("squarelet.organizations.models.Customer.card", None) + def test_staff_can_sync_wix_for_org_with_direct_wix_plan( self, rf, organization_factory, plan_factory, user_factory, mocker ): @@ -740,6 +750,11 @@ def _setup_plan(self): slug="organization", defaults={"name": "Organization"} ) + @pytest.fixture(autouse=True) + def _mock_card(self, mocker): + """Avoid hitting Stripe when the view fetches the customer's card.""" + mocker.patch("squarelet.organizations.models.Customer.card", None) + def test_join_button_shown_for_non_member( self, rf, organization_factory, user_factory ): @@ -1319,6 +1334,377 @@ def test_post_remove_card_on_file( self.assert_message(messages.SUCCESS, "Credit card removed") +@pytest.mark.django_db() +class TestRemoveCard(ViewTestMixin): + """Test the Organization Remove Card view""" + + view = views.RemoveCard + url = "/organizations/{slug}/card/remove/" + + @staticmethod + def _mock_card_on_file(mocker, present=True): + """Patch the customer's card-on-file state.""" + mocker.patch( + "squarelet.organizations.models.Customer.payment_details", + new_callable=mocker.PropertyMock, + return_value=mocker.Mock() if present else None, + ) + + def test_post_remove_card(self, rf, organization_factory, user_factory, mocker): + """Removes the card and redirects when there is no active subscription""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_called_once() + self.assert_message(messages.SUCCESS, "Credit card removed") + + def test_post_remove_card_stripe_error( + self, rf, organization_factory, user_factory, mocker + ): + """A Stripe error while removing surfaces an error message""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method", + side_effect=stripe.StripeError("boom"), + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + # pylint: disable=protected-access + level, message, _extra = self.request._messages.add.call_args.args + assert level == messages.ERROR + assert message.startswith("Payment error:") + + def test_no_card_on_file(self, rf, organization_factory, user_factory, mocker): + """Removal with no card on file errors and never calls remove""" + self._mock_card_on_file(mocker, present=False) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_not_called() + self.assert_message(messages.ERROR, "You do not have a card on file to remove.") + + def test_blocked_by_active_subscription( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """A non-cancelled subscription blocks removal""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + subscription_factory( + organization=organization, plan=plan_factory(), cancelled=False + ) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_not_called() + self.assert_message( + messages.ERROR, + "You must cancel your active subscriptions before " + "removing your payment method.", + ) + + def test_allowed_when_all_subscriptions_cancelled( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """Removal is allowed when every subscription is cancelled""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + subscription_factory( + organization=organization, plan=plan_factory(), cancelled=True + ) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_called_once() + self.assert_message(messages.SUCCESS, "Credit card removed") + + @staticmethod + def _ajax_post(rf, user, slug): + """Build an AJAX POST request to the remove-card route.""" + request = rf.post( + f"/organizations/{slug}/card/remove/", + {}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + request.user = user + # pylint: disable=protected-access + request._messages = MagicMock() + request.session = MagicMock() + return views.RemoveCard.as_view()(request, slug=slug) + + def test_ajax_remove_card(self, rf, organization_factory, user_factory, mocker): + """AJAX removal returns JSON with the redirect target and message""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self._ajax_post(rf, user, organization.slug) + assert response.status_code == 200 + assert json.loads(response.content)["message"] == "Credit card removed" + + def test_ajax_blocked_by_active_subscription( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """AJAX removal returns a 400 with the error when blocked""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + subscription_factory( + organization=organization, plan=plan_factory(), cancelled=False + ) + response = self._ajax_post(rf, user, organization.slug) + assert response.status_code == 400 + mocked_remove.assert_not_called() + assert ( + "cancel your active subscriptions" in json.loads(response.content)["error"] + ) + + def test_get_not_allowed(self, rf, organization_factory, user_factory): + """Removal is POST-only""" + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, slug=organization.slug) + assert response.status_code == 405 + + def test_staff_remove_card_creates_action( + self, rf, organization_factory, user_factory, mocker + ): + """Staff removing a card on someone's behalf is logged for accountability""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + response = self.call_view(rf, staff_member, {}, slug=organization.slug) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="removed the payment method", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + + def test_non_staff_remove_card_no_action( + self, rf, organization_factory, user_factory, mocker + ): + """A regular admin removing their own card is not logged""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + response = self.call_view(rf, admin, {}, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="removed the payment method").exists() + + +@pytest.mark.django_db() +class TestUpdateCard(ViewTestMixin): + """Test staff-action logging on the Organization Update Card view""" + + view = views.UpdateCard + url = "/organizations/{slug}/card/" + + def test_staff_update_card_creates_action( + self, rf, organization_factory, user_factory, mocker + ): + """Staff updating a card on someone's behalf is logged for accountability""" + mocker.patch("squarelet.organizations.models.Customer.payment_details", None) + mocker.patch("squarelet.organizations.models.Organization.save_card") + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + data = {"stripe_token": "token", "stripe_pk": "key"} + response = self.call_view(rf, staff_member, data, slug=organization.slug) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="updated the payment method", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + + def test_non_staff_update_card_no_action( + self, rf, organization_factory, user_factory, mocker + ): + """A regular admin updating their own card is not logged""" + mocker.patch("squarelet.organizations.models.Customer.payment_details", None) + mocker.patch("squarelet.organizations.models.Organization.save_card") + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + data = {"stripe_token": "token", "stripe_pk": "key"} + response = self.call_view(rf, admin, data, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="updated the payment method").exists() + + +@pytest.mark.django_db() +class TestCancelSubscription(ViewTestMixin): + """Test staff-action logging on the Organization Cancel Subscription view""" + + view = views.CancelSubscription + url = "/organizations/{slug}/subscriptions/{pk}/cancel" + + def test_staff_cancel_subscription_creates_action( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """Staff cancelling a subscription on someone's behalf is logged""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + plan = plan_factory(name="Professional") + subscription = subscription_factory(organization=organization, plan=plan) + + response = self.call_view( + rf, staff_member, {}, slug=organization.slug, pk=subscription.pk + ) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="cancelled a subscription", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + assert action.description == "Professional" + + def test_non_staff_cancel_subscription_no_action( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """A regular admin cancelling their own subscription is not logged""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + subscription = subscription_factory( + organization=organization, plan=plan_factory() + ) + + response = self.call_view( + rf, admin, {}, slug=organization.slug, pk=subscription.pk + ) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="cancelled a subscription").exists() + + +@pytest.mark.django_db() +class TestUpdateReceiptEmail(ViewTestMixin): + """Test staff-action logging on the Organization Update Receipt Email view""" + + view = views.UpdateReceiptEmail + url = "/organizations/{slug}/receipt-email/" + + def test_staff_update_receipt_email_creates_action( + self, rf, organization_factory, user_factory + ): + """Staff updating receipt emails on someone's behalf is logged""" + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + data = {"receipt_emails": "receipts@example.com"} + response = self.call_view(rf, staff_member, data, slug=organization.slug) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="updated the receipt emails", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + + def test_non_staff_update_receipt_email_no_action( + self, rf, organization_factory, user_factory + ): + """A regular admin updating their own receipt emails is not logged""" + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + data = {"receipt_emails": "receipts@example.com"} + response = self.call_view(rf, admin, data, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="updated the receipt emails").exists() + + def test_staff_admin_of_own_org_no_action( + self, rf, organization_factory, user_factory + ): + """Staff managing an org they administer is not logged — it's their + own account, not an action taken on someone else's behalf""" + staff_admin = user_factory(is_staff=True) + organization = organization_factory(admins=[staff_admin]) + data = {"receipt_emails": "receipts@example.com"} + response = self.call_view(rf, staff_admin, data, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="updated the receipt emails").exists() + + @pytest.mark.django_db() class TestCreate(ViewTestMixin): """Test the Organization Create view""" diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 45fe8a75c..1030d9a2c 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -1,5 +1,6 @@ # Django from django.urls import path +from django.views.generic.base import RedirectView # Local from . import views @@ -16,7 +17,31 @@ "~charge-pdf//", view=views.PDFChargeDetail.as_view(), name="charge-pdf" ), path( - "/payment/", view=views.UpdateSubscription.as_view(), name="payment" + "/subscriptions/", + view=views.ManageSubscriptions.as_view(), + name="subscriptions", + ), + path( + "/subscriptions//cancel", + view=views.CancelSubscription.as_view(), + name="cancel-subscription", + ), + path("/card/", view=views.UpdateCard.as_view(), name="update-card"), + path( + "/card/remove/", + view=views.RemoveCard.as_view(), + name="remove-card", + ), + path( + "/receipt-email/", + view=views.UpdateReceiptEmail.as_view(), + name="update-receipt-email", + ), + path("/payments/", view=views.PaymentsList.as_view(), name="payments"), + path( + "/payment/", + view=RedirectView.as_view(pattern_name="organizations:subscriptions"), + name="payment", ), path("/update/", view=views.Update.as_view(), name="update"), path( diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index a53be83f4..b01833129 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -13,8 +13,14 @@ ) from .profile import RequestProfileChange, ReviewProfileChange, Update from .subscription import ( + CancelSubscription, ChargeDetail, + ManageSubscriptions, + PaymentsList, PDFChargeDetail, + RemoveCard, + UpdateCard, + UpdateReceiptEmail, UpdateSubscription, stripe_webhook, ) @@ -25,7 +31,13 @@ "List", "autocomplete", # Subscription views + "ManageSubscriptions", "UpdateSubscription", + "UpdateCard", + "RemoveCard", + "UpdateReceiptEmail", + "CancelSubscription", + "PaymentsList", "ChargeDetail", "PDFChargeDetail", "stripe_webhook", diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py index 0bf6bb9a7..97a2d1a4b 100644 --- a/squarelet/organizations/views/detail.py +++ b/squarelet/organizations/views/detail.py @@ -7,14 +7,12 @@ from django.db.models.functions import Lower, StrIndex from django.http import JsonResponse from django.shortcuts import redirect -from django.utils import timezone from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView # Standard Library import logging -from datetime import datetime # Squarelet from squarelet.core.mixins import AdminLinkMixin @@ -22,7 +20,6 @@ from squarelet.organizations.forms import InvitationAcceptForm from squarelet.organizations.models import Invitation, Membership, Organization, Plan from squarelet.organizations.models.invitation import OrganizationInvitation -from squarelet.organizations.payments.factory import get_payment_provider from squarelet.organizations.tasks import sync_wix # How much to paginate organizations list by @@ -54,22 +51,14 @@ def get_context_data(self, **kwargs): context["users"] = admins context["admins"] = admins - # Get the current plan and subscription, if any - current_plan = None + # Get subscriptions, if any upgrade_plan = Plan.objects.get(slug="organization") - subscription = None - if hasattr(org, "subscriptions"): - subscription = org.subscriptions.first() - if subscription and hasattr(subscription, "plan"): - current_plan = subscription.plan - upgrade_plan = None - context["current_plan"] = current_plan + context["subscriptions"] = org.subscriptions.all() context["upgrade_plan"] = upgrade_plan context["member_count"] = len(users) context["admin_count"] = len(admins) - if current_plan and subscription: - context.update(self._get_subscription_context(org, subscription)) + context.update(self._get_subscription_context(org)) # Any member (not just admins) may request verification, but only if # they have confirmed an email address on their account. @@ -117,29 +106,15 @@ def _get_membership_context(self, user, org): ctx["pending_invitations"] = org.invitations.get_pending_invitations() return ctx - def _get_subscription_context(self, org, subscription): + def _get_subscription_context(self, org): """Return context dict for card, next charge date, and cancellation status.""" customer = org.customer() ctx = { "current_plan_card": bool(customer.stripe_payment_method_id), "current_plan_card_brand": customer.payment_brand, "current_plan_card_last4": customer.payment_last4, - "current_plan_cancelled": subscription.cancelled, } - stripe_sub = subscription.stripe_subscription - if stripe_sub: - time_stamp = ( - get_payment_provider() - .get_subscription_service() - .get_current_period_end(stripe_sub) - ) - if time_stamp: - tz_datetime = datetime.fromtimestamp( - time_stamp, tz=timezone.get_current_timezone() - ) - ctx["current_plan_next_charge_date"] = tz_datetime.date() - return ctx def _get_groups_context(self, user, org): diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 143c6e051..9ec9529c6 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -1,7 +1,7 @@ # Django from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import UserPassesTestMixin +from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.http.response import ( HttpResponse, HttpResponseBadRequest, @@ -9,6 +9,7 @@ JsonResponse, ) from django.shortcuts import redirect +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -19,6 +20,7 @@ import json import logging import sys +from datetime import datetime # Third Party import stripe @@ -35,6 +37,7 @@ from squarelet.organizations.models import Charge, Organization from squarelet.organizations.payments.base import PaymentActionRequired from squarelet.organizations.payments.exceptions import SubscriptionError +from squarelet.organizations.payments.factory import get_payment_provider from squarelet.organizations.tasks import ( handle_charge_succeeded, handle_customer_updated, @@ -48,6 +51,14 @@ handle_subscription_deleted, handle_subscription_updated, ) +from squarelet.payments.views import ( + BaseCancelSubscription, + BaseManageSubscriptions, + BasePaymentsList, + BaseRemoveCard, + BaseUpdateCard, + BaseUpdateReceiptEmail, +) logger = logging.getLogger(__name__) @@ -147,13 +158,6 @@ def get_context_data(self, **kwargs): context["failed_receipt_emails"] = self.object.receipt_emails.filter( failed=True ) - # Provide a single subscription for the template to check cancelled status. - # In the multi-subscription world this will need to be revisited, but for - # now the template only needs to know about the primary (first) subscription. - plan = self.object.plans.first() - context["current_subscription"] = ( - self.object.subscriptions.filter(plan=plan).first() if plan else None - ) return context def get_initial(self): @@ -169,6 +173,43 @@ def get_initial(self): } +class OrgSubscriptionView(OrganizationPermissionMixin): + """Base class for org subscription views.""" + + subject = "organizations" + individual = False + permission_required = "organizations.can_edit_subscription" + + +class ManageSubscriptions(OrgSubscriptionView, BaseManageSubscriptions): + pass + + +class UpdateCard(OrgSubscriptionView, BaseUpdateCard): + pass + + +class RemoveCard(OrgSubscriptionView, BaseRemoveCard): + pass + + +class UpdateReceiptEmail(OrgSubscriptionView, BaseUpdateReceiptEmail): + pass + + +class CancelSubscription(OrgSubscriptionView, BaseCancelSubscription): + pass + + +class PaymentsList(PermissionRequiredMixin, BasePaymentsList): + subject = "organizations" + individual = False + + def has_permission(self): + user = self.request.user + return user.has_perm("organizations.can_view_charge", self.get_organization()) + + @method_decorator(xframe_options_sameorigin, name="dispatch") class ChargeDetail(UserPassesTestMixin, DetailView): queryset = Charge.objects.all() @@ -293,3 +334,19 @@ def stripe_webhook(request): if handler: handler.delay(event_obj) return HttpResponse() + + +def get_subscription_next_date(subscription): + stripe_sub = subscription.stripe_subscription + if stripe_sub: + time_stamp = ( + get_payment_provider() + .get_subscription_service() + .get_current_period_end(stripe_sub) + ) + if time_stamp: + tz_datetime = datetime.fromtimestamp( + time_stamp, tz=timezone.get_current_timezone() + ) + return tz_datetime.date() + return None diff --git a/squarelet/payments/forms.py b/squarelet/payments/forms.py index 8ebb67ec3..8090b814a 100644 --- a/squarelet/payments/forms.py +++ b/squarelet/payments/forms.py @@ -2,20 +2,25 @@ # Django from django import forms +from django.core.validators import validate_email from django.db.models import Q from django.utils.translation import gettext_lazy as _ # Standard Library import logging +import re import sys # Third Party import stripe from allauth.account.adapter import get_adapter from allauth.account.utils import has_verified_email +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field as CrispyField, Layout # Squarelet from squarelet.core.forms import StripeForm +from squarelet.core.layout import Field from squarelet.organizations.models import Organization, Plan from squarelet.organizations.models.payment import get_payment_brand from squarelet.users.forms import NewOrganizationModelChoiceField @@ -421,3 +426,72 @@ def save(self, user): "payment_method": self.cleaned_data.get("payment_method"), "stripe_token": self.cleaned_data.get("stripe_token"), } + + +class CardForm(StripeForm): + """Update the credit card on file for an organization.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # This form is only for replacing the current card, so these fields aren't used + self.fields.pop("use_card_on_file", None) + self.fields.pop("remove_card_on_file", None) + + self.helper = FormHelper() + self.helper.layout = Layout( + Field("stripe_pk"), + Field("stripe_token"), + ) + self.helper.form_tag = False + + +class UpdateReceiptEmailForm(forms.ModelForm): + """Update the receipt email for an organization.""" + + receipt_emails = forms.CharField( + label=_("Receipt emails"), + widget=forms.TextInput(), + required=True, + help_text=_("Enter one or more email addresses, separated by commas"), + ) + + class Meta: + model = Organization + fields = ["receipt_emails"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.template_pack = "forms" + self.helper.layout = Layout( + CrispyField("receipt_emails"), + ) + self.helper.form_tag = False + + def clean_receipt_emails(self): + """Make sure each entry is a valid email""" + emails = re.split(r",\s*", self.cleaned_data["receipt_emails"]) + emails = [e.strip() for e in emails if e.strip()] + bad_emails = [] + for email in emails: + try: + validate_email(email.strip()) + except forms.ValidationError: + bad_emails.append(email) + if bad_emails: + bad_emails_str = ", ".join(bad_emails) + raise forms.ValidationError(f"Invalid email: {bad_emails_str}") + return emails + + +class CancelSubscriptionForm(forms.ModelForm): + """Cancel a subscription.""" + + class Meta: + model = Organization + fields = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False diff --git a/squarelet/payments/tests/test_hub.py b/squarelet/payments/tests/test_hub.py new file mode 100644 index 000000000..653df7f94 --- /dev/null +++ b/squarelet/payments/tests/test_hub.py @@ -0,0 +1,157 @@ +# Django +from django.urls import reverse + +# Third Party +import factory +import pytest + +# Squarelet +from squarelet.core.tests.mixins import ViewTestMixin +from squarelet.payments import views + + +@pytest.mark.django_db() +class TestPaymentsHubView(ViewTestMixin): + """Test the cross-organization payments hub.""" + + view = views.PaymentsHubView + url = "/payments/" + + def test_unauthenticated_user_redirected_to_login(self, rf): + """Anonymous users are redirected to the login page.""" + response = self.call_view(rf, user=None) + + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + def test_includes_personal_account(self, rf, user_factory): + """The user's personal account is always the first account listed.""" + user = user_factory() + + response = self.call_view(rf, user) + + accounts = response.context_data["accounts"] + assert accounts[0]["organization"] == user.individual_organization + assert accounts[0]["history_url"] == reverse( + "users:payments", kwargs={"username": user.username} + ) + + def test_includes_admin_orgs_but_not_member_orgs( + self, rf, user_factory, organization_factory, membership_factory + ): + """Admin orgs appear; orgs where the user is only a member do not.""" + user = user_factory() + admin_org = organization_factory() + admin_org.add_creator(user) + member_org = organization_factory() + membership_factory(user=user, organization=member_org, admin=False) + + response = self.call_view(rf, user) + + orgs = [ + account["organization"] for account in response.context_data["accounts"] + ] + assert admin_org in orgs + assert member_org not in orgs + + def test_admin_org_links_to_org_payment_history( + self, rf, user_factory, organization_factory + ): + """Each admin org links out to its full org payment history.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user) + + account = next( + a for a in response.context_data["accounts"] if a["organization"] == org + ) + assert account["history_url"] == reverse( + "organizations:payments", kwargs={"slug": org.slug} + ) + + def test_account_includes_card_and_management_urls( + self, rf, user_factory, organization_factory + ): + """Each account exposes its card on file and the URLs used to + manage the subscription and payment method.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + customer = org.customer() + customer.payment_brand = "Visa" + customer.payment_last4 = "4242" + customer.save() + + response = self.call_view(rf, user) + + account = next( + a for a in response.context_data["accounts"] if a["organization"] == org + ) + assert account["card_brand"] == "Visa" + assert account["card_last4"] == "4242" + assert account["manage_url"] == reverse( + "organizations:subscriptions", kwargs={"slug": org.slug} + ) + assert account["update_card_url"] == reverse( + "organizations:update-card", kwargs={"slug": org.slug} + ) + + def test_personal_account_management_urls_use_username(self, rf, user_factory): + """The personal account's management URLs are keyed on username.""" + user = user_factory() + + response = self.call_view(rf, user) + + account = response.context_data["accounts"][0] + assert account["organization"] == user.individual_organization + assert account["manage_url"] == reverse( + "users:subscriptions", kwargs={"username": user.username} + ) + assert account["update_card_url"] == reverse( + "users:update-card", kwargs={"username": user.username} + ) + + def test_shows_five_most_recent_payments_per_account( + self, rf, user_factory, organization_factory, charge_factory + ): + """Each account shows at most its five most recent charges, newest first.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + charge_factory.create_batch( + 7, organization=org, charge_id=factory.Sequence(lambda n: f"ch_recent_{n}") + ) + + response = self.call_view(rf, user) + + account = next( + a for a in response.context_data["accounts"] if a["organization"] == org + ) + payments = account["payments"] + assert len(payments) == 5 + # Newest first + created = [p.created_at for p in payments] + assert created == sorted(created, reverse=True) + + def test_does_not_leak_other_org_payments( + self, rf, user_factory, organization_factory, charge_factory + ): + """Charges from an org the user cannot view are not shown.""" + user = user_factory() + own_org = organization_factory() + own_org.add_creator(user) + other_org = organization_factory() + charge_factory.create_batch( + 3, + organization=other_org, + charge_id=factory.Sequence(lambda n: f"ch_other_{n}"), + ) + + response = self.call_view(rf, user) + + orgs = [ + account["organization"] for account in response.context_data["accounts"] + ] + assert other_org not in orgs diff --git a/squarelet/payments/tests/test_payments_list.py b/squarelet/payments/tests/test_payments_list.py new file mode 100644 index 000000000..5dc36747f --- /dev/null +++ b/squarelet/payments/tests/test_payments_list.py @@ -0,0 +1,96 @@ +# Django +from django.urls import reverse + +# Third Party +import pytest + +# Squarelet +from squarelet.core.tests.mixins import ViewTestMixin +from squarelet.organizations.views import subscription as org_views +from squarelet.users import views as user_views + + +@pytest.mark.django_db() +class TestOrganizationPaymentsListNav(ViewTestMixin): + """The org payment history sidebar navigates across the user's accounts.""" + + view = org_views.PaymentsList + url = "/organizations/{slug}/payments/" + + def test_nav_includes_personal_account_and_admin_orgs( + self, rf, user_factory, organization_factory, membership_factory + ): + """The sidebar lists the personal account and every administered org, + but not orgs where the user is only a member.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + member_org = organization_factory() + membership_factory(user=user, organization=member_org, admin=False) + + response = self.call_view(rf, user, slug=org.slug) + + nav_orgs = [ + account["organization"] for account in response.context_data["nav_accounts"] + ] + assert user.individual_organization in nav_orgs + assert org in nav_orgs + assert member_org not in nav_orgs + + def test_current_org_is_active(self, rf, user_factory, organization_factory): + """The organization being viewed is flagged active; the others are not.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user, slug=org.slug) + + accounts = { + account["organization"]: account + for account in response.context_data["nav_accounts"] + } + assert accounts[org]["active"] is True + assert accounts[user.individual_organization]["active"] is False + + def test_nav_links_to_each_accounts_payment_history( + self, rf, user_factory, organization_factory + ): + """Each nav entry links to that account's payment history page.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user, slug=org.slug) + + accounts = { + account["organization"]: account + for account in response.context_data["nav_accounts"] + } + assert accounts[org]["url"] == reverse( + "organizations:payments", kwargs={"slug": org.slug} + ) + assert accounts[user.individual_organization]["url"] == reverse( + "users:payments", kwargs={"username": user.username} + ) + + +@pytest.mark.django_db() +class TestUserPaymentsListNav(ViewTestMixin): + """The personal payment history sidebar flags the personal account active.""" + + view = user_views.PaymentsList + url = "/users/{username}/payments/" + + def test_personal_account_is_active(self, rf, user_factory, organization_factory): + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user, username=user.username) + + accounts = { + account["organization"]: account + for account in response.context_data["nav_accounts"] + } + assert accounts[user.individual_organization]["active"] is True + assert accounts[org]["active"] is False diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index b3c8993ec..a66fd1816 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -1,28 +1,45 @@ # Django from django.conf import settings from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import redirect_to_login from django.db import transaction from django.http import Http404, JsonResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, RedirectView, TemplateView +from django.views.generic import ( + DetailView, + ListView, + RedirectView, + TemplateView, + UpdateView, + View, +) # Standard Library import logging import sys +from datetime import datetime # Third Party import stripe # Squarelet -from squarelet.organizations.models import Organization, Plan +from squarelet.core.utils import format_stripe_error, new_action +from squarelet.organizations.models import Charge, Organization, Plan from squarelet.organizations.models.payment import Subscription, get_payment_brand from squarelet.organizations.payments.base import PaymentActionRequired from squarelet.organizations.payments.exceptions import SubscriptionError +from squarelet.organizations.payments.factory import get_payment_provider from squarelet.organizations.tasks import add_to_waitlist -from squarelet.payments.forms import PlanPurchaseForm +from squarelet.payments.forms import ( + CancelSubscriptionForm, + CardForm, + PlanPurchaseForm, + UpdateReceiptEmailForm, +) logger = logging.getLogger(__name__) @@ -405,3 +422,343 @@ def get_redirect_url(self, *args, **kwargs): except Plan.DoesNotExist: raise Http404("No Plan found matching the query") + + +class PaymentsHubView(LoginRequiredMixin, TemplateView): + """Cross-organization payments hub. + + Shows the most recent payments for every account whose charges the user + can view — their personal account and any organization they administer + (``can_view_charge`` is admin-only). Each account links out to its full + payment history so the user can dig deeper. + """ + + template_name = "payments/payments_hub.html" + recent_limit = 5 + + def _subject_url(self, organization, name): + """Reverse a subject-scoped URL for the account. + + Organization pages are keyed on the org slug; the personal account's + pages live under the ``users`` namespace and are keyed on the + member's username. + """ + if organization.individual: + return reverse( + f"users:{name}", kwargs={"username": self.request.user.username} + ) + return reverse(f"organizations:{name}", kwargs={"slug": organization.slug}) + + def get_accounts(self): + """Build per-account context: the organization, its most recent + charges, the card on file, and the URLs used to manage it.""" + user = self.request.user + organizations = [user.individual_organization] + organizations += list( + user.organizations.filter(individual=False, memberships__admin=True) + .distinct() + .order_by("name") + ) + + accounts = [] + for organization in organizations: + customer = organization.customer() + accounts.append( + { + "organization": organization, + "payments": list( + organization.charges.order_by("-created_at")[ + : self.recent_limit + ] + ), + "card_brand": customer.payment_brand, + "card_last4": customer.payment_last4, + "history_url": self._subject_url(organization, "payments"), + "manage_url": self._subject_url(organization, "subscriptions"), + "update_card_url": self._subject_url(organization, "update-card"), + } + ) + return accounts + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["accounts"] = self.get_accounts() + return context + + +class SubscriptionObjectMixin: + """Resolve the organization for a subscription view and build the + subject-scoped URLs used to move between its pages. + + Organization views key these URLs on the organization slug. User + views key them on the member's username — the individual organization + is an implementation detail that is never exposed in the URL — by + setting ``subject_url_kwarg = "username"`` and overriding + ``get_organization``. + """ + + subject = None + subject_url_kwarg = "slug" + individual = False + + def get_organization(self): + return get_object_or_404( + Organization, + individual=self.individual, + slug=self.kwargs[self.subject_url_kwarg], + ) + + def get_object(self, queryset=None): + # pylint: disable=unused-argument + return self.get_organization() + + def get_subject_url_kwargs(self): + return {self.subject_url_kwarg: self.kwargs[self.subject_url_kwarg]} + + def reverse_subject(self, name, **kwargs): + """Reverse a subject-scoped URL (e.g. ``users:subscriptions``).""" + return reverse( + f"{self.subject}:{name}", + kwargs={**self.get_subject_url_kwargs(), **kwargs}, + ) + + def log_staff_action(self, verb, description=None): + """Record staff member actions for an accountability trail of changes + made on someone else's behalf.""" + user = self.request.user + organization = self.get_organization() + if user.is_staff and not organization.has_admin(user): + new_action( + actor=user, + verb=verb, + target=organization, + description=description, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["subject"] = self.subject + context["subject_slug"] = self.kwargs[self.subject_url_kwarg] + return context + + +class BaseManageSubscriptions(SubscriptionObjectMixin, DetailView): + template_name = "subscriptions/manage_subscriptions.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Get subscriptions and add renewal/cancellation date and cost data + subscriptions = self.object.subscriptions.all() + for subscription in subscriptions: + subscription.next_date = get_subscription_next_date(subscription) + subscription.cost = subscription.plan.base_price + context["subscriptions"] = subscriptions + + # Get card on file + customer = self.object.customer() + context["card_brand"] = customer.payment_brand + context["card_last4"] = customer.payment_last4 + + # Get all receipt emails + context["receipt_emails"] = self.object.receipt_emails.all() + + # Get failed receipt emails + context["failed_receipt_emails"] = self.object.receipt_emails.filter( + failed=True + ) + + # Get five most recent payments + payments = self.object.charges.order_by("-created_at").all()[:5] + context["payments"] = payments + + return context + + +class BaseUpdateCard(SubscriptionObjectMixin, UpdateView): + """Update the credit card on file for an organization.""" + + form_class = CardForm + template_name = "subscriptions/update_card.html" + + def _is_ajax(self): + return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + + def form_valid(self, form): + organization = self.object + user = self.request.user + redirect_url = self.reverse_subject("subscriptions") + token = form.cleaned_data["stripe_token"] + try: + organization.save_card(token, user) + except stripe.StripeError as exc: + user_message = format_stripe_error(exc) + if self._is_ajax(): + return JsonResponse({"error": user_message}, status=400) + messages.error(self.request, f"Payment error: {user_message}") + return redirect(redirect_url) + else: + self.log_staff_action("updated the payment method") + success_msg = _("Credit card updated") + if self._is_ajax(): + return JsonResponse( + {"redirect": redirect_url, "message": str(success_msg)} + ) + messages.success(self.request, success_msg) + return redirect(redirect_url) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + customer = self.object.customer() + context["card_brand"] = customer.payment_brand + context["card_last4"] = customer.payment_last4 + + return context + + +class BaseRemoveCard(SubscriptionObjectMixin, View): + """Remove the credit card on file for an organization.""" + + def _is_ajax(self): + return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + + def _error(self, message): + """Return the error as JSON for AJAX callers, else flash and redirect.""" + if self._is_ajax(): + return JsonResponse({"error": str(message)}, status=400) + messages.error(self.request, message) + return redirect(self.reverse_subject("subscriptions")) + + def post(self, request, *args, **kwargs): + organization = self.get_object() + redirect_url = self.reverse_subject("subscriptions") + + if organization.customer().payment_details is None: + return self._error(_("You do not have a card on file to remove.")) + + # A non-cancelled subscription still bills the card on file, so removing + # it would set up a failed renewal. Require cancellation first. + if organization.subscriptions.filter(cancelled=False).exists(): + return self._error( + _( + "You must cancel your active subscriptions before " + "removing your payment method." + ) + ) + + try: + organization.remove_payment_method() + except stripe.StripeError as exc: + return self._error(f"Payment error: {format_stripe_error(exc)}") + + self.log_staff_action("removed the payment method") + success_msg = _("Credit card removed") + if self._is_ajax(): + return JsonResponse({"redirect": redirect_url, "message": str(success_msg)}) + messages.success(request, success_msg) + return redirect(redirect_url) + + +class BaseUpdateReceiptEmail(SubscriptionObjectMixin, UpdateView): + form_class = UpdateReceiptEmailForm + template_name = "subscriptions/update_receipt_email.html" + + def form_valid(self, form): + self.object.set_receipt_emails(form.cleaned_data["receipt_emails"]) + self.log_staff_action("updated the receipt emails") + return redirect(self.reverse_subject("subscriptions")) + + def get_initial(self): + return { + "receipt_emails": ", ".join( + r.email for r in self.object.receipt_emails.all() + ), + } + + +class BaseCancelSubscription(SubscriptionObjectMixin, UpdateView): + form_class = CancelSubscriptionForm + template_name = "subscriptions/cancel_subscription.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first() + if subscription: + context["subscription"] = subscription + context["next_date"] = get_subscription_next_date(subscription) + return context + + def form_valid(self, form): + organization = self.object + subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first() + if subscription: + organization.remove_subscription(subscription) + self.log_staff_action( + "cancelled a subscription", description=subscription.plan.name + ) + messages.success(self.request, _("Subscription cancelled.")) + return redirect(self.reverse_subject("subscriptions")) + + +class BasePaymentsList(SubscriptionObjectMixin, ListView): + template_name = "subscriptions/payments.html" + paginate_by = 20 + + def get_queryset(self): + return Charge.objects.filter(organization=self.get_organization()) + + def _payments_url(self, organization): + """Reverse the payment history URL for an account the user can view. + + The personal account's pages live under the ``users`` namespace and + are keyed on the member's username; organization pages are keyed on + the org slug. + """ + if organization.individual: + return reverse( + "users:payments", kwargs={"username": self.request.user.username} + ) + return reverse("organizations:payments", kwargs={"slug": organization.slug}) + + def get_nav_accounts(self, organization): + """Build the sidebar nav across every account whose payments the user + can view — their personal account and any organization they + administer — flagging the one currently being viewed as active.""" + user = self.request.user + organizations = [user.individual_organization] + organizations += list( + user.organizations.filter(individual=False, memberships__admin=True) + .distinct() + .order_by("name") + ) + return [ + { + "organization": org, + "url": self._payments_url(org), + "active": org.pk == organization.pk, + } + for org in organizations + ] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organization = self.get_organization() + context["organization"] = organization + context["nav_accounts"] = self.get_nav_accounts(organization) + return context + + +def get_subscription_next_date(subscription): + stripe_sub = subscription.stripe_subscription + if stripe_sub: + time_stamp = ( + get_payment_provider() + .get_subscription_service() + .get_current_period_end(stripe_sub) + ) + if time_stamp: + tz_datetime = datetime.fromtimestamp( + time_stamp, tz=timezone.get_current_timezone() + ) + return tz_datetime.date() + return None diff --git a/squarelet/templates/layouts/modal_layout.html b/squarelet/templates/layouts/modal_layout.html new file mode 100644 index 000000000..3c577f988 --- /dev/null +++ b/squarelet/templates/layouts/modal_layout.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load django_vite %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/modal_layout.ts" %} +{% endblock vite %} + +{% block container %} + +{% endblock container %} diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index b08267eae..0c7a90c17 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -4,70 +4,77 @@ Plan card partial. Args: - org — subject Organization (used for payment / receipts URLs). - plan — own Plan or None. - inherited_plans — list of (source_org, plan) tuples; may be empty. - upgrade_plan — Plan to advertise when there is no own plan (optional). + subject_slug — URL key for the subject (org slug or member username). + plans — list of own Plans or empty list. + inherited_plans. — list of (source_org, plan) tuples; may be empty. + upgrade_plan — Plan to advertise when there is no own plan (optional). next_charge_date, card, cancelled — subscription details (optional, org only). - payment_url — URL for the upgrade / billing-settings CTA (optional). can_edit_subscription — bool gating the CTA. Default True. - subject — "user" | "org". Controls copy. Default "org". + subject — "users" | "organizations". Controls copy and links {% endcomment %} -{% if plan or upgrade_plan or not inherited_plans %} -
- {% if plan %} +{% if subscriptions or upgrade_plan or not inherited_plans %} +
+ {% if subscriptions %}
-

- {% if subject == "user" %} - {% trans "You are subscribed to the" %} - {% else %} - {% trans "Subscribed to the" %} - {% endif %} - {{ plan.name }} - {% trans "plan." %} -

- {% if payment_url and can_edit_subscription %} - - {% if subject == "user" %}{% trans "Change plan" %}{% else %}{% trans "Billing settings" %}{% endif %} - - {% endif %} -
- {% if plan.benefits %} -
    - {% for benefit in plan.benefits %} -
  • - {% icon "checkmark" %} - {{ benefit }} -
  • - {% endfor %} -
- {% endif %} - {% if next_charge_date or card %} -
    - {% if next_charge_date %} -
  • - {% icon "calendar" %} - - {% if cancelled %}{% trans "Subscription ends on" %}{% else %}{% trans "Subscription renews on" %}{% endif %} - + + +
    +

    {% trans "Your plans" %}

    + + {% trans "Modify or cancel your" %} {% if subject == "organizations" %}{% trans "organization's " %}{% endif %}{% trans "subscriptions" %} + +
    + +
      + {% for subscription in subscriptions %} +
    • +
      + {{ subscription.plan.name }} + + {% icon "calendar" %} + {{ subscription.plan.annual|yesno:_("Annual,Monthly") }} + +
      + + {% if subscription.cancelled %} + + {% trans "Cancelled" %} + + {% else %} + + {% icon "gear" %} + {% trans "Manage" %} + + {% endif %}
    • - {% endif %} - {% if card %} -
    • - {% icon "credit-card" %} - {{ card_brand }} {% trans "ending in" %} {{ card_last4 }} -
    • - {% endif %} + {% endfor %}
    - {% endif %} + {% else %}

    - {% if subject == "user" %} + {% if subject == "users" %} {% trans "You are using MuckRock’s" %} {% trans "Free" %} {% trans "plan." %} @@ -75,8 +82,8 @@ {% trans "No active subscription." %} {% endif %}

    - {% if payment_url and can_edit_subscription %} - {% trans "Upgrade" %} + {% if can_edit_subscription and upgrade_plan %} + {% trans "Upgrade" %} {% endif %}
    {% if upgrade_plan %} @@ -105,7 +112,7 @@ {% for source_org, source_plan in inherited_plans %}

    - {% if subject == "user" %} + {% if subject == "users" %} {% trans "You inherit" %} {% else %} {% trans "Inherits" %} diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html index 40a9569ab..1e47c355b 100644 --- a/squarelet/templates/organizations/organization_detail.html +++ b/squarelet/templates/organizations/organization_detail.html @@ -66,9 +66,9 @@

    {% trans "Staff control panel" %}

    {% endif %} {% if can_view_subscription %}
  • - + {% icon "credit-card" %} - {% trans "Plan" %} + {% trans "Billing" %}
  • {% endif %} @@ -269,26 +269,27 @@

    {% trans "Affiliations" %}

    {% endif %} {% if can_view_subscription %} -
    +
    -

    {% trans "Plan" %}

    - {% if can_edit_subscription %} +

    {% trans "Billing" %}

    + {% if can_edit_subscription or can_view_charge %} {% endif %}
    - {% url 'organizations:payment' organization.slug as payment_url %} - {% include "organizations/includes/plan_card.html" with org=organization plan=current_plan inherited_plans=inherited_plans upgrade_plan=upgrade_plan next_charge_date=current_plan_next_charge_date card=current_plan_card card_brand=current_plan_card_brand card_last4=current_plan_card_last4 cancelled=current_plan_cancelled payment_url=payment_url can_edit_subscription=can_edit_subscription subject="org" %} + {% include "organizations/includes/plan_card.html" with org=organization subject_slug=organization.slug subscriptions=subscriptions inherited_plans=inherited_plans upgrade_plan=upgrade_plan card=current_plan_card card_brand=current_plan_card_brand card_last4=current_plan_card_last4 can_edit_subscription=can_edit_subscription subject="organizations" %}
    {% endif %} diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index bcd85acd0..ada9a0971 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load i18n django_vite crispy_forms_tags planinfo %} +{% load i18n django_vite crispy_forms_tags planinfo icon %} {% block title %}{{ organization.reference_name }}{% endblock %} diff --git a/squarelet/templates/payments/payments_hub.html b/squarelet/templates/payments/payments_hub.html new file mode 100644 index 000000000..b38266b84 --- /dev/null +++ b/squarelet/templates/payments/payments_hub.html @@ -0,0 +1,70 @@ +{% extends "layouts/sidebar_layout.html" %} +{% load django_vite i18n %} +{% load icon %} + +{% block title %}{% trans "Payments" %}{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/payments_hub.ts" %} +{% endblock vite %} + +{% block layout_id %}payments_hub{% endblock %} + +{% block header %} +
    +

    {% trans "Payments" %}

    + {% icon "chevron-right" %} +

    {% trans "Recent activity across your accounts" %}

    +
    +{% endblock %} + +{% block sidebar %} + + {% icon "chevron-left" %} + {% trans "Back to your account" %} + + +{% endblock sidebar %} + +{% block main %} + {% for account in accounts %} +
    +
    +
    +

    {{ account.organization.name }}

    + {% if account.organization.individual %} + {% trans "Personal" %} + {% endif %} +
    + +
    + + {% include "subscriptions/includes/receipts_card.html" with payments=account.payments %} +
    + {% endfor %} +{% endblock main %} diff --git a/squarelet/templates/subscriptions/cancel_subscription.html b/squarelet/templates/subscriptions/cancel_subscription.html new file mode 100644 index 000000000..b013c8d3d --- /dev/null +++ b/squarelet/templates/subscriptions/cancel_subscription.html @@ -0,0 +1,52 @@ +{% extends "layouts/modal_layout.html" %} +{% load i18n crispy_forms_tags %} +{% load icon %} + +{% block title %}{{ subscription.plan.name }} | Cancel Subscription{% endblock %} + +{% block layout_id %}cancel_subscription{% endblock %} + +{% block content %} +{% endblock content %} + +{% block header %} +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Cancel subscription" %}

    +{% endblock header %} + +{% block main %} + {% url subject|add:':subscriptions' subject_slug as subscriptions_url %} +
    + {% crispy form form.helper %} +
    +

    + {{ subscription.plan.name }} +

    +

    {% trans "Are you sure you want to cancel this plan?" %}

    +
    +

    + {% blocktrans with date=next_date %} + Your plan is paid through {{ date }}. + {% endblocktrans %} +

    +
    + + + + {% trans "Keep plan" %} + +
    +
    +{% endblock main %} + +{% block footer %} + +{% endblock footer %} diff --git a/squarelet/templates/subscriptions/includes/receipts_card.html b/squarelet/templates/subscriptions/includes/receipts_card.html new file mode 100644 index 000000000..9befad416 --- /dev/null +++ b/squarelet/templates/subscriptions/includes/receipts_card.html @@ -0,0 +1,81 @@ +{% load i18n icon django_vite %} + +{% block vite %} +{% vite_asset "frontend/views/receipts_card.ts" %} +{% endblock vite %} + +
    + + + + + + + + + + + {% for payment in payments %} + + + + + + + {% empty %} + + + + {% endfor %} + + {% if page_obj %} + + + + + + {% endif %} +
    {% trans "Date" %}{% trans "Description" %}{% trans "Amount" %}{% trans "Actions" %}
    + + {{ payment.description }}${{ payment.amount_dollars|floatformat:2 }} +
    + {% url "organizations:charge" payment.pk as view_url %} + + {% icon "eye" %} + + + {% url "organizations:charge-pdf" payment.pk as pdf_url %} + + {% icon "download" %} + +
    +
    + {% trans "No payments to show." %} +
    + +
    +
    diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html new file mode 100644 index 000000000..3740656e3 --- /dev/null +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -0,0 +1,205 @@ +{% extends "layouts/sidebar_layout.html" %} +{% load avatar humanize django_vite i18n crispy_forms_tags planinfo %} +{% load icon %} + +{% block title %}{{ organization.name }} | Manage Subscriptions{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/manage_subscriptions.ts" %} +{% endblock vite %} + +{% block layout_id %}manage_billing{% endblock %} + +{% block header %} +
    +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Subscriptions" %}

    +
    +{% endblock %} + +{% block sidebar %} + + {% icon "chevron-left" %} + {% if subject == "organizations" %} + {% trans "Back to team overview" %} + {% else %} + {% trans "Back to your account" %} + {% endif %} + + +{% endblock sidebar %} + +{% block main %} +
    +
    +
    +

    {% trans "Your plans" %}

    +
    +
    + + {% for subscription in subscriptions %} + {% with annual=subscription.plan.annual cancelled=subscription.cancelled %} +
    +
    +

    {{ subscription.plan.name }}

    + {% if cancelled %} + + {% trans "Cancelled" %} + + {% else %} +
    + + {{ annual|yesno:_("Annual,Monthly") }} + +
    + {% endif %} +
    +
    +
    + + + {% icon "tag" %} + + + ${{ subscription.cost|floatformat:2|intcomma }} + {{ annual|yesno:_("per year,per month") }} + + + + + {% icon "calendar" %} + + + {{ cancelled|yesno:_("Ends on,Renews on") }} + {{ subscription.next_date }} + + +
    + {% if not cancelled %} + {% url subject|add:':cancel-subscription' subject_slug subscription.id as cancel_url %} + + {% icon "x-circle" %} + {% trans "Cancel plan" %} + + {% endif %} +
    +
    + {% endwith %} + {% empty %} +
    +

    {% trans "You have no active subscriptions." %}

    +
    + {% endfor %} +
    + +
    +
    +
    +

    {% trans "Payment method" %}

    +
    + + {% if card_brand and card_last4 %} + {% trans "Update card" %} + {% else %} + {% trans "Add card" %} + {% endif %} + +
    + +
    + +
    +
    + +
    +
    +
    +

    {% trans "Receipt emails" %}

    +
    + {% url subject|add:':update-receipt-email' subject_slug as receipt_email_url %} + + {% trans "Change email" %} + +
    + +
    + {% for email in receipt_emails %} +
    + + {% icon "mail" %} + + +
    + {% endfor %} +
    +
    + +
    +
    +
    +

    {% trans "Recent payments" %}

    +
    + {% url subject|add:':payments' subject_slug as payments_url %} + + {% icon "log" %} + {% trans "View all" %} + +
    + + {% include "subscriptions/includes/receipts_card.html" with payments=payments %} +
    +{% endblock main %} \ No newline at end of file diff --git a/squarelet/templates/subscriptions/payments.html b/squarelet/templates/subscriptions/payments.html new file mode 100644 index 000000000..357a050a8 --- /dev/null +++ b/squarelet/templates/subscriptions/payments.html @@ -0,0 +1,52 @@ +{% extends "layouts/sidebar_layout.html" %} +{% load avatar humanize django_vite i18n crispy_forms_tags planinfo %} +{% load icon %} + +{% block title %}{{ organization.name }} | Payments{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/payments.ts" %} +{% endblock vite %} + + +{% block layout_id %}payments{% endblock %} + +{% block header %} +
    +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Payment history" %}

    +
    +{% endblock %} + +{% block sidebar %} + + {% icon "chevron-left" %} + {% if subject == "users" %} + {% trans "Back to your subscriptions" %} + {% else %} + {% trans "Back to subscriptions" %} + {% endif %} + + +{% endblock sidebar %} + +{% block main %} +
    + {% include "subscriptions/includes/receipts_card.html" with payments=object_list page_obj=page_obj %} +
    +{% endblock main %} diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html new file mode 100644 index 000000000..bb1289771 --- /dev/null +++ b/squarelet/templates/subscriptions/update_card.html @@ -0,0 +1,68 @@ +{% extends "layouts/modal_layout.html" %} +{% load django_vite i18n crispy_forms_tags %} +{% load icon %} + +{% block title %}{{ organization.name }} | Update Card{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/update_card.ts" %} +{% endblock vite %} + +{% block layout_id %}update_card{% endblock %} + +{% block header %} +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Change payment card" %}

    +{% endblock header %} + +{% block main %} + {% if card_brand and card_last4 %} +
    + {% icon "credit-card" %} + + {% blocktrans with card_brand as brand and card_last4 as last4 %} + Current card: {{ brand }} ending in {{ last4 }} + {% endblocktrans %} + +
    + {% endif %} + +
    + {% crispy form form.helper %} + +
    +

    {% trans "Enter your new credit card details below." %}

    +
    +
    +
    + +
    + +
    + + + + {% trans "Cancel" %} + +
    +
    + + {% if card_brand and card_last4 %} +
    + {% csrf_token %} + +
    + {% endif %} +{% endblock main %} diff --git a/squarelet/templates/subscriptions/update_receipt_email.html b/squarelet/templates/subscriptions/update_receipt_email.html new file mode 100644 index 000000000..9e85d03e1 --- /dev/null +++ b/squarelet/templates/subscriptions/update_receipt_email.html @@ -0,0 +1,41 @@ +{% extends "layouts/modal_layout.html" %} +{% load i18n crispy_forms_tags %} +{% load icon %} + +{% block title %}{{ organization.name }} | Update Receipt Emails{% endblock %} + +{% block layout_id %}update_receipt_emails{% endblock %} + +{% block header %} +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Change receipt emails" %}

    +{% endblock header %} + +{% block main %} + {% if card %} +
    + {% icon "credit-card" %} + + {% blocktrans with card.brand as brand and card.last4 as last4 %} + Current card: {{ brand }} ending in {{ last4 }} + {% endblocktrans %} + +
    + {% endif %} + +
    + {% crispy form form.helper %} + +
    + + + + {% trans "Cancel" %} + +
    +
    +{% endblock main %} diff --git a/squarelet/templates/users/user_detail.html b/squarelet/templates/users/user_detail.html index 5c7254625..76ae87b7c 100644 --- a/squarelet/templates/users/user_detail.html +++ b/squarelet/templates/users/user_detail.html @@ -30,9 +30,9 @@

    {% trans "User Account" %}

  • - + {% icon "credit-card" %} - {% trans "Plan" %} + {% trans "Billing" %}
  • @@ -130,21 +130,21 @@

    {% firstof user.name user.username %}

-
+
-

{% trans "Plan" %}

+

{% trans "Billing" %}

- {% url 'users:payment' username=user.username as user_payment_url %} - {% include "organizations/includes/plan_card.html" with org=user.individual_organization plan=current_plan inherited_plans=premium_org_plans upgrade_plan=upgrade_plan next_charge_date=current_plan_next_charge_date card=current_plan_card card_brand=current_plan_card_brand card_last4=current_plan_card_last4 cancelled=current_plan_cancelled payment_url=user_payment_url can_edit_subscription=True subject="user" %} + {% include "organizations/includes/plan_card.html" with org=user.individual_organization subject_slug=user.username subscriptions=subscriptions inherited_plans=premium_org_plans upgrade_plan=upgrade_plan card=current_plan_card card_brand=current_plan_card_brand card_last4=current_plan_card_last4 can_edit_subscription=True subject="users" %}
diff --git a/squarelet/users/tests/test_views.py b/squarelet/users/tests/test_views.py index 9a5056747..b07ed6988 100644 --- a/squarelet/users/tests/test_views.py +++ b/squarelet/users/tests/test_views.py @@ -13,6 +13,7 @@ # Third Party import pytest +from actstream.models import Action from allauth.account.models import EmailAddress # Squarelet @@ -128,6 +129,11 @@ class TestUserDetailView(ViewTestMixin): view = views.UserDetailView url = "/users/{username}/" + @pytest.fixture(autouse=True) + def _mock_card(self, mocker): + """Avoid hitting Stripe when the view fetches the customer's card.""" + mocker.patch("squarelet.organizations.models.Customer.card", None) + def test_get(self, rf, user_factory, professional_plan_factory): user = user_factory() professional_plan_factory() @@ -409,6 +415,151 @@ def test_get_admin(self, rf, organization_factory, user_factory, charge_factory) assert response.status_code == 200 +@pytest.mark.django_db() +class TestIndividualSubscriptionViews: + """The user billing routes are keyed on username; the individual + organization is resolved from it rather than exposed in the URL.""" + + def _bind(self, view_class, rf, **kwargs): + view = view_class() + view.request = rf.get("/") + view.kwargs = kwargs + return view + + def test_resolves_individual_org_by_username(self, rf, user_factory): + # Usernames may contain characters (e.g. ".") that are not valid in + # an org slug, so resolution must go through the username. + user = user_factory(username="lasser.allan") + view = self._bind(views.ManageSubscriptions, rf, username=user.username) + assert view.get_object() == user.individual_organization + + def test_unknown_username_is_404(self, rf): + view = self._bind(views.ManageSubscriptions, rf, username="nobody.here") + with pytest.raises(Http404): + view.get_object() + + def test_payments_scoped_to_individual_org( + self, rf, user_factory, organization_factory, charge_factory + ): + user = user_factory(username="dotted.name") + charge = charge_factory( + organization=user.individual_organization, charge_id="ch_individual" + ) + other_charge = charge_factory( + organization=organization_factory(), charge_id="ch_other" + ) + view = self._bind(views.PaymentsList, rf, username=user.username) + charges = list(view.get_queryset()) + assert charge in charges + assert other_charge not in charges + + def test_subject_urls_use_username(self, rf, user_factory): + user = user_factory(username="dotted.name") + view = self._bind(views.ManageSubscriptions, rf, username=user.username) + view.object = view.get_object() + assert view.reverse_subject("subscriptions") == ( + f"/users/{user.username}/subscriptions/" + ) + + +@pytest.mark.django_db() +class TestIndividualSubscriptionAccess(ViewTestMixin): + """User billing pages are limited to the account owner and staff.""" + + view = views.PaymentsList + url = "/users/{username}/payments/" + + def test_owner_allowed(self, rf, user_factory): + user = user_factory(username="dotted.name") + response = self.call_view(rf, user, username=user.username) + assert response.status_code == 200 + + def test_staff_allowed(self, rf, user_factory): + user = user_factory(username="dotted.name") + staff = user_factory(is_staff=True) + response = self.call_view(rf, staff, username=user.username) + assert response.status_code == 200 + + def test_other_user_denied(self, rf, user_factory): + user = user_factory(username="dotted.name") + other = user_factory() + with pytest.raises(Http404): + self.call_view(rf, other, username=user.username) + + def test_anonymous_redirected_to_login(self, rf, user_factory): + user = user_factory(username="dotted.name") + response = self.call_view(rf, username=user.username) + assert response.status_code == 302 + + +@pytest.mark.django_db() +class TestIndividualSubscriptionStaffActions(ViewTestMixin): + """Staff managing a user's billing is logged to the activity stream.""" + + view = views.CancelSubscription + url = "/users/{username}/cancel/{pk}/" + + def test_staff_cancel_subscription_creates_action( + self, rf, user_factory, plan_factory, subscription_factory, mocker + ): + """Staff cancelling a user's subscription targets the individual org""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + user = user_factory(username="dotted.name") + staff = user_factory(is_staff=True) + organization = user.individual_organization + plan = plan_factory(name="Professional") + subscription = subscription_factory(organization=organization, plan=plan) + + response = self.call_view( + rf, staff, {}, username=user.username, pk=subscription.pk + ) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff.pk), + verb="cancelled a subscription", + ).first() + assert action is not None + assert action.actor == staff + assert action.target == organization + assert action.public is False + + def test_owner_cancel_subscription_no_action( + self, rf, user_factory, plan_factory, subscription_factory, mocker + ): + """A user cancelling their own subscription is not logged""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + user = user_factory(username="dotted.name") + subscription = subscription_factory( + organization=user.individual_organization, plan=plan_factory() + ) + + response = self.call_view( + rf, user, {}, username=user.username, pk=subscription.pk + ) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="cancelled a subscription").exists() + + def test_staff_managing_own_account_no_action( + self, rf, user_factory, plan_factory, subscription_factory, mocker + ): + """A staff member managing their own individual account is not logged — + they are the owner (admin) of their own individual organization""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + staff = user_factory(is_staff=True, username="staffer") + subscription = subscription_factory( + organization=staff.individual_organization, plan=plan_factory() + ) + + response = self.call_view( + rf, staff, {}, username=staff.username, pk=subscription.pk + ) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="cancelled a subscription").exists() + + @pytest.mark.django_db() class TestUserOnboardingView(ViewTestMixin): """Test the User Onboarding view""" diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py index e153696f3..dc987f8b7 100644 --- a/squarelet/users/urls.py +++ b/squarelet/users/urls.py @@ -50,4 +50,28 @@ view=views.UserRequestsView.as_view(), name="requests", ), + path( + "/subscriptions/", + view=views.ManageSubscriptions.as_view(), + name="subscriptions", + ), + path( + "/cancel//", + view=views.CancelSubscription.as_view(), + name="cancel-subscription", + ), + path( + "/receipt-email/", + view=views.UpdateReceiptEmail.as_view(), + name="update-receipt-email", + ), + path("/card/", view=views.UpdateCard.as_view(), name="update-card"), + path( + "/card/remove/", + view=views.RemoveCard.as_view(), + name="remove-card", + ), + path( + "/payments/", view=views.PaymentsList.as_view(), name="payments" + ), ] diff --git a/squarelet/users/views.py b/squarelet/users/views.py index 7044044e0..8ff7bdb4c 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -12,9 +12,8 @@ HttpResponseRedirect, JsonResponse, ) -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.generic import ( DetailView, @@ -31,7 +30,6 @@ import logging import sys import time -from datetime import datetime # Third Party from allauth.account.utils import get_next_redirect_url, send_email_confirmation @@ -52,8 +50,15 @@ from squarelet.organizations.forms import InvitationAcceptForm from squarelet.organizations.models import Invitation, ReceiptEmail from squarelet.organizations.models.payment import Plan -from squarelet.organizations.payments.factory import get_payment_provider from squarelet.organizations.views import UpdateSubscription +from squarelet.payments.views import ( + BaseCancelSubscription, + BaseManageSubscriptions, + BasePaymentsList, + BaseRemoveCard, + BaseUpdateCard, + BaseUpdateReceiptEmail, +) from squarelet.services.models import Service from squarelet.users.forms import ( SignupForm, @@ -162,38 +167,15 @@ def get_context_data(self, **kwargs): context["unused_code_count"] = len(self.get_recovery_codes()) # Get the current plan and subscription, if any individual_org = user.individual_organization - current_plan = None upgrade_plan = Plan.objects.filter(slug="professional").first() - subscription = None - if hasattr(individual_org, "subscriptions"): - subscription = individual_org.subscriptions.first() - if subscription and hasattr(subscription, "plan"): - current_plan = subscription.plan - upgrade_plan = None - context["current_plan"] = current_plan + context["subscriptions"] = individual_org.subscriptions.all() context["upgrade_plan"] = upgrade_plan - # Get card, next charge date, and cancelled status for active subscription - if current_plan and subscription: - customer = individual_org.customer() - context["current_plan_card"] = bool(customer.stripe_payment_method_id) - context["current_plan_card_brand"] = customer.payment_brand - context["current_plan_card_last4"] = customer.payment_last4 - # Stripe subscription may have next charge date - stripe_sub = subscription.stripe_subscription - if stripe_sub: - # Try to get next charge date from Stripe subscription - time_stamp = ( - get_payment_provider() - .get_subscription_service() - .get_current_period_end(stripe_sub) - ) - if time_stamp: - tz_datetime = datetime.fromtimestamp( - time_stamp, tz=timezone.get_current_timezone() - ) - context["current_plan_next_charge_date"] = tz_datetime.date() - # Check if the plan is cancelled - context["current_plan_cancelled"] = subscription.cancelled + # Get card for active subscription + customer = individual_org.customer() + context["current_plan_card"] = bool(customer.stripe_payment_method_id) + context["current_plan_card_brand"] = customer.payment_brand + context["current_plan_card_last4"] = customer.payment_last4 + # Cards for each non-individual org the user belongs to that has its own # paid plan. Plans the org inherits from parents/groups are intentionally # excluded here -- those are an org-level concept and confuse users when @@ -684,3 +666,44 @@ class UserRequestsView(BaseUserInvitationRequestView): context_object_name = "requests" is_request_view = True redirect_url_name = "users:requests" + + +class IndividualSubscriptionView(LoginRequiredMixin, StaffAccessMixin): + """Base class for individual subscription views. + + Reached by username; the individual organization is resolved from it + and never exposed in the URL. Access is limited to the account owner + and staff (via ``StaffAccessMixin``). + """ + + subject = "users" + subject_url_kwarg = "username" + individual = True + + def get_organization(self): + user = get_object_or_404(User, username=self.kwargs["username"]) + return user.individual_organization + + +class ManageSubscriptions(IndividualSubscriptionView, BaseManageSubscriptions): + pass + + +class CancelSubscription(IndividualSubscriptionView, BaseCancelSubscription): + pass + + +class UpdateCard(IndividualSubscriptionView, BaseUpdateCard): + pass + + +class RemoveCard(IndividualSubscriptionView, BaseRemoveCard): + pass + + +class UpdateReceiptEmail(IndividualSubscriptionView, BaseUpdateReceiptEmail): + pass + + +class PaymentsList(IndividualSubscriptionView, BasePaymentsList): + pass