From ea97f307fcbc64589cba686409ec18138091d9c4 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 14:25:29 +0200 Subject: [PATCH 1/4] feat(account): always-visible Renew on yearly licences, deep-linked to checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a "Renew" button on every renewable (yearly) licence on the account page, not only when it is expiring-soon or already expired. Lifetime licences (null expiry) show no Renew — there is nothing to renew. The button deep-links to the pre-filled yearly checkout (/pro/checkout?product=) instead of the generic /pro; on payment the existing order-completed subscriber extends the SAME licence key using the locked max(expiry, now) + 1yr rule (see wcpos-medusa#63). Falls back to /pro when the plan handle cannot be resolved. Renew is styled primary when expired (needed to regain access) and secondary when active (an optional early renewal — no days lost). To avoid two identical CTAs on one card, the footer Renew is suppressed while the expiring-soon banner already offers one, so exactly one Renew is ever present. Tests updated for the new always-visible behaviour + a deep-link assertion. --- .../account/licenses-client.test.tsx | 30 ++++++- src/components/account/licenses-client.tsx | 83 ++++++++++++------- 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/components/account/licenses-client.test.tsx b/src/components/account/licenses-client.test.tsx index ead3e7df..f96a1d56 100644 --- a/src/components/account/licenses-client.test.tsx +++ b/src/components/account/licenses-client.test.tsx @@ -226,7 +226,11 @@ describe('LicensesClient', () => { expect( screen.queryByText(/renew to keep receiving updates/) ).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'Renew' })).not.toBeInTheDocument() + // The expiry banner is gone, but the always-visible footer Renew remains. + expect(screen.getByRole('link', { name: 'Renew' })).toHaveAttribute( + 'href', + '/pro' + ) }) it('does not warn for lifetime licenses', () => { @@ -239,6 +243,10 @@ describe('LicensesClient', () => { expect( screen.queryByText(/renew to keep receiving updates/) ).not.toBeInTheDocument() + // A lifetime licence never expires — there is nothing to renew. + expect( + screen.queryByRole('link', { name: 'Renew' }) + ).not.toBeInTheDocument() }) it('labels a yearly-policy license "Yearly"', () => { @@ -253,6 +261,26 @@ describe('LicensesClient', () => { expect(screen.getByText('Yearly')).toBeInTheDocument() }) + it('deep-links the Renew button to the pre-filled yearly checkout', () => { + render( + + ) + + // An active, non-expiring yearly licence shows the always-visible Renew, + // deep-linked to the pre-filled yearly checkout (not the generic /pro). + expect(screen.getByRole('link', { name: 'Renew' })).toHaveAttribute( + 'href', + '/pro/checkout?product=wcpos-pro-yearly' + ) + }) + it('does NOT label an unregistered/unknown policy as "Lifetime"', () => { render( getLicenseDisplayStatus(license, now) - const getPlanLabel = (policyId: string) => { - const plan = getPlanByPolicyId(policyId) - return plan ? t(plan.labelKey) : null - } - // When another active license (e.g. a lifetime one) keeps update access // open beyond the warning window, the per-card notice drops the "renew to // keep receiving updates" urgency — updates are not actually at risk. @@ -256,7 +251,26 @@ export function LicensesClient({ const displayStatus = getDisplayStatus(license) const statusPresentation = presentLicenseStatus(displayStatus) const expiringSoon = isLicenseExpiringSoon(license, now) - const planLabel = getPlanLabel(license.policyId) + const plan = getPlanByPolicyId(license.policyId) + const planLabel = plan ? t(plan.labelKey) : null + // A licence is renewable when it has an expiry to extend; a lifetime + // licence (null expiry) never renews. Deep-link to the pre-filled + // yearly checkout when the plan handle resolves (else the generic + // /pro). On payment the Medusa order-completed subscriber extends the + // SAME licence using the locked max(expiry, now) + 1yr rule. + const isRenewable = license.expiry != null + const renewHref = plan?.handle + ? `/pro/checkout?product=${plan.handle}` + : '/pro' + // Always offer renewal on a renewable licence that is active or + // expired, EXCEPT while the expiring-soon banner is already showing + // its own Renew (avoids two identical CTAs on one card). Skip the + // exceptional states (suspended/revoked/unknown) where a charge would + // not restore access. + const showRenew = + isRenewable && + !expiringSoon && + (displayStatus === 'active' || displayStatus === 'expired') // Per-licence attributed version (ADR-0006): null when this licence // alone covers nothing. const coveredVersion = entitledVersions[license.id] ?? null @@ -347,7 +361,7 @@ export function LicensesClient({ - {t('renew')} + {t('renew')} } > @@ -412,28 +426,41 @@ export function LicensesClient({ - {displayStatus === 'active' && ( - - )} - {displayStatus === 'expired' && ( + {(showRenew || + displayStatus === 'active' || + displayStatus === 'expired') && (
- - {/* Expired licenses can still download versions released - before their expiry; scope the downloads page to this - licence so another active licence does not pool access. */} - + {/* Renew is always offered on a yearly licence: primary when + expired (needed to regain access), secondary when active + (an optional early renewal — no days are lost). */} + {showRenew && ( + + )} + {displayStatus === 'active' && ( + + )} + {displayStatus === 'expired' && ( + /* Expired licenses can still download versions released + before their expiry; scope the downloads page to this + licence so another active licence does not pool access. */ + + )}
)} From cfac1028a0adb90cc7de9c7ae3713dc66eb407af Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 15:26:02 +0200 Subject: [PATCH 2/4] fix(e2e): expect deep-linked Renew href for the expired yearly persona The e2e-expired fixture is a yearly policy, so its card Renew now points at /pro/checkout?product=wcpos-pro-yearly instead of /pro. Updates the assertion to match the intended deep-link (Codex P2 / E2E CI failure). --- e2e/license-flows.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/license-flows.spec.ts b/e2e/license-flows.spec.ts index ae450d5b..1be8b34c 100644 --- a/e2e/license-flows.spec.ts +++ b/e2e/license-flows.spec.ts @@ -160,7 +160,11 @@ test.describe('Existing license holder data accuracy', () => { // downloads link stays, alongside a renew CTA. const renewLink = card.getByRole('link', { name: 'Renew' }) await expect(renewLink).toBeVisible() - await expect(renewLink).toHaveAttribute('href', '/pro') + // Yearly licence → Renew deep-links to the pre-filled yearly checkout. + await expect(renewLink).toHaveAttribute( + 'href', + '/pro/checkout?product=wcpos-pro-yearly' + ) await expect(card.getByRole('link', { name: /Downloads/ })).toBeVisible() }) From 2c83c38a3c6aeb9016ff8bdb77d97141157c3cb5 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 15:26:02 +0200 Subject: [PATCH 3/4] refactor(account): drop redundant showRenew disjunct in Renew CTA gate showRenew already implies displayStatus is active|expired, so the outer condition simplifies to the displayStatus check alone. No behaviour change (CodeRabbit nitpick). --- src/components/account/licenses-client.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/account/licenses-client.tsx b/src/components/account/licenses-client.tsx index a2774c78..315ae77e 100644 --- a/src/components/account/licenses-client.tsx +++ b/src/components/account/licenses-client.tsx @@ -426,8 +426,7 @@ export function LicensesClient({ - {(showRenew || - displayStatus === 'active' || + {(displayStatus === 'active' || displayStatus === 'expired') && (
{/* Renew is always offered on a yearly licence: primary when From 6bbe95213df87450b335162c0fdac16199c62357 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 15:26:02 +0200 Subject: [PATCH 4/4] test(account): key the Renew deep-link test off DEFAULT_YEARLY_POLICY_ID Import the constant from @/lib/plans instead of duplicating the yearly policy UUID literal, so the test stays coupled to the source of truth (also the existing Yearly-label test). CodeRabbit nitpick. --- src/components/account/licenses-client.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/account/licenses-client.test.tsx b/src/components/account/licenses-client.test.tsx index f96a1d56..3b843081 100644 --- a/src/components/account/licenses-client.test.tsx +++ b/src/components/account/licenses-client.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, screen } from '@testing-library/react' import { renderWithIntl as render } from '@/test/intl' import { LicensesClient } from './licenses-client' import type { CanonicalLicenseStatus } from '@/lib/license-status' +import { DEFAULT_YEARLY_POLICY_ID } from '@/lib/plans' // Mock the locale-aware Link as a simple anchor vi.mock('@/i18n/navigation', () => ({ @@ -253,7 +254,7 @@ describe('LicensesClient', () => { render( ) @@ -267,7 +268,7 @@ describe('LicensesClient', () => { initialLicenses={[ makeLicense({ status: 'active', - policyId: '261cb7e2-6e80-476e-98bd-fe7f406f258d', + policyId: DEFAULT_YEARLY_POLICY_ID, }), ]} />