Skip to content

Commit b55c244

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 df447f5 commit b55c244

6 files changed

Lines changed: 112 additions & 15 deletions

File tree

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,38 @@
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+
{{#if @subscription.canBeExtended}}
8+
<p>
9+
Your membership does not renew automatically.
10+
</p>
11+
<p>
12+
If you extend your membership before expiry, unused time will be added to your new membership.
13+
</p>
14+
{{else}}
15+
<p>
16+
Your membership does not renew automatically.
17+
</p>
18+
<p>
19+
45 days before your membership expires, you'll be able to extend it below.
20+
</p>
21+
{{/if}}
722
</div>
23+
24+
{{#if @subscription.canBeExtended}}
25+
<PrimaryButton @size="small" {{on "click" @onExtendMembershipButtonClick}} data-test-extend-membership-button>
26+
Extend membership →
27+
</PrimaryButton>
28+
{{else}}
29+
<PrimaryButton @size="small" @isDisabled={{true}} data-test-extend-membership-button>
30+
Extend membership →
31+
32+
<EmberTooltip>
33+
This membership can only be extended after
34+
{{date-format @subscription.extendableAt format="PPP"}}.
35+
</EmberTooltip>
36+
</PrimaryButton>
37+
{{/if}}
838
</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: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import { inject as service } from '@ember/service';
12
import Controller from '@ember/controller';
2-
import type ChargeModel from 'codecrafters-frontend/models/charge';
33
import type { ModelType as SettingsModelType } from 'codecrafters-frontend/routes/settings';
4-
5-
interface BillingModelType extends SettingsModelType {
6-
charges: ChargeModel[];
7-
}
4+
import { tracked } from '@glimmer/tracking';
5+
import type RegionalDiscountModel from 'codecrafters-frontend/models/regional-discount';
6+
import { task } from 'ember-concurrency';
7+
import Store from '@ember-data/store';
88

99
export default class BillingController extends Controller {
10-
declare model: BillingModelType;
10+
declare model: SettingsModelType;
11+
12+
@service declare store: Store;
13+
14+
@tracked chooseMembershipPlanModalIsOpen: boolean = false;
15+
@tracked regionalDiscount: RegionalDiscountModel | null = null;
16+
17+
loadRegionalDiscountTask = task({ keepLatest: true }, async () => {
18+
this.regionalDiscount = await this.store.createRecord('regional-discount').fetchCurrent();
19+
});
1120
}

app/models/subscription.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,29 @@ export default class SubscriptionModel extends Model {
1111
@belongsTo('subscription-source', { async: false, inverse: null, polymorphic: true }) declare source: InstitutionMembershipGrantModel;
1212
@belongsTo('user', { async: false, inverse: 'subscriptions' }) declare user: UserModel;
1313

14+
get canBeExtended(): boolean {
15+
return !!this.extendableAt && new Date() >= this.extendableAt;
16+
}
17+
18+
// A membership can be extended up to 45 days before it expires
19+
get extendableAt(): Date | null {
20+
if (!this.isActive || this.isLifetimeMembership) {
21+
return null;
22+
}
23+
24+
return new Date(this.cancelAt.getTime() - 45 * 24 * 60 * 60 * 1000);
25+
}
26+
1427
get isActive() {
1528
return !this.endedAt && !this.isNew;
1629
}
1730

1831
get isInactive() {
1932
return this.endedAt;
2033
}
34+
35+
// A lifetime membership is modeled as 100 year expiry, we check for 50 here to be safe
36+
get isLifetimeMembership(): boolean {
37+
return this.cancelAt >= new Date(Date.now() + 50 * 365 * 24 * 60 * 60 * 1000); // 50 years from now
38+
}
2139
}

app/templates/settings/billing.hbs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
<Settings::BillingPage::MembershipSection @user={{@model.user}} />
22
<Settings::FormDivider />
33

4-
{{#if @model.user.hasActiveSubscription}}
5-
<Settings::BillingPage::RenewalSection />
6-
<Settings::FormDivider />
4+
{{#if @model.user.activeSubscription}}
5+
{{#unless @model.user.activeSubscription.isLifetimeMembership}}
6+
<Settings::BillingPage::RenewalSection
7+
@onExtendMembershipButtonClick={{fn (mut this.chooseMembershipPlanModalIsOpen) true}}
8+
@subscription={{@model.user.activeSubscription}}
9+
/>
10+
11+
<Settings::FormDivider />
12+
{{/unless}}
713
{{/if}}
814

915
<Settings::BillingPage::SupportSection @user={{@model.user}} />
1016
<Settings::FormDivider />
11-
<Settings::BillingPage::PaymentHistorySection @user={{@model.user}} />
17+
<Settings::BillingPage::PaymentHistorySection @user={{@model.user}} />
18+
19+
{{#if this.chooseMembershipPlanModalIsOpen}}
20+
<ModalBackdrop>
21+
<PayPage::ChooseMembershipPlanModal
22+
{{! @glint-expect-error mut types are broken }}
23+
@onClose={{fn (mut this.chooseMembershipPlanModalIsOpen) false}}
24+
{{! we don't support discounts on extensions yet !}}
25+
@activeDiscountForYearlyPlan={{null}}
26+
{{! we're expecting this to be loaded by the time someone clicks on the extend membership button }}
27+
@regionalDiscount={{this.regionalDiscount}}
28+
/>
29+
</ModalBackdrop>
30+
{{/if}}

tests/pages/settings/billing-page.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { hasClass, visitable, collection, text, attribute } from 'ember-cli-page-object';
1+
import { hasClass, visitable, collection, text, attribute, triggerable } 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+
58
membershipSection: {
69
scope: '[data-test-membership-section]',
710
},
@@ -15,6 +18,18 @@ export default createPage({
1518
scope: '[data-test-payment-history-section]',
1619
},
1720

21+
renewalSection: {
22+
explanationText: text('[data-test-explanation-text]'),
23+
24+
extendMembershipButton: {
25+
isDisabled: hasClass('cursor-not-allowed'),
26+
hover: triggerable('mouseenter'),
27+
scope: '[data-test-extend-membership-button]',
28+
},
29+
30+
scope: '[data-test-renewal-section]',
31+
},
32+
1833
supportSection: {
1934
scope: '[data-test-support-section]',
2035
contactButtonHref: attribute('href', '[data-test-support-contact-button]'),

0 commit comments

Comments
 (0)