Merge pull request #6655 from ouchihao/codex/issue-275-account-expiry-presets

feat(accounts): add month and year expiry presets
This commit is contained in:
Wesley Liddick
2026-09-07 18:50:13 +08:00
committed by GitHub
6 changed files with 134 additions and 2 deletions
@@ -2961,6 +2961,14 @@
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<div class="mt-2 flex gap-2">
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(1)">
{{ t('payment.oneMonth') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(12)">
{{ t('payment.oneYear') }}
</button>
</div>
<p class="input-hint">
{{ t('admin.accounts.expiresAtHint') }}
{{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }}
@@ -3867,6 +3875,7 @@ import {
parseDateTimeLocalInput
} from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry'
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
import {
OPENAI_WS_MODE_CTX_POOL,
@@ -1617,6 +1617,14 @@
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<div class="mt-2 flex gap-2">
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(1)">
{{ t('payment.oneMonth') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(12)">
{{ t('payment.oneYear') }}
</button>
</div>
<p class="input-hint">
{{ t('admin.accounts.expiresAtHint') }}
{{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }}
@@ -2963,6 +2971,7 @@ import {
parseDateTimeLocalInput
} from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry'
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
import {
@@ -1,6 +1,6 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
createAccountMock,
@@ -212,6 +212,50 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
})
afterEach(() => vi.useRealTimers())
it('sets month and year expiry presets without submitting the account form', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-01-31T12:34:00'))
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
for (const [label, expected] of [
['payment.oneMonth', '2026-02-28T12:34'],
['payment.oneYear', '2027-01-31T12:34'],
]) {
const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)!
expect(button.attributes('type')).toBe('button')
await button.trigger('click')
expect(input.element.value).toBe(expected)
expect(createAccountMock).not.toHaveBeenCalled()
}
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000)
wrapper.unmount()
})
it('allows a manually entered expiry to override a preset before account creation', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await selectButtonByText(wrapper, 'payment.oneMonth')
await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000)
wrapper.unmount()
})
it('hides only the redundant account toggle when every selected group enables tier pricing', async () => {
authIsSimpleMode.value = false
const wrapper = mountModal([
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
@@ -328,6 +328,50 @@ describe('EditAccountModal', () => {
authIsSimpleMode.value = true
})
afterEach(() => vi.useRealTimers())
it('sets expiry presets from now instead of extending the saved expiry', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2028-02-29T12:34:00'))
const account = buildAccount()
account.expires_at = new Date('2030-06-15T09:00:00').getTime() / 1000
updateAccountMock.mockReset().mockResolvedValue(account)
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
const wrapper = mountModal(account)
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
for (const [label, expected] of [
['payment.oneMonth', '2028-03-29T12:34'],
['payment.oneYear', '2029-02-28T12:34'],
]) {
const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)!
expect(button.attributes('type')).toBe('button')
await button.trigger('click')
expect(input.element.value).toBe(expected)
expect(updateAccountMock).not.toHaveBeenCalled()
}
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(new Date('2029-02-28T12:34:00').getTime() / 1000)
wrapper.unmount()
})
it('can clear a selected expiry preset before saving the account', async () => {
const account = buildAccount()
updateAccountMock.mockReset().mockResolvedValue(account)
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
const wrapper = mountModal(account)
const button = wrapper.findAll('button').find((candidate) => candidate.text() === 'payment.oneYear')!
await button.trigger('click')
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
expect(input.element.value).not.toBe('')
await input.setValue('')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(0)
wrapper.unmount()
})
it('reopening the same account rehydrates the OpenAI whitelist from props', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { getAccountExpiryTimestamp } from '../accountExpiry'
describe('getAccountExpiryTimestamp', () => {
it.each([
['2026-01-31T12:34:56.789', 1, '2026-02-28T12:34:56'],
['2028-01-31T12:34:56.789', 1, '2028-02-29T12:34:56'],
['2028-02-29T12:34:56.789', 12, '2029-02-28T12:34:56'],
['2026-12-31T12:34:56.789', 1, '2027-01-31T12:34:56'],
// With TZ=Australia/Melbourne, October 1 contains a DST gap; avoid using it as an intermediate date.
['2028-10-31T02:30:00', 1, '2028-11-30T02:30:00'],
])('adds %s by %i calendar months with month-end clamping', (input, months, expected) => {
const now = new Date(input)
const originalTimestamp = now.getTime()
expect(getAccountExpiryTimestamp(months, now)).toBe(new Date(expected).getTime() / 1000)
expect(now.getTime()).toBe(originalTimestamp)
})
})
@@ -0,0 +1,7 @@
/** Set an expiry from local calendar months, clamping month-end dates. */
export function getAccountExpiryTimestamp(months: number, now = new Date()): number {
const expiry = new Date(now.getTime())
const lastDay = new Date(expiry.getFullYear(), expiry.getMonth() + months + 1, 0).getDate()
expiry.setMonth(expiry.getMonth() + months, Math.min(expiry.getDate(), lastDay))
return Math.floor(expiry.getTime() / 1000)
}