Skip to content

Commit 4bd3ddf

Browse files
committed
feat(billing): add membership renewal extension flow
Add ability to extend active subscriptions within 45 days of expiry by introducing `canBeExtended` getter on Subscription model. Update renewal section to reflect membership renewal status and conditionally enable the extend membership button with tooltip showing the earliest extension date. Pass active subscription to renewal section and handle button click action.
1 parent 82aa13a commit 4bd3ddf

8 files changed

Lines changed: 211 additions & 16 deletions

File tree

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1-
<Settings::FormSection @title="Renewal" @description="Details of your upcoming payment">
1+
<Settings::FormSection @title="Renewal" @description="Details on renewing your membership" data-test-renewal-section>
22
<Settings::BillingPage::StatusPill @color="gray" class="mb-3">
3-
Auto-renew disabled
3+
Auto-renew off
44
</Settings::BillingPage::StatusPill>
5-
<div class="prose prose-sm prose-compact dark:prose-invert">
6-
Your membership does not renew automatically. Once your membership expires, you'll be able to make a new one-time payment.
5+
6+
<div class="prose prose-sm prose-compact dark:prose-invert mb-4" data-test-explanation-text>
7+
<p>
8+
Your membership does not renew automatically.
9+
</p>
10+
<p>
11+
If you extend your membership before expiry, unused time will be added to your new membership.
12+
</p>
713
</div>
14+
15+
<PrimaryButton @size="small" {{on "click" @onExtendMembershipButtonClick}} data-test-extend-membership-button>
16+
Extend membership →
17+
</PrimaryButton>
818
</Settings::FormSection>

app/components/settings/billing-page/renewal-section.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import Component from '@glimmer/component';
2+
import type SubscriptionModel from 'codecrafters-frontend/models/subscription';
23

34
interface Signature {
45
Element: HTMLDivElement;
6+
7+
Args: {
8+
onExtendMembershipButtonClick: () => void;
9+
subscription: SubscriptionModel;
10+
};
511
}
612

713
export default class RenewalSection extends Component<Signature> {}
Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
1+
import { action } from '@ember/object';
2+
import { service } from '@ember/service';
13
import Controller from '@ember/controller';
2-
import type ChargeModel from 'codecrafters-frontend/models/charge';
34
import type { ModelType as SettingsModelType } from 'codecrafters-frontend/routes/settings';
4-
5-
interface BillingModelType extends SettingsModelType {
6-
charges: ChargeModel[];
7-
}
5+
import { tracked } from '@glimmer/tracking';
6+
import type RegionalDiscountModel from 'codecrafters-frontend/models/regional-discount';
7+
import { task } from 'ember-concurrency';
8+
import type RouterService from '@ember/routing/router-service';
9+
import type Store from '@ember-data/store';
810

911
export default class BillingController extends Controller {
10-
declare model: BillingModelType;
12+
declare model: SettingsModelType;
13+
14+
queryParams = ['action'];
15+
16+
@service declare router: RouterService;
17+
@service declare store: Store;
18+
19+
@tracked action: string | undefined = undefined;
20+
@tracked chooseMembershipPlanModalIsOpen: boolean = false;
21+
@tracked regionalDiscount: RegionalDiscountModel | null = null;
22+
@tracked shouldShowMembershipExtendedNotice: boolean = false;
23+
24+
loadRegionalDiscountTask = task({ keepLatest: true }, async () => {
25+
this.regionalDiscount = await this.store.createRecord('regional-discount').fetchCurrent();
26+
});
27+
28+
@action
29+
async handleDidInsert() {
30+
await this.loadRegionalDiscountTask.perform();
31+
32+
if (this.action === 'membership_extended') {
33+
this.shouldShowMembershipExtendedNotice = true;
34+
this.router.transitionTo({ queryParams: { action: null } });
35+
}
36+
}
37+
38+
@action
39+
handleDismissNotice() {
40+
this.shouldShowMembershipExtendedNotice = false;
41+
}
1142
}

app/models/subscription.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,9 @@ export default class SubscriptionModel extends Model {
2424
get isInactive() {
2525
return this.endedAt;
2626
}
27+
28+
// A lifetime membership is modeled as 100 year expiry, we check for 50 here to be safe
29+
get isLifetimeMembership(): boolean {
30+
return this.cancelAt >= new Date(Date.now() + 50 * 365 * 24 * 60 * 60 * 1000); // 50 years from now
31+
}
2732
}

app/templates/settings/billing.hbs

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,43 @@
1+
{{! ChooseMembershipPlanModal needs this, make sure it's loaded !}}
2+
<div {{did-insert this.handleDidInsert}} />
3+
4+
{{#if this.shouldShowMembershipExtendedNotice}}
5+
<AlertWithIcon @type="success" @isDismissable={{true}} @onDismiss={{this.handleDismissNotice}} class="mb-6" data-test-membership-extended-notice>
6+
Your membership has been extended!
7+
</AlertWithIcon>
8+
{{/if}}
9+
110
<Settings::BillingPage::MembershipSection @user={{@model.user}} />
2-
<Settings::FormDivider />
311

4-
{{#if @model.user.hasActiveSubscription}}
5-
<Settings::BillingPage::RenewalSection />
6-
<Settings::FormDivider />
12+
{{#if @model.user.activeSubscription}}
13+
{{#unless @model.user.activeSubscription.isLifetimeMembership}}
14+
<Settings::FormDivider />
15+
16+
<Settings::BillingPage::RenewalSection
17+
@onExtendMembershipButtonClick={{fn (mut this.chooseMembershipPlanModalIsOpen) true}}
18+
@subscription={{@model.user.activeSubscription}}
19+
/>
20+
{{/unless}}
721
{{/if}}
822

23+
<Settings::FormDivider />
924
<Settings::BillingPage::SupportSection @user={{@model.user}} />
25+
1026
<Settings::FormDivider />
11-
<Settings::BillingPage::PaymentHistorySection @user={{@model.user}} />
27+
<Settings::BillingPage::PaymentHistorySection @user={{@model.user}} />
28+
29+
{{#if this.chooseMembershipPlanModalIsOpen}}
30+
<ModalBackdrop>
31+
<PayPage::ChooseMembershipPlanModal
32+
{{! @glint-expect-error mut types are broken }}
33+
@onClose={{fn (mut this.chooseMembershipPlanModalIsOpen) false}}
34+
{{! we don't support discounts on extensions yet !}}
35+
@activeDiscountForYearlyPlan={{null}}
36+
@checkoutSessionCancelPath="/settings/billing"
37+
@checkoutSessionSuccessPath="/settings/billing?action=membership_extended"
38+
@closeModalCTAText="Back to billing page"
39+
{{! we're expecting this to be loaded by the time someone clicks on the extend membership button }}
40+
@regionalDiscount={{this.regionalDiscount}}
41+
/>
42+
</ModalBackdrop>
43+
{{/if}}

tests/acceptance/settings-page/billing-test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { currentURL, visit } from '@ember/test-helpers';
12
import { module, test } from 'qunit';
23
import { setupApplicationTest } from 'codecrafters-frontend/tests/helpers';
34
import {
@@ -232,4 +233,36 @@ module('Acceptance | settings-page | billing-test', function (hooks) {
232233

233234
await percySnapshot('Billing Page - Payment History with Refunded Charges');
234235
});
236+
237+
test('membership extended notice is shown when visiting with action query param', async function (assert) {
238+
testScenario(this.server);
239+
signInAsSubscriber(this.owner, this.server);
240+
241+
await visit('/settings/billing?action=membership_extended');
242+
243+
assert.true(billingPage.membershipExtendedNotice.isVisible, 'membership extended notice is visible');
244+
assert.strictEqual(currentURL(), '/settings/billing', 'query param is cleared from URL');
245+
});
246+
247+
test('membership extended notice can be dismissed', async function (assert) {
248+
testScenario(this.server);
249+
signInAsSubscriber(this.owner, this.server);
250+
251+
await visit('/settings/billing?action=membership_extended');
252+
253+
assert.true(billingPage.membershipExtendedNotice.isVisible, 'membership extended notice is visible');
254+
255+
await billingPage.membershipExtendedNotice.clickDismissButton();
256+
257+
assert.false(billingPage.membershipExtendedNotice.isVisible, 'membership extended notice is dismissed');
258+
});
259+
260+
test('membership extended notice is not shown on normal page visit', async function (assert) {
261+
testScenario(this.server);
262+
signInAsSubscriber(this.owner, this.server);
263+
264+
await billingPage.visit();
265+
266+
assert.false(billingPage.membershipExtendedNotice.isVisible, 'membership extended notice is not visible');
267+
});
235268
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import billingPage from 'codecrafters-frontend/tests/pages/settings/billing-page';
2+
import testScenario from 'codecrafters-frontend/mirage/scenarios/test';
3+
import windowMock from 'ember-window-mock';
4+
import { module, test } from 'qunit';
5+
import { setupApplicationTest } from 'codecrafters-frontend/tests/helpers';
6+
import { setupWindowMock } from 'ember-window-mock/test-support';
7+
import { signIn, signInAsSubscriber } from 'codecrafters-frontend/tests/support/authentication-helpers';
8+
9+
module('Acceptance | settings-page | extend-membership-test', function (hooks) {
10+
setupApplicationTest(hooks);
11+
setupWindowMock(hooks);
12+
13+
test('can extend membership', async function (assert) {
14+
testScenario(this.server);
15+
signInAsSubscriber(this.owner, this.server);
16+
17+
const subscription = this.server.schema.subscriptions.first();
18+
subscription.update('cancelAt', new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days from now
19+
20+
await billingPage.visit();
21+
22+
assert.strictEqual(
23+
billingPage.renewalSection.explanationText,
24+
'Your membership does not renew automatically. If you extend your membership before expiry, unused time will be added to your new membership.',
25+
'explanation text is correct',
26+
);
27+
28+
await billingPage.renewalSection.extendMembershipButton.click();
29+
assert.true(billingPage.chooseMembershipPlanModal.isVisible, 'choose membership plan modal is visible');
30+
await billingPage.chooseMembershipPlanModal.clickOnChoosePlanButton();
31+
await billingPage.chooseMembershipPlanModal.clickOnProceedToCheckoutButton();
32+
33+
assert.strictEqual(windowMock.location.href, 'https://test.com/checkout_session');
34+
35+
const individualCheckoutSession = this.server.schema.individualCheckoutSessions.first();
36+
37+
assert.strictEqual(individualCheckoutSession.cancelUrl, `${window.location.origin}/settings/billing`);
38+
39+
// TODO: See if we can add a "membership extended" notice for success?
40+
assert.strictEqual(individualCheckoutSession.successUrl, `${window.location.origin}/settings/billing`);
41+
});
42+
43+
test('renewal section is not displayed when membership has expired', async function (assert) {
44+
testScenario(this.server);
45+
signIn(this.owner, this.server); // Sign in without subscription (no active membership)
46+
47+
await billingPage.visit();
48+
49+
assert.false(billingPage.renewalSection.isVisible, 'renewal section is not visible for expired/no membership');
50+
});
51+
52+
test('renewal section is not displayed when membership is a lifetime membership', async function (assert) {
53+
testScenario(this.server);
54+
signInAsSubscriber(this.owner, this.server);
55+
56+
const subscription = this.server.schema.subscriptions.first();
57+
// Set cancelAt to more than 50 years from now (lifetime membership)
58+
subscription.update('cancelAt', new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000)); // 100 years from now
59+
60+
await billingPage.visit();
61+
62+
assert.false(billingPage.renewalSection.isVisible, 'renewal section is not visible for lifetime membership');
63+
});
64+
});

tests/pages/settings/billing-page.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
import { hasClass, visitable, collection, text, attribute } from 'ember-cli-page-object';
1+
import { attribute, clickable, collection, hasClass, text, visitable } from 'ember-cli-page-object';
2+
import ChooseMembershipPlanModal from 'codecrafters-frontend/tests/pages/components/choose-membership-plan-modal';
23
import createPage from 'codecrafters-frontend/tests/support/create-page';
34

45
export default createPage({
6+
chooseMembershipPlanModal: ChooseMembershipPlanModal,
7+
8+
membershipExtendedNotice: {
9+
scope: '[data-test-membership-extended-notice]',
10+
clickDismissButton: clickable('[data-test-dismiss-button]'),
11+
},
12+
513
membershipSection: {
614
scope: '[data-test-membership-section]',
715
},
@@ -15,6 +23,12 @@ export default createPage({
1523
scope: '[data-test-payment-history-section]',
1624
},
1725

26+
renewalSection: {
27+
explanationText: text('[data-test-explanation-text]'),
28+
extendMembershipButton: { scope: '[data-test-extend-membership-button]' },
29+
scope: '[data-test-renewal-section]',
30+
},
31+
1832
supportSection: {
1933
scope: '[data-test-support-section]',
2034
contactButtonHref: attribute('href', '[data-test-support-contact-button]'),

0 commit comments

Comments
 (0)