Repository: mdn/kuma

Issue:
T - Acct Settings: subscription component
# Acceptance Criteria

- [ ] An active subscriber can see the status of their subscription on account settings
- [ ] A user can cancel their subscription from the account settings page

# Design 

![Screenshot 2020-06-01 at 09 50 28](https://user-images.githubusercontent.com/10350960/83387798-9fb16080-a3ed-11ea-9f67-857c07613a03.png)



Discussion:
@atopal if the user does not have the `subscription_form` waffle flag set, should we hide the subscription link in the sidebar nav? 
No, that's okay, since the subscription button is supposed to link to the subscription landing page anyway, and we won't launch this new account settings page without having launched the subscription option.
status: 1/2 complete: component and logic, but based on original designs.
Acceptance criteria and design have been updated:
https://github.com/mdn/kuma/issues/7076#issue-618982588
Currently, on the payment management page the detail and layout is as follows:

![Screenshot 2020-06-18 at 14 22 35](https://user-images.githubusercontent.com/10350960/85019887-ce5e6380-b16f-11ea-80b3-ec97fbb39b1c.png)

On the same section on account details, the designs call for the following:

![Screenshot 2020-06-18 at 14 22 49](https://user-images.githubusercontent.com/10350960/85019903-d4ecdb00-b16f-11ea-894c-a827d3ea796b.png)

Seeing that we can share components and functionality here, it makes sense to me for these to match from a UI perspective as well. The alternative is that we need to create two variants based on the main component that uses the sub-components.

We _can_ do that, but it seems wasteful. Will the component on account setting not replace the payment management page in the end anyway?

Thoughts @malqinneh and @atopal?

That's a good question, I don't think it's a good idea to cancel immediately after people click that button, that can easily happen by accident. For cancelling your account we have an overlay for you to confirm, but I haven't seen that for subscriptions. Mustafa?
> That's a good question, I don't think it's a good idea to cancel immediately after people click that button, that can easily happen by accident. For cancelling your account we have an overlay for you to confirm, but I haven't seen that for subscriptions. Mustafa?

The management page does do that and so, sharing the components and as such the functionality, we would inherit the same flow. It's a little more work right now, to abstract/extract the common functionality, but well worth it I recon.
Here is the updated UX for 1) closing an account and 2) canceling subscription that Schalk, Kadir, and I talked about.

![8CD1ECD6-E0B4-4625-8EC1-53368E5BEDB7](https://user-images.githubusercontent.com/4819906/85056096-9efa2780-b16c-11ea-935b-9dfae6ace2b2.png)


Resolving patch:
diff --git a/kuma/settings/common.py b/kuma/settings/common.py
--- a/kuma/settings/common.py
+++ b/kuma/settings/common.py
@@ -668,18 +668,18 @@ def pipeline_one_scss(slug, **kwargs):
         "source_filenames": ("styles/minimalist/pages/accountsettings.scss",),
         "output_filename": "build/styles/pages-accountsettings.css",
     },
-    "accountsettings-titlebar": {
-        "source_filenames": (
-            "styles/minimalist/components/accountsettings/titlebar.scss",
-        ),
-        "output_filename": "build/styles/accountsettings-titlebar.css",
-    },
     "accountsettings-user-details": {
         "source_filenames": (
             "styles/minimalist/components/accountsettings/user-details.scss",
         ),
         "output_filename": "build/styles/accountsettings-user-details.css",
     },
+    "accountsettings-subscription": {
+        "source_filenames": (
+            "styles/minimalist/components/accountsettings/subscription.scss",
+        ),
+        "output_filename": "build/styles/accountsettings-subscription.css",
+    },
     "mdn": {
         "source_filenames": ("styles/main.scss",),
         "output_filename": "build/styles/mdn.css",


Test patch:
diff --git a/kuma/javascript/src/payments/components/cancel-subscription-form.test.jsx b/kuma/javascript/src/accountsettings/components/cancel-subscription-form.test.jsx
similarity index 88%
rename from kuma/javascript/src/payments/components/cancel-subscription-form.test.jsx
rename to kuma/javascript/src/accountsettings/components/cancel-subscription-form.test.jsx
--- a/kuma/javascript/src/payments/components/cancel-subscription-form.test.jsx
+++ b/kuma/javascript/src/accountsettings/components/cancel-subscription-form.test.jsx
@@ -1,17 +1,17 @@
 import React from 'react';
 import { render, fireEvent, waitFor } from '@testing-library/react';
 import CancelSubscriptionForm from './cancel-subscription-form.jsx';
-import { SUBSCRIPTIONS_URL } from '../api.js';
+import { SUBSCRIPTIONS_URL } from '../../payments/api.js';
 
 const setup = (props = {}) => {
     const mockProps = {
-        setShowForm: () => {},
+        onCancel: () => {},
         onSuccess: () => {},
         date: 'April 22, 2020',
         ...props,
     };
     const utils = render(<CancelSubscriptionForm {...mockProps} />);
-    const cancelBtn = utils.getByText(/keep my membership/i);
+    const cancelBtn = utils.getByText(/keep subscription/i);
     const submitBtn = utils.getByText(/cancel subscription/i);
 
     return {
@@ -22,13 +22,13 @@ const setup = (props = {}) => {
 };
 
 describe('Cancel Subscriptions Form', () => {
-    it('calls setShowForm() with false if cancel button is clicked', () => {
+    it('calls onCancel() with false if cancel button is clicked', () => {
         const mockProps = {
-            setShowForm: jest.fn(),
+            onCancel: jest.fn(),
         };
         const { cancelBtn } = setup(mockProps);
         fireEvent.click(cancelBtn);
-        expect(mockProps.setShowForm).toHaveBeenCalledWith(false);
+        expect(mockProps.onCancel).toHaveBeenCalled();
     });
 
     it('submits and disables form when submit button is clicked', async () => {
diff --git a/kuma/javascript/src/accountsettings/components/subscription-detail.test.jsx b/kuma/javascript/src/accountsettings/components/subscription-detail.test.jsx
new file mode 100644
--- /dev/null
+++ b/kuma/javascript/src/accountsettings/components/subscription-detail.test.jsx
@@ -0,0 +1,122 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import SubscriptionDetail from './subscription-detail.jsx';
+import UserProvider from '../../user-provider.jsx';
+
+import { formatDate, formatMoney } from '../../formatters.js';
+
+const getProps = (userData = {}) => {
+    const mockUserData = {
+        ...UserProvider.defaultUserData,
+        ...userData,
+    };
+    const subscription = {
+        id: 'sub_H9PQHPTDQGQCK1',
+        amount: 5,
+        brand: 'Visa',
+        expires_at: '11/2020', // eslint-disable-line camelcase
+        last4: '4242',
+        next_payment_at: '2020-05-23T08:04:40', // eslint-disable-line camelcase
+        zip: '11201',
+    };
+
+    return {
+        contributionSupportEmail: 'mock-support@mozilla.com',
+        locale: 'en-US',
+        subscription,
+        userData: mockUserData,
+    };
+};
+
+describe('SubscriptionDetail', () => {
+    test('subscription renders', () => {
+        const mockUserData = {
+            isAuthenticated: true,
+            isSubscriber: true,
+        };
+        const { locale, subscription, userData } = getProps(mockUserData);
+        const nextPaymentDate = formatDate(
+            locale,
+            subscription.next_payment_at
+        );
+        const amount = formatMoney(locale, subscription.amount / 100);
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <SubscriptionDetail {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        // next payment string
+        expect(
+            screen.getByText(
+                `Next payment of ${amount} (monthly) occurs on ${nextPaymentDate}.`
+            )
+        ).toBeInTheDocument();
+
+        // cancel subscription button present
+        expect(
+            screen.getByRole('button', { name: /Cancel subscription/i })
+        ).toBeInTheDocument();
+
+        // check brand and last 4
+        expect(
+            screen.getByText(
+                `${subscription.brand} ending in ${subscription.last4}`
+            )
+        ).toBeInTheDocument();
+
+        // check expires at
+        expect(
+            screen.getByText(`Expires ${subscription.expires_at}`)
+        ).toBeInTheDocument();
+
+        // check zip code
+        expect(
+            screen.getByText(`Postal/Zip Code: ${subscription.zip}`)
+        ).toBeInTheDocument();
+
+        // check subscription footer content
+        expect(
+            screen.getByText('To see your member perks', {
+                exact: false,
+            })
+        ).toBeInTheDocument();
+
+        expect(
+            screen.getByText('If you have questions', {
+                exact: false,
+            })
+        ).toBeInTheDocument();
+    });
+
+    test('clicking cancel button shows cancel subscription form', () => {
+        const mockUserData = {
+            isAuthenticated: true,
+            isSubscriber: true,
+        };
+        const { userData } = getProps(mockUserData);
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <SubscriptionDetail {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        const cancelButton = screen.getByRole('button', {
+            name: /Cancel subscription/i,
+        });
+
+        // cancel subscription button present
+        expect(cancelButton).toBeInTheDocument();
+
+        userEvent.click(cancelButton);
+        expect(
+            screen.getByRole('form', {
+                name: /Are you sure you want to cancel?/i,
+            })
+        ).toBeVisible();
+    });
+});
diff --git a/kuma/javascript/src/accountsettings/components/subscription.test.jsx b/kuma/javascript/src/accountsettings/components/subscription.test.jsx
new file mode 100644
--- /dev/null
+++ b/kuma/javascript/src/accountsettings/components/subscription.test.jsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import Subscription, { successMsg } from './subscription.jsx';
+import UserProvider from '../../user-provider.jsx';
+
+const getProps = (userData = {}) => {
+    const mockUserData = {
+        ...UserProvider.defaultUserData,
+        ...userData,
+    };
+    const mockResponse = {
+        subscriptions: [
+            {
+                id: 'sub_H9PQHPTDQGQCK1',
+                amount: 5,
+                brand: 'Visa',
+                expires_at: '11/2020', // eslint-disable-line camelcase
+                last4: '4242',
+                next_payment_at: '2020-05-23T08:04:40', // eslint-disable-line camelcase
+                zip: '11201',
+            },
+        ],
+    };
+
+    return {
+        contributionSupportEmail: 'mock-support@mozilla.com',
+        locale: 'en-US',
+        response: mockResponse,
+        userData: mockUserData,
+    };
+};
+
+describe('Subscription', () => {
+    it('renders invitation if user is not subscriber', () => {
+        const mockUserData = { isAuthenticated: true };
+        const { userData } = getProps(mockUserData);
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <Subscription {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        expect(
+            screen.getByText(/You have no active subscription/i)
+        ).toBeInTheDocument();
+    });
+
+    it('renders subscriptions details for subscriber', async () => {
+        const mockUserData = { isAuthenticated: true, isSubscriber: true };
+        const { response, userData } = getProps(mockUserData);
+
+        window.fetch = jest.fn(() =>
+            Promise.resolve({ ok: true, json: () => response })
+        );
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <Subscription {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        await waitFor(() => {
+            expect(
+                screen.queryByText(/You have no active subscription/i)
+            ).not.toBeInTheDocument();
+        });
+
+        window.fetch.mockReset();
+    });
+
+    test('renders loading while fetching subscriptions data', async () => {
+        const mockUserData = { isAuthenticated: true, isSubscriber: true };
+        const mockResponse = {
+            subscriptions: [],
+        };
+        const userData = getProps(mockUserData);
+
+        window.fetch = jest.fn(() =>
+            Promise.resolve({ ok: true, json: () => mockResponse })
+        );
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <Subscription {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        await waitFor(() => {
+            expect(screen.getByText(/loading/i)).toBeInTheDocument();
+        });
+
+        window.fetch.mockReset();
+    });
+
+    it('shows error message if cannot retrieve subscriptions', async () => {
+        const mockUserData = { isAuthenticated: true, isSubscriber: true };
+        const userData = getProps(mockUserData);
+
+        // used by <GenericError />
+        window.mdn = {
+            contributionSupportEmail: 'mock-support@mozilla.com',
+        };
+
+        window.fetch = jest.fn(() => Promise.resolve({ ok: false }));
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <Subscription {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        await waitFor(() => {
+            expect(
+                screen.getByText(/sorry, something went wrong/i)
+            ).toBeInTheDocument();
+        });
+
+        window.fetch.mockReset();
+    });
+
+    it('successfully cancels subscription', async () => {
+        const mockUserData = { isAuthenticated: true, isSubscriber: true };
+        const { response, userData } = getProps(mockUserData);
+
+        let cancelConfirmButton = null;
+
+        // mock first request to get subscriptions
+        // second request is to cancel subscription
+        window.fetch = jest
+            .fn()
+            .mockImplementationOnce(() =>
+                Promise.resolve({ ok: true, json: () => response })
+            )
+            .mockImplementationOnce(() => Promise.resolve({ ok: true }));
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <Subscription {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        await waitFor(() => {
+            const cancelButton = screen.getByRole('button', {
+                name: /Cancel subscription/i,
+            });
+
+            expect(cancelButton).toBeInTheDocument();
+
+            userEvent.click(cancelButton);
+
+            cancelConfirmButton = screen.getByRole('button', {
+                name: /Yes, cancel subscription/i,
+            });
+
+            expect(cancelConfirmButton).toBeInTheDocument();
+        });
+
+        userEvent.click(cancelConfirmButton);
+
+        // Success message and no subscriptions message should show
+        await waitFor(() => {
+            expect(screen.getByText(successMsg)).toBeInTheDocument();
+            expect(
+                screen.getByText(/no active subscription/i)
+            ).toBeInTheDocument();
+        });
+
+        window.fetch.mockReset();
+    });
+
+    test('clicking keep my membership button hides the cancel subscription form', async () => {
+        const mockUserData = {
+            isAuthenticated: true,
+            isSubscriber: true,
+        };
+        const { response, userData } = getProps(mockUserData);
+
+        let cancelButton = null;
+
+        window.fetch = jest.fn(() =>
+            Promise.resolve({ ok: true, json: () => response })
+        );
+
+        render(
+            <UserProvider.context.Provider value={userData}>
+                <Subscription {...getProps(mockUserData)} />
+            </UserProvider.context.Provider>
+        );
+
+        await waitFor(() => {
+            cancelButton = screen.getByRole('button', {
+                name: /Cancel subscription/i,
+            });
+
+            // cancel subscription button present
+            expect(cancelButton).toBeInTheDocument();
+        });
+
+        userEvent.click(cancelButton);
+
+        const cancellationForm = screen.getByRole('form', {
+            name: /Are you sure you want to cancel?/i,
+        });
+        const keepSubscription = screen.getByRole('button', {
+            name: /Keep subscription/i,
+        });
+
+        expect(cancellationForm).toBeVisible();
+
+        userEvent.click(keepSubscription);
+
+        expect(cancellationForm).not.toBeInTheDocument();
+
+        window.fetch.mockReset();
+    });
+});
diff --git a/kuma/javascript/src/accountsettings/pages/index.test.jsx b/kuma/javascript/src/accountsettings/pages/index.test.jsx
--- a/kuma/javascript/src/accountsettings/pages/index.test.jsx
+++ b/kuma/javascript/src/accountsettings/pages/index.test.jsx
@@ -41,10 +41,26 @@ describe('Account Settings Landing Page', () => {
             </UserProvider.context.Provider>
         );
 
-        expect(screen.queryByTestId('user-details-form')).toBeTruthy();
+        expect(screen.getByTestId('user-details-form')).toBeInTheDocument();
         expect(screen.queryByText('Sign in')).not.toBeInTheDocument();
     });
 
+    it('renders the subscription component for authenticated users', () => {
+        const mockData = getTestData({
+            isAuthenticated: true,
+        });
+
+        render(
+            <UserProvider.context.Provider value={mockData.mockUserData}>
+                <AccountSettingsLandingPage {...mockData.mockProps} />
+            </UserProvider.context.Provider>
+        );
+
+        expect(
+            screen.getByRole('region', { name: /Subscription/i })
+        ).toBeInTheDocument();
+    });
+
     it('show singin link for unauthticated users', () => {
         const mockData = getTestData();
 
@@ -54,7 +70,9 @@ describe('Account Settings Landing Page', () => {
             </UserProvider.context.Provider>
         );
 
-        expect(screen.getByText('Sign in')).not.toBeNull();
-        expect(screen.queryByTestId('user-details-form')).toBeNull();
+        expect(screen.getByText('signed in')).toBeInTheDocument();
+        expect(
+            screen.queryByTestId('user-details-form')
+        ).not.toBeInTheDocument();
     });
 });
diff --git a/kuma/javascript/src/accountsettings/routes.test.jsx b/kuma/javascript/src/accountsettings/routes.test.jsx
--- a/kuma/javascript/src/accountsettings/routes.test.jsx
+++ b/kuma/javascript/src/accountsettings/routes.test.jsx
@@ -3,8 +3,12 @@ import { render } from '@testing-library/react';
 import { AccountSettingsPage } from './routes.jsx';
 
 describe('AccountSettingsPage', () => {
+    const mockData = { contributionSupportEmail: 'mock-support@mozilla.com' };
+
     it('renders Landing page by default', () => {
-        const { queryByTestId } = render(<AccountSettingsPage slug="/" />);
+        const { queryByTestId } = render(
+            <AccountSettingsPage slug="/" data={mockData} />
+        );
         expect(queryByTestId('landing-page')).toBeInTheDocument();
     });
 });
diff --git a/kuma/javascript/src/payments/components/errors.test.jsx b/kuma/javascript/src/common/errors.test.jsx
similarity index 100%
rename from kuma/javascript/src/payments/components/errors.test.jsx
rename to kuma/javascript/src/common/errors.test.jsx
diff --git a/kuma/javascript/src/payments/pages/management.test.jsx b/kuma/javascript/src/payments/pages/management.test.jsx
--- a/kuma/javascript/src/payments/pages/management.test.jsx
+++ b/kuma/javascript/src/payments/pages/management.test.jsx
@@ -1,18 +1,17 @@
 import React from 'react';
 import { render, fireEvent, waitFor } from '@testing-library/react';
 import UserProvider from '../../user-provider.jsx';
-import { title as cancelTitle } from '../components/cancel-subscription-form.jsx';
-import { formatDate } from '../../formatters.js';
-import ManagementPage, { title, successMsg } from './management.jsx';
+import ManagementPage, { title } from './management.jsx';
 
 const setup = (userData = {}) => {
+    const mockData = { contributionSupportEmail: 'mock-support@mozilla.com' };
     const mockUserData = {
         ...UserProvider.defaultUserData,
         ...userData,
     };
     return render(
         <UserProvider.context.Provider value={mockUserData}>
-            <ManagementPage locale="en-US" />
+            <ManagementPage locale="en-US" data={mockData} />
         </UserProvider.context.Provider>
     );
 };
@@ -70,83 +69,4 @@ describe('Payments Management Page', () => {
             window.fetch.mockReset();
         });
     });
-
-    test('subscriptions render and buttons work', async () => {
-        const mockUserData = { isAuthenticated: true, isSubscriber: true };
-        const mockResponse = {
-            subscriptions: [
-                {
-                    id: 'sub_H9PQHPTDQGQCK1',
-                    amount: 5,
-                    brand: 'Visa',
-                    expires_at: '11/2020', // eslint-disable-line camelcase
-                    last4: '4242',
-                    next_payment_at: '2020-05-23T08:04:40', // eslint-disable-line camelcase
-                    zip: '11201',
-                },
-            ],
-        };
-
-        // mock first request to get subscriptions
-        // second request is to delete subscription
-        window.fetch = jest
-            .fn()
-            .mockImplementationOnce(() =>
-                Promise.resolve({ ok: true, json: () => mockResponse })
-            )
-            .mockImplementationOnce(() => Promise.resolve({ ok: true }));
-
-        const { getByText } = setup(mockUserData);
-        const expected = mockResponse.subscriptions[0];
-
-        // Content renders
-        await waitFor(() => {
-            // check last 4
-            expect(
-                getByText(expected.last4, { exact: false })
-            ).toBeInTheDocument();
-
-            // check expires at
-            expect(getByText(expected.expires_at)).toBeInTheDocument();
-
-            // check next payment date
-            expect(
-                getByText(formatDate('en-US', expected.next_payment_at), {
-                    exact: false,
-                })
-            ).toBeInTheDocument();
-        });
-
-        // Click on cancel subscription button to show confirmation message
-        let cancelBtn;
-        await waitFor(() => {
-            // there are two matches for `/cancel subscription/i`, so we use the
-            // case-sensitive version to get the button:
-            cancelBtn = getByText(/Cancel subscription/);
-            expect(cancelBtn).toBeInTheDocument();
-            fireEvent.click(cancelBtn);
-            expect(getByText(cancelTitle)).toBeVisible();
-        });
-
-        // Click on Keep my membership button to hide confirmation message
-        const keepBtn = getByText(/keep my membership/i);
-        const cancelText = getByText(cancelTitle);
-        fireEvent.click(keepBtn);
-
-        expect(keepBtn).not.toBeInTheDocument();
-        expect(cancelText).not.toBeInTheDocument();
-
-        // Open confirmation again, click on "Yes, cancel subscription"
-        fireEvent.click(cancelBtn);
-        const submitBtn = getByText(/yes, cancel subscription/i);
-        fireEvent.click(submitBtn);
-
-        // Success message and no subscriptions message should show
-        await waitFor(() => {
-            expect(getByText(successMsg)).toBeInTheDocument();
-            expect(getByText(/no active subscription/i)).toBeInTheDocument();
-        });
-
-        window.fetch.mockReset();
-    });
 });
diff --git a/kuma/javascript/src/payments/routes.test.jsx b/kuma/javascript/src/payments/routes.test.jsx
--- a/kuma/javascript/src/payments/routes.test.jsx
+++ b/kuma/javascript/src/payments/routes.test.jsx
@@ -3,7 +3,7 @@ import { render } from '@testing-library/react';
 import { PaymentPage, PAYMENT_PATHS } from './routes.jsx';
 
 describe('PaymentPage', () => {
-    const mockData = { email: 'mock-support@mozilla.com' };
+    const mockData = { contributionSupportEmail: 'mock-support@mozilla.com' };
 
     it('renders Thank You page if path contains thank-you', () => {
         const { queryByTestId } = render(
@@ -14,14 +14,14 @@ describe('PaymentPage', () => {
 
     it('renders Terms page if path contains terms', () => {
         const { queryByTestId } = render(
-            <PaymentPage slug={PAYMENT_PATHS.TERMS} data={mockData} />
+            <PaymentPage slug={PAYMENT_PATHS.TERMS} />
         );
         expect(queryByTestId('terms-page')).toBeInTheDocument();
     });
 
     it('renders Management page if path contains management', () => {
         const { queryByTestId } = render(
-            <PaymentPage slug={PAYMENT_PATHS.MANAGEMENT} />
+            <PaymentPage slug={PAYMENT_PATHS.MANAGEMENT} data={mockData} />
         );
         expect(queryByTestId('management-page')).toBeInTheDocument();
     });