feat(billing): make the expired-trial state visible with a clear upgrade path (#1725)

getCompanyEntitlements now derives an entitlementState (trial /
trial_expired / lapsed_subscription / paid / none) plus trialExpiredAt
from the grants it already fetches, reading company_subscriptions.status
inside the existing Promise.all so churned payers get 'abonnemang' copy
instead of 'provperiod'. The state threads through CompanyContext and the
dashboard layout.

Two new surfaces, both hidden in sandbox:
- SubscriptionTouchpoint replaces the sidebar trial pill: countdown while
  the trial runs, a persistent muted upgrade link to /settings/billing
  once it lapses (visible even collapsed, icon-only with aria-label), and
  the first mobile bottom-sheet touchpoint.
- TrialExpiredDialog: one-time on-entry notice with 'Se abonnemang' and a
  ghost dismiss; acknowledgement persists per user+company in
  user_preferences.ui_state.trial_expired_ack (read server-side, no
  flash), set on dismiss and click-through alike.

Narrows the 2026-07-11 'no trial-expired nag' decision at the founder's
direction after a user could not find the upgrade path at all; see
DECISIONS.md.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:05:39 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 834cc4d0e8
commit c402421908
13 changed files with 476 additions and 62 deletions
@@ -146,6 +146,48 @@ describe('POST /api/user/ui-state', () => {
expect(body.data.ui_state).toEqual({ nav_collapsed: true })
})
it('returns 400 when a trial_expired_ack key is not a company UUID', async () => {
const res = await POST(request({ trial_expired_ack: { 'not-a-uuid': new Date().toISOString() } }))
expect(res.status).toBe(400)
})
it('returns 400 when a trial_expired_ack value is not an ISO timestamp', async () => {
const res = await POST(
request({ trial_expired_ack: { '11111111-1111-4111-8111-111111111111': 'yesterday' } }),
)
expect(res.status).toBe(400)
})
it('merges trial_expired_ack per company instead of replacing the record', async () => {
const ackedAt = '2026-08-01T10:00:00.000Z'
enqueue({
data: {
ui_state: {
trial_expired_ack: { '11111111-1111-4111-8111-111111111111': ackedAt },
},
},
})
enqueue({ data: null })
const newAck = '2026-08-19T09:00:00.000Z'
const { status, body } = await parseJsonResponse<{
data: { ui_state: { trial_expired_ack: Record<string, string> } }
}>(
await POST(
request({
trial_expired_ack: { '22222222-2222-4222-8222-222222222222': newAck },
}),
),
)
expect(status).toBe(200)
// Acking company B never clears company A's ack.
expect(body.data.ui_state.trial_expired_ack).toEqual({
'11111111-1111-4111-8111-111111111111': ackedAt,
'22222222-2222-4222-8222-222222222222': newAck,
})
})
it('returns 500 when the upsert fails', async () => {
enqueue({ data: null })
enqueue({ data: null, error: { message: 'boom' } })
+9
View File
@@ -35,6 +35,12 @@ const BodySchema = z
})
.strict()
.optional(),
// One-time expired-trial dialog acknowledgement: companyId -> ISO
// timestamp of the ack. Merged per key like create_mode, so acking one
// company never clears another's.
trial_expired_ack: z
.record(z.string().uuid(), z.string().datetime())
.optional(),
})
.strict()
@@ -79,6 +85,9 @@ export async function POST(request: Request) {
...(patch.agent_panel
? { agent_panel: { ...current.agent_panel, ...patch.agent_panel } }
: {}),
...(patch.trial_expired_ack
? { trial_expired_ack: { ...current.trial_expired_ack, ...patch.trial_expired_ack } }
: {}),
}
const { error: upsertError } = await supabase