diff --git a/web/pay-insights/README.md b/web/pay-insights/README.md index a121c362..47f213d9 100644 --- a/web/pay-insights/README.md +++ b/web/pay-insights/README.md @@ -39,7 +39,8 @@ Cloud SQL connector uses IAM auth with the service's own identity, so no passwor The home page, report page (`/reports/`), filters, totals, table and CSV export (`/api/v1/reports//export.csv`) come from the definition. In the SQL: -- Use named parameters: `:paymentMethod`. A `fiscalYear` filter provides `:fiscalYear`, - `:fiscalYearStart` and `:fiscalYearEnd` (UTC, matching how PAY stores dates). +- Use named parameters: `:paymentMethod`. A `period` filter (fiscal year or custom date range) + provides `:Start` and `:End` — UTC timestamps, end exclusive, matching how PAY stores dates. + A `fiscalYear` filter provides `:fiscalYear`, `:fiscalYearStart` and `:fiscalYearEnd`. - Cast parameters that can be NULL, e.g. `(:paymentMethod::text IS NULL OR ...)`. - Aggregate in SQL and don't add `LIMIT`. diff --git a/web/pay-insights/app/pages/index.vue b/web/pay-insights/app/pages/index.vue index d3d4d8b5..e4a273e3 100644 --- a/web/pay-insights/app/pages/index.vue +++ b/web/pay-insights/app/pages/index.vue @@ -8,7 +8,18 @@ useHead({ // Runs on the server during page render; the session cookie is forwarded automatically const { data: reports } = await useFetch('/api/v1/reports') -const { data: check, status } = await useFetch('/api/v1/warehouse/check') + +// Small status line: checked in the browser after the page loads, so it doesn't slow the page down +const { status: warehouseStatus, data: warehouse } = useFetch('/api/v1/warehouse/check', { server: false, lazy: true }) +const warehouseState = computed(() => { + if (warehouseStatus.value === 'success' && warehouse.value?.serverTime) { + return { dot: 'bg-green-600', text: t('page.home.warehouse.connected') } + } + if (warehouseStatus.value === 'error') { + return { dot: 'bg-red-600', text: t('page.home.warehouse.unavailable') } + } + return { dot: 'bg-gray-400', text: t('page.home.warehouse.checking') } +}) diff --git a/web/pay-insights/app/pages/reports/[id].vue b/web/pay-insights/app/pages/reports/[id].vue index fd73a593..3aa2f6cb 100644 --- a/web/pay-insights/app/pages/reports/[id].vue +++ b/web/pay-insights/app/pages/reports/[id].vue @@ -11,6 +11,7 @@ interface ReportColumn { } const ALL = '__all__' +const CUSTOM_PERIOD = 'custom' const PAGE_SIZE = 50 const route = useRoute() @@ -22,13 +23,30 @@ const reportId = String(route.params.id) // Filters live in the URL, so a report can be bookmarked or shared const query = computed(() => route.query) // lazy: navigate straight away and show a loading state while the warehouse query runs -const { data, status, error } = useFetch(`/api/v1/reports/${reportId}`, { query, lazy: true }) +const { data: latest, status, error } = useFetch(`/api/v1/reports/${reportId}`, { query, lazy: true }) + +// Keep showing the last good result (and its filters) if a request fails, e.g. an invalid date range +const lastGood = shallowRef(latest.value) +watch(latest, (value) => { + if (value) { + lastGood.value = value + } +}) +const data = computed(() => latest.value ?? lastGood.value) const isFirstLoad = computed(() => status.value === 'pending' && !data.value) useHead({ title: () => data.value?.report.title ?? 'PAY Insights' }) +// Breadcrumb: PAY Insights › , so the header back arrow returns home +watchEffect(() => { + route.meta.breadcrumbs = [ + { label: 'PAY Insights', to: localePath('/') }, + { label: data.value?.report.title ?? '' } + ] +}) + const currency = new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD' }) const number = new Intl.NumberFormat('en-CA') @@ -45,9 +63,26 @@ function formatValue(value: unknown, format?: ReportColumn['format']): string { return String(value) } +function setQuery(patch: Record) { + router.replace({ query: { ...route.query, ...patch } }) +} + function setFilter(key: string, value: unknown) { - const next = { ...route.query, [key]: value === ALL ? undefined : String(value) } - router.replace({ query: next }) + setQuery({ [key]: value === ALL ? undefined : String(value) }) +} + +/** Period: picking "Custom range" starts from the dates currently shown; a fiscal year clears them. */ +function setPeriod(filter: { key: string, from?: string, to?: string }, value: unknown) { + const custom = value === CUSTOM_PERIOD + setQuery({ + [filter.key]: String(value), + [`${filter.key}From`]: custom ? filter.from : undefined, + [`${filter.key}To`]: custom ? filter.to : undefined + }) +} + +function setPeriodDate(key: string, value: string | number) { + setQuery({ [key]: String(value) || undefined }) } const filters = computed(() => (data.value?.filters ?? []).map(filter => ({ @@ -105,19 +140,54 @@ const exportUrl = computed(() => {
- - - + + + + + { } const suffix = filters - .map(filter => filter.value) + .map(filter => filter.value === CUSTOM_PERIOD ? `${filter.from}_${filter.to}` : filter.value) .filter(value => value !== null) .join('-') const filename = `${report.id}${suffix ? `-${suffix}` : ''}.csv`.replace(/[^\w.-]/g, '_') diff --git a/web/pay-insights/server/reports/service-fees.ts b/web/pay-insights/server/reports/service-fees.ts index 39bb6096..9ec9a8dd 100644 --- a/web/pay-insights/server/reports/service-fees.ts +++ b/web/pay-insights/server/reports/service-fees.ts @@ -4,11 +4,11 @@ import { defineReport } from './types' export default defineReport({ id: 'service-fees', title: 'Service fees by product', - description: 'Service fees collected on paid invoices, by product, for one fiscal year (April 1 to March 31).', + description: 'Service fees collected on paid invoices, by product, for a fiscal year or a custom date range.', note: 'Includes invoices with status PAID, by payment date in Pacific time. Refunded invoices are not included.', filters: [ - { key: 'fiscalYear', type: 'fiscalYear', label: 'Fiscal year' }, + { key: 'period', type: 'period', label: 'Period' }, { key: 'paymentMethod', type: 'select', @@ -26,8 +26,8 @@ export default defineReport({ FROM pay.invoices i LEFT JOIN pay.corp_types ct ON ct.code = i.corp_type_code WHERE i.invoice_status_code = 'PAID' - AND i.payment_date >= :fiscalYearStart::timestamp - AND i.payment_date < :fiscalYearEnd::timestamp + AND i.payment_date >= :periodStart::timestamp + AND i.payment_date < :periodEnd::timestamp AND (:paymentMethod::text IS NULL OR i.payment_method_code = :paymentMethod::text) GROUP BY 1, 2 ORDER BY 4 DESC, 2`, diff --git a/web/pay-insights/server/reports/types.ts b/web/pay-insights/server/reports/types.ts index 91c4a48c..d5058e3d 100644 --- a/web/pay-insights/server/reports/types.ts +++ b/web/pay-insights/server/reports/types.ts @@ -11,6 +11,17 @@ export interface FiscalYearFilter { label: string } +/** + * A fiscal year (quick pick) or a custom date range. Query string: `=FY2025`, or + * `=custom&From=2025-05-01&To=2025-06-30` (dates in Pacific time, inclusive). + * Provides `:Start` and `:End` to the SQL — UTC timestamps, end exclusive. + */ +export interface PeriodFilter { + key: string + type: 'period' + label: string +} + export interface SelectFilter { key: string type: 'select' @@ -21,7 +32,7 @@ export interface SelectFilter { allLabel?: string } -export type ReportFilter = FiscalYearFilter | SelectFilter +export type ReportFilter = FiscalYearFilter | PeriodFilter | SelectFilter export interface ReportColumn { /** Column name returned by the SQL */ diff --git a/web/pay-insights/server/utils/dates.ts b/web/pay-insights/server/utils/dates.ts new file mode 100644 index 00000000..ad43caff --- /dev/null +++ b/web/pay-insights/server/utils/dates.ts @@ -0,0 +1,34 @@ +const PACIFIC_HOUR = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Vancouver', + hour: '2-digit', + hourCycle: 'h23' +}) + +/** Parses a `YYYY-MM-DD` date, or returns undefined if it isn't a real date. */ +export function parseIsoDate(value: unknown): string | undefined { + const text = typeof value === 'string' ? value : '' + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + if (!match) { + return undefined + } + const [year, month, day] = match.slice(1).map(Number) as [number, number, number] + const date = new Date(Date.UTC(year, month - 1, day)) + const valid = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day + return valid ? text : undefined +} + +export function addDays(date: string, days: number): string { + const [year, month, day] = date.split('-').map(Number) as [number, number, number] + return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10) +} + +/** + * Midnight Pacific on the given date, as a UTC timestamp (how PAY stores dates). + * Pacific midnight is 08:00 UTC in standard time and 07:00 UTC in daylight time. + */ +export function pacificMidnightAsUtc(date: string): string { + const [year, month, day] = date.split('-').map(Number) as [number, number, number] + const standardTimeGuess = new Date(Date.UTC(year, month - 1, day, 8)) + const isDaylightTime = PACIFIC_HOUR.format(standardTimeGuess) === '01' + return `${date} ${isDaylightTime ? '07' : '08'}:00:00` +} diff --git a/web/pay-insights/server/utils/fiscalYear.ts b/web/pay-insights/server/utils/fiscalYear.ts index d39fe24b..9edc4bd1 100644 --- a/web/pay-insights/server/utils/fiscalYear.ts +++ b/web/pay-insights/server/utils/fiscalYear.ts @@ -19,6 +19,11 @@ export function fiscalYearRange(year: number): { start: string, end: string } { return { start: `${year}-04-01 07:00:00`, end: `${year + 1}-04-01 07:00:00` } } +/** Fiscal year as inclusive Pacific dates, e.g. 2025-04-01 to 2026-03-31. */ +export function fiscalYearDates(year: number): { from: string, to: string } { + return { from: `${year}-04-01`, to: `${year + 1}-03-31` } +} + /** Returns a valid fiscal year from user input, or the current one if missing. Throws on invalid input. */ export function parseFiscalYear(value: unknown, today = new Date()): number { const current = currentFiscalYear(today) diff --git a/web/pay-insights/server/utils/reports.ts b/web/pay-insights/server/utils/reports.ts index 26bb32bc..aa98ae5b 100644 --- a/web/pay-insights/server/utils/reports.ts +++ b/web/pay-insights/server/utils/reports.ts @@ -1,5 +1,13 @@ import type { ReportColumn, ReportFilter } from '../reports/types' -import { currentFiscalYear, FIRST_FISCAL_YEAR, fiscalYearLabel, fiscalYearRange, parseFiscalYear } from './fiscalYear' +import { addDays, pacificMidnightAsUtc, parseIsoDate } from './dates' +import { + currentFiscalYear, + FIRST_FISCAL_YEAR, + fiscalYearDates, + fiscalYearLabel, + fiscalYearRange, + parseFiscalYear +} from './fiscalYear' export const PAGE_ROW_LIMIT = 5000 export const EXPORT_ROW_LIMIT = 100000 @@ -16,6 +24,9 @@ export interface ResolvedFilter { value: string | number | null options: FilterOption[] allLabel?: string + /** Period filters: the selected range as inclusive Pacific dates */ + from?: string + to?: string } export class ReportInputError extends Error {} @@ -40,14 +51,57 @@ export function toPositionalSql(sql: string, values: Record): { return { text, params } } +export const CUSTOM_PERIOD = 'custom' +const FIRST_DATE = `${FIRST_FISCAL_YEAR}-04-01` + +function first(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value +} + /** Validates one filter's input against its allowed values and returns the SQL parameters it provides. */ export function resolveFilter( filter: ReportFilter, - input: unknown, + query: Record, options: FilterOption[], today = new Date() ): { resolved: ResolvedFilter, sqlValues: Record } { - const raw = Array.isArray(input) ? input[0] : input + const raw = first(query[filter.key]) + + if (filter.type === 'period') { + let from: string | undefined + let to: string | undefined + let value: string + + if (raw === CUSTOM_PERIOD) { + from = parseIsoDate(first(query[`${filter.key}From`])) + to = parseIsoDate(first(query[`${filter.key}To`])) + if (!from || !to || from > to || from < FIRST_DATE) { + throw new ReportInputError(`Invalid ${filter.label}: choose a start and end date, with the start first`) + } + value = CUSTOM_PERIOD + } else { + const match = raw === undefined || raw === '' ? undefined : /^FY(\d{4})$/.exec(String(raw)) + if (raw !== undefined && raw !== '' && !match) { + throw new ReportInputError(`Invalid ${filter.label}`) + } + let year: number + try { + year = parseFiscalYear(match?.[1], today) + } catch { + throw new ReportInputError(`Invalid ${filter.label}`) + } + ({ from, to } = fiscalYearDates(year)) + value = `FY${year}` + } + + return { + resolved: { key: filter.key, type: filter.type, label: filter.label, value, options, from, to }, + sqlValues: { + [`${filter.key}Start`]: pacificMidnightAsUtc(from), + [`${filter.key}End`]: pacificMidnightAsUtc(addDays(to, 1)) + } + } + } if (filter.type === 'fiscalYear') { let year: number @@ -76,6 +130,13 @@ export function resolveFilter( } } +export function periodOptions(today = new Date()): FilterOption[] { + return [ + ...fiscalYearOptions(today).map(option => ({ value: `FY${option.value}`, label: `FY ${option.label}` })), + { value: CUSTOM_PERIOD, label: 'Custom range' } + ] +} + export function fiscalYearOptions(today = new Date()): FilterOption[] { const options: FilterOption[] = [] for (let year = currentFiscalYear(today); year >= FIRST_FISCAL_YEAR; year--) { diff --git a/web/pay-insights/server/utils/runReport.ts b/web/pay-insights/server/utils/runReport.ts index ad15291b..1630026e 100644 --- a/web/pay-insights/server/utils/runReport.ts +++ b/web/pay-insights/server/utils/runReport.ts @@ -25,11 +25,11 @@ export async function runReport( let sqlValues: Record = {} for (const filter of report.filters) { - const options = filter.type === 'fiscalYear' - ? fiscalYearOptions() - : await warehouseQuery>(filter.optionsSql) + const options = filter.type === 'select' + ? await warehouseQuery>(filter.optionsSql) + : filter.type === 'period' ? periodOptions() : fiscalYearOptions() try { - const result = resolveFilter(filter, query[filter.key], options) + const result = resolveFilter(filter, query, options) filters.push(result.resolved) sqlValues = { ...sqlValues, ...result.sqlValues } } catch (error) { diff --git a/web/pay-insights/tests/unit/server/dates.test.ts b/web/pay-insights/tests/unit/server/dates.test.ts new file mode 100644 index 00000000..95bbaaf0 --- /dev/null +++ b/web/pay-insights/tests/unit/server/dates.test.ts @@ -0,0 +1,27 @@ +import { addDays, pacificMidnightAsUtc, parseIsoDate } from '../../../server/utils/dates' + +describe('date utils', () => { + it('parses only real YYYY-MM-DD dates', () => { + expect(parseIsoDate('2025-02-28')).toBe('2025-02-28') + expect(parseIsoDate('2024-02-29')).toBe('2024-02-29') + expect(parseIsoDate('2025-02-29')).toBeUndefined() + expect(parseIsoDate('2025-2-1')).toBeUndefined() + expect(parseIsoDate(undefined)).toBeUndefined() + }) + + it('adds days across months and years', () => { + expect(addDays('2025-12-31', 1)).toBe('2026-01-01') + expect(addDays('2025-03-01', -1)).toBe('2025-02-28') + }) + + it('converts Pacific midnight to UTC, including daylight time changes', () => { + expect(pacificMidnightAsUtc('2025-01-15')).toBe('2025-01-15 08:00:00') + expect(pacificMidnightAsUtc('2025-07-01')).toBe('2025-07-01 07:00:00') + // Daylight time starts at 2am on March 9, 2025, so midnight is still standard time + expect(pacificMidnightAsUtc('2025-03-09')).toBe('2025-03-09 08:00:00') + expect(pacificMidnightAsUtc('2025-03-10')).toBe('2025-03-10 07:00:00') + // Standard time returns at 2am on November 2, 2025 + expect(pacificMidnightAsUtc('2025-11-02')).toBe('2025-11-02 07:00:00') + expect(pacificMidnightAsUtc('2025-11-03')).toBe('2025-11-03 08:00:00') + }) +}) diff --git a/web/pay-insights/tests/unit/server/reports.test.ts b/web/pay-insights/tests/unit/server/reports.test.ts index 00e481f5..7935f472 100644 --- a/web/pay-insights/tests/unit/server/reports.test.ts +++ b/web/pay-insights/tests/unit/server/reports.test.ts @@ -37,23 +37,60 @@ describe('resolveFilter', () => { it('provides fiscal year boundaries', () => { const fiscalYear: ReportFilter = { key: 'fiscalYear', type: 'fiscalYear', label: 'Fiscal year' } - const { sqlValues } = resolveFilter(fiscalYear, '2024', [], today) + const { sqlValues } = resolveFilter(fiscalYear, { fiscalYear: '2024' }, [], today) expect(sqlValues).toEqual({ fiscalYear: 2024, fiscalYearStart: '2024-04-01 07:00:00', fiscalYearEnd: '2025-04-01 07:00:00' }) - expect(() => resolveFilter(fiscalYear, '2030', [], today)).toThrow(ReportInputError) + expect(() => resolveFilter(fiscalYear, { fiscalYear: '2030' }, [], today)).toThrow(ReportInputError) }) it('only accepts listed select values', () => { - expect(resolveFilter(select, 'PAD', options).sqlValues).toEqual({ method: 'PAD' }) - expect(resolveFilter(select, undefined, options).sqlValues).toEqual({ method: null }) - expect(() => resolveFilter(select, "PAD' OR 1=1", options)).toThrow(ReportInputError) + expect(resolveFilter(select, { method: 'PAD' }, options).sqlValues).toEqual({ method: 'PAD' }) + expect(resolveFilter(select, {}, options).sqlValues).toEqual({ method: null }) + expect(() => resolveFilter(select, { method: "PAD' OR 1=1" }, options)).toThrow(ReportInputError) }) it('requires a select without an "all" option', () => { - expect(() => resolveFilter({ ...select, allLabel: undefined }, undefined, options)).toThrow(ReportInputError) + expect(() => resolveFilter({ ...select, allLabel: undefined }, {}, options)).toThrow(ReportInputError) + }) + + describe('period', () => { + const period: ReportFilter = { key: 'period', type: 'period', label: 'Period' } + + it('defaults to the current fiscal year', () => { + const { resolved, sqlValues } = resolveFilter(period, {}, [], today) + expect(resolved).toMatchObject({ value: 'FY2026', from: '2026-04-01', to: '2027-03-31' }) + expect(sqlValues).toEqual({ periodStart: '2026-04-01 07:00:00', periodEnd: '2027-04-01 07:00:00' }) + }) + + it('accepts a fiscal year', () => { + expect(resolveFilter(period, { period: 'FY2024' }, [], today).sqlValues) + .toEqual({ periodStart: '2024-04-01 07:00:00', periodEnd: '2025-04-01 07:00:00' }) + }) + + it('accepts a custom range in Pacific time, end date included', () => { + const { sqlValues } = resolveFilter( + period, { period: 'custom', periodFrom: '2025-01-15', periodTo: '2025-06-30' }, [], today + ) + // January is standard time (UTC-8), July is daylight time (UTC-7) + expect(sqlValues).toEqual({ periodStart: '2025-01-15 08:00:00', periodEnd: '2025-07-01 07:00:00' }) + }) + + it('rejects invalid periods', () => { + const invalid = [ + { period: 'FY2030' }, + { period: '2024' }, + { period: 'custom' }, + { period: 'custom', periodFrom: '2025-06-30', periodTo: '2025-01-01' }, + { period: 'custom', periodFrom: '2025-02-30', periodTo: '2025-03-01' }, + { period: 'custom', periodFrom: '2010-01-01', periodTo: '2010-02-01' } + ] + for (const query of invalid) { + expect(() => resolveFilter(period, query, [], today)).toThrow(ReportInputError) + } + }) }) }) @@ -93,7 +130,7 @@ describe('report definitions', () => { const values: Record = {} for (const filter of report.filters) { values[filter.key] = null - if (filter.type === 'fiscalYear') { + if (filter.type === 'fiscalYear' || filter.type === 'period') { values[`${filter.key}Start`] = null values[`${filter.key}End`] = null }