diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 143a555509..4db8c628cf 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v2 with: - deno-version: 2.5.7 + deno-version: 2.9.7 cache: true - name: Require telemetry configuration for deployment diff --git a/docs/docs/img/pr-2275/billing-settings-populated.png b/docs/docs/img/pr-2275/billing-settings-populated.png new file mode 100644 index 0000000000..4812e107b1 Binary files /dev/null and b/docs/docs/img/pr-2275/billing-settings-populated.png differ diff --git a/docs/docs/img/pr-2275/invoices-storybook.png b/docs/docs/img/pr-2275/invoices-storybook.png new file mode 100644 index 0000000000..74304ea325 Binary files /dev/null and b/docs/docs/img/pr-2275/invoices-storybook.png differ diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/billing-information.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/billing-information.e2e.ts new file mode 100644 index 0000000000..c4cde28b15 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/billing-information.e2e.ts @@ -0,0 +1,128 @@ +import { expect, test } from '@playwright/test'; + +const ORGANIZATION_ID = '000000000000000000000001'; +const USER_ID = '000000000000000000000002'; + +for (const width of [1440, 390]) { + test(`billing information autosaves and clears at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ height: 1000, width }); + const data: Record = { unrelated_key: 'preserved' }; + const organization = { data, features: [], id: ORGANIZATION_ID, name: 'Billing Test', plan_id: 'EX_FREE', plan_name: 'Free' }; + const writes: string[] = []; + let rejectNextWrite = false; + const writeGate = { pending: undefined as Promise | undefined }; + await page.addInitScript((organizationId) => { + localStorage.setItem('satellizer_token', 'billing-test-token'); + localStorage.setItem('organization', JSON.stringify(organizationId)); + }, ORGANIZATION_ID); + await page.route('**/health', (route) => route.fulfill({ body: 'OK' })); + await page.route('**/api/v2/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + const dataPrefix = `/api/v2/organizations/${ORGANIZATION_ID}/data/`; + if (path.startsWith(dataPrefix)) { + await writeGate.pending; + if (rejectNextWrite) { + rejectNextWrite = false; + await route.fulfill({ + contentType: 'application/problem+json', + json: { detail: 'Please retry saving.', status: 503, title: 'Save unavailable' }, + status: 503 + }); + return; + } + const key = decodeURIComponent(path.slice(dataPrefix.length)); + writes.push(`${request.method()}:${key}`); + if (request.method() === 'DELETE') { + delete data[key]; + } else { + data[key] = (request.postDataJSON() as { value: string }).value; + } + await route.fulfill({ status: 200 }); + return; + } + if (path === '/api/v2/users/me') { + await route.fulfill({ + json: { + email_address: 'billing@example.test', + email_notifications_enabled: true, + full_name: 'Billing Tester', + has_local_account: true, + id: USER_ID, + is_active: true, + is_email_address_verified: true, + is_invite: false, + o_auth_accounts: [], + organization_ids: [ORGANIZATION_ID], + organization_preferences: [], + roles: [] + } + }); + } else if (path === '/api/v2/organizations') { + await route.fulfill({ json: [organization] }); + } else if (path === `/api/v2/organizations/${ORGANIZATION_ID}`) { + await route.fulfill({ json: organization }); + } else if (path === `/api/v2/organizations/${ORGANIZATION_ID}/invoices`) { + await route.fulfill({ json: [{ date: '2026-07-01T12:00:00Z', id: 'invoice-1', paid: true, status: 'paid', total: 199 }] }); + } else if (path === '/api/v2/assistant/access') { + await route.fulfill({ json: { enabled: false, has_access: false, message: null, upgrade_required: false } }); + } else { + await route.fulfill({ json: [] }); + } + }); + + await page.goto(`/next/organization/${ORGANIZATION_ID}/billing`); + const name = page.getByRole('textbox', { exact: true, name: 'Billing name' }); + await expect(name).toHaveValue(''); + await name.fill(' Acme, Inc. '); + await page.getByRole('textbox', { exact: true, name: 'Billing address' }).fill('123 Main Street\nAnytown'); + await page.getByRole('textbox', { exact: true, name: 'VAT ID' }).fill('DE123456789'); + await page.getByRole('textbox', { exact: true, name: 'VAT number' }).fill('123456789'); + await expect.poll(() => writes.length).toBe(4); + expect(data).toEqual({ + billing_address: '123 Main Street\nAnytown', + billing_name: 'Acme, Inc.', + billing_vat_id: 'DE123456789', + billing_vat_number: '123456789', + unrelated_key: 'preserved' + }); + await page.reload(); + await expect(name).toHaveValue('Acme, Inc.'); + await expect(page.getByRole('columnheader', { exact: true, name: 'Amount' })).toBeVisible(); + await expect(page.getByRole('cell', { exact: true, name: 'Paid' })).toBeVisible(); + await testInfo.attach(`billing-${width}`, { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' }); + + rejectNextWrite = true; + await name.fill('Retry name'); + await expect(page.getByText(/Error saving billing information/).first()).toBeVisible(); + await expect(name).toHaveValue('Retry name'); + expect(data.billing_name).toBe('Acme, Inc.'); + await name.fill('Retried name'); + await expect.poll(() => data.billing_name).toBe('Retried name'); + await name.fill(' '); + await expect.poll(() => data.billing_name).toBeUndefined(); + expect(writes).toContain('DELETE:billing_name'); + expect(data.unrelated_key).toBe('preserved'); + await page.reload(); + await expect(name).toHaveValue(''); + + await name.fill('Saved before leaving'); + await page.getByRole('link', { exact: true, name: 'General' }).click(); + await expect(page).toHaveURL(new RegExp(`/organization/${ORGANIZATION_ID}/billing$`)); + await expect.poll(() => data.billing_name).toBe('Saved before leaving'); + + const releaseWrite = Promise.withResolvers(); + writeGate.pending = releaseWrite.promise; + const pendingRequest = page.waitForRequest((request) => request.method() === 'POST' && request.url().endsWith('/data/billing_name')); + await name.fill('Save in flight'); + await pendingRequest; + await page.getByRole('link', { exact: true, name: 'General' }).click(); + await expect(page.getByText('Please wait for billing information to finish saving, then try navigating again.')).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/organization/${ORGANIZATION_ID}/billing$`)); + releaseWrite.resolve(); + await expect.poll(() => data.billing_name).toBe('Save in flight'); + await expect(page.getByText('Successfully updated billing information.').last()).toBeVisible(); + await page.getByRole('link', { exact: true, name: 'General' }).click(); + await expect(page).toHaveURL(new RegExp(`/organization/${ORGANIZATION_ID}/manage$`)); + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.test.ts new file mode 100644 index 0000000000..4648580eab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.test.ts @@ -0,0 +1,122 @@ +import type { QueryClient as QueryClientType } from '@tanstack/svelte-query'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mutationOptions } = vi.hoisted(() => ({ + mutationOptions: [] as { + onMutate: (variables: { organizationId: string }) => Promise; + onSettled: () => Promise; + onSuccess: (result: boolean, variables: { key: string; organizationId: string; value: string }) => Promise; + }[] +})); + +vi.mock('$features/auth/index.svelte', () => ({ + accessToken: { current: 'token' } +})); + +vi.mock('$features/shared/api/api.svelte', () => ({ + fetchApiJson: vi.fn() +})); + +vi.mock('$features/users/api.svelte', () => ({ + queryKeys: { me: () => ['User', 'me'] } +})); + +vi.mock('@foundatiofx/fetchclient', () => ({ + useFetchClient: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (importOriginal) => ({ + ...(await importOriginal()), + createMutation: (options: () => unknown) => { + const mutation = options() as (typeof mutationOptions)[number]; + mutationOptions.push(mutation); + return mutation; + }, + createQuery: vi.fn(), + useQueryClient: () => queryClient +})); + +import { QueryClient, QueryObserver } from '@tanstack/svelte-query'; + +import { deleteOrganizationDataMutation, postOrganizationDataMutation, queryKeys } from './api.svelte'; + +const queryClient: QueryClientType = new QueryClient(); + +afterEach(() => { + queryClient.clear(); + mutationOptions.length = 0; + vi.restoreAllMocks(); +}); + +describe('organization data mutations', () => { + it('cancels an in-flight organization read before each data write', async () => { + const organizationId = 'organization-id'; + const cancelQueries = vi.spyOn(queryClient, 'cancelQueries'); + + postOrganizationDataMutation(); + deleteOrganizationDataMutation(); + + const postMutation = mutationOptions[0]!; + const deleteMutation = mutationOptions[1]!; + await postMutation.onMutate?.({ organizationId }); + await deleteMutation.onMutate?.({ organizationId }); + + expect(cancelQueries).toHaveBeenCalledWith({ queryKey: queryKeys.id(organizationId, undefined) }); + }); + + it.each(['before', 'during'])('prevents a list read started %s a write from replacing saved billing data', async (readTiming) => { + const organizationId = 'organization-id'; + const organization = { data: { billing_name: 'Old name', unrelated_key: 'preserved' }, id: organizationId }; + const listKey = queryKeys.list(undefined); + const oldResponse = { data: [organization] }; + queryClient.setQueryData(listKey, oldResponse); + queryClient.setQueryData(queryKeys.id(organizationId, undefined), organization); + const pendingRead = Promise.withResolvers(); + postOrganizationDataMutation(); + const mutation = mutationOptions[0]!; + if (readTiming === 'during') { + await mutation.onMutate({ organizationId }); + } + const read = queryClient.fetchQuery({ queryFn: () => pendingRead.promise, queryKey: listKey }).catch(() => undefined); + if (readTiming === 'before') { + await mutation.onMutate({ organizationId }); + } + await mutation.onSuccess(true, { key: 'billing_name', organizationId, value: 'New name' }); + pendingRead.resolve(oldResponse); + await read; + + expect(queryClient.getQueryData(listKey)).toEqual({ data: [{ ...organization, data: { billing_name: 'New name', unrelated_key: 'preserved' } }] }); + expect(queryClient.getQueryData(queryKeys.id(organizationId, undefined))).toEqual({ + ...organization, + data: { billing_name: 'New name', unrelated_key: 'preserved' } + }); + }); + + it.each([true, false])('restarts an initial list read after a billing mutation settles (success: %s)', async (success) => { + const organizationId = 'organization-id'; + const response = { data: [{ data: { billing_name: 'Saved name' }, id: organizationId }] }; + const initialRead = Promise.withResolvers(); + const queryFn = vi.fn().mockReturnValueOnce(initialRead.promise).mockResolvedValue(response); + const observer = new QueryObserver(queryClient, { queryFn, queryKey: queryKeys.list(undefined) }); + const unsubscribe = observer.subscribe(() => {}); + + try { + expect(queryFn).toHaveBeenCalledTimes(1); + postOrganizationDataMutation(); + const mutation = mutationOptions[0]!; + await mutation.onMutate({ organizationId }); + if (success) { + await mutation.onSuccess(true, { key: 'billing_name', organizationId, value: 'Saved name' }); + } + await mutation.onSettled(); + + expect(queryFn).toHaveBeenCalledTimes(2); + expect(observer.getCurrentResult().status).toBe('success'); + expect(observer.getCurrentResult().data).toEqual(response); + } finally { + unsubscribe(); + initialRead.resolve(response); + } + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index 241189f845..5de54d4411 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -1,3 +1,4 @@ +import type { StringValueFromBody } from '$features/shared/models'; import type { WebSocketMessageValue } from '$features/websockets/models'; import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; @@ -69,6 +70,7 @@ export const queryKeys = { } ] as const, changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const, + data: (id: string | undefined) => [...queryKeys.type, id, 'data'] as const, deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, icon: (id: string | undefined) => [...queryKeys.id(id, undefined), 'icon'] as const, id: (id: string | undefined, mode: 'stats' | undefined) => @@ -193,6 +195,11 @@ export interface GetPlansRequest { }; } +export interface OrganizationDataParams { + key: string; + organizationId: string; +} + export interface OrganizationIconRequest { route: { id: string | undefined; @@ -205,6 +212,10 @@ export interface PatchOrganizationRequest { }; } +export interface PostOrganizationDataParams extends OrganizationDataParams { + value: string; +} + export interface PostSetBonusOrganizationParams { bonusEvents: number; expires?: Date; @@ -306,6 +317,48 @@ export function deleteOrganization(request: DeleteOrganizationRequest) { })); } +export function deleteOrganizationDataMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ key, organizationId }: OrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.delete(`organizations/${organizationId}/data/${encodeURIComponent(key)}`); + return response.ok; + }, + mutationKey: queryKeys.data(undefined), + onError: (_, { organizationId }) => { + return queryClient.invalidateQueries({ + queryKey: queryKeys.id(organizationId, undefined) + }); + }, + onMutate: ({ organizationId }) => cancelOrganizationDataRead(queryClient, organizationId), + onSettled: () => + queryClient.invalidateQueries({ + queryKey: queryKeys.list(undefined) + }), + onSuccess: async (_, { key, organizationId }) => { + await cancelOrganizationDataRead(queryClient, organizationId); + updateOrganizationCaches(queryClient, organizationId, (organization) => { + if (!organization.data) { + return organization; + } + + const data = { + ...organization.data + }; + delete data[key]; + + return { + ...organization, + data + }; + }); + } + })); +} + export function deleteOrganizationIcon(request: OrganizationIconRequest) { const queryClient = useQueryClient(); @@ -584,6 +637,42 @@ export function postOrganization() { })); } +export function postOrganizationDataMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ key, organizationId, value }: PostOrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.post(`organizations/${organizationId}/data/${encodeURIComponent(key)}`, { + value + } satisfies StringValueFromBody); + return response.ok; + }, + mutationKey: queryKeys.data(undefined), + onError: (_, { organizationId }) => { + return queryClient.invalidateQueries({ + queryKey: queryKeys.id(organizationId, undefined) + }); + }, + onMutate: ({ organizationId }) => cancelOrganizationDataRead(queryClient, organizationId), + onSettled: () => + queryClient.invalidateQueries({ + queryKey: queryKeys.list(undefined) + }), + onSuccess: async (_, { key, organizationId, value }) => { + await cancelOrganizationDataRead(queryClient, organizationId); + updateOrganizationCaches(queryClient, organizationId, (organization) => ({ + ...organization, + data: { + ...(organization.data ?? {}), + [key]: value + } + })); + } + })); +} + export function postSetBonusOrganization() { const queryClient = useQueryClient(); @@ -735,6 +824,17 @@ export function uploadOrganizationIcon(request: OrganizationIconRequest) { })); } +async function cancelOrganizationDataRead(queryClient: QueryClient, organizationId: string) { + await Promise.all([ + queryClient.cancelQueries({ + queryKey: queryKeys.id(organizationId, undefined) + }), + queryClient.cancelQueries({ + queryKey: queryKeys.list(undefined) + }) + ]); +} + function updateOrganizationCache(queryClient: QueryClient, id: string | undefined, organization: ViewOrganization) { if (!id) { return; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts new file mode 100644 index 0000000000..46f7b8682d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createSerializedBillingInformationSave, + getOrganizationBillingInformation, + getOrganizationBillingInformationChanges, + normalizeOrganizationBillingInformationValue, + organizationBillingInformationDataKeys, + saveOrganizationBillingInformationChanges +} from './billing-information'; + +describe('getOrganizationBillingInformation', () => { + it('returns billing information from known organization data keys', () => { + // Arrange + const organization = { + data: { + [organizationBillingInformationDataKeys.address]: '123 Main Street', + [organizationBillingInformationDataKeys.name]: 'Acme, Inc.', + [organizationBillingInformationDataKeys.vatId]: 'DE123456789', + [organizationBillingInformationDataKeys.vatNumber]: '123456789' + } + }; + + // Act + const billingInformation = getOrganizationBillingInformation(organization); + + // Assert + expect(billingInformation).toEqual({ + address: '123 Main Street', + name: 'Acme, Inc.', + vatId: 'DE123456789', + vatNumber: '123456789' + }); + }); + + it('defaults missing or non-string billing information values to empty strings', () => { + // Arrange + const organization = { + data: { + [organizationBillingInformationDataKeys.address]: ['invalid'], + [organizationBillingInformationDataKeys.name]: null, + [organizationBillingInformationDataKeys.vatId]: undefined, + [organizationBillingInformationDataKeys.vatNumber]: 42 + } + }; + + // Act + const billingInformation = getOrganizationBillingInformation(organization); + + // Assert + expect(billingInformation).toEqual({ + address: '', + name: '', + vatId: '', + vatNumber: '' + }); + }); +}); + +describe('normalizeOrganizationBillingInformationValue', () => { + it('trims non-empty values and removes blank values', () => { + // Arrange + const value = ' DE123456789 '; + const blankValue = ' '; + + // Act + const normalizedValue = normalizeOrganizationBillingInformationValue(value); + const normalizedBlankValue = normalizeOrganizationBillingInformationValue(blankValue); + + // Assert + expect(normalizedValue).toBe('DE123456789'); + expect(normalizedBlankValue).toBeNull(); + }); +}); + +describe('getOrganizationBillingInformationChanges', () => { + it('returns only normalized values that changed', () => { + const current = { + address: '123 Main Street', + name: 'Acme, Inc.', + vatId: 'DE123456789', + vatNumber: '' + }; + + const changes = getOrganizationBillingInformationChanges(current, { + ...current, + name: ' Acme, Inc. ', + vatId: ' ', + vatNumber: ' 123456789 ' + }); + + expect(changes).toEqual([ + { key: organizationBillingInformationDataKeys.vatId, value: null }, + { key: organizationBillingInformationDataKeys.vatNumber, value: '123456789' } + ]); + }); +}); + +describe('saveOrganizationBillingInformationChanges', () => { + it('waits for each organization write before starting the next one', async () => { + const firstWrite = Promise.withResolvers(); + const calls: string[] = []; + const writer = { + remove: vi.fn(async (key: string) => { + calls.push(`remove:${key}`); + }), + set: vi.fn(async (key: string, value: string) => { + calls.push(`set:${key}:${value}`); + await firstWrite.promise; + }) + }; + + const save = saveOrganizationBillingInformationChanges( + [ + { key: organizationBillingInformationDataKeys.name, value: 'Acme, Inc.' }, + { key: organizationBillingInformationDataKeys.vatId, value: null } + ], + writer + ); + + expect(calls).toEqual([`set:${organizationBillingInformationDataKeys.name}:Acme, Inc.`]); + expect(writer.remove).not.toHaveBeenCalled(); + + firstWrite.resolve(); + await save; + + expect(calls).toEqual([`set:${organizationBillingInformationDataKeys.name}:Acme, Inc.`, `remove:${organizationBillingInformationDataKeys.vatId}`]); + }); +}); + +describe('createSerializedBillingInformationSave', () => { + it('serializes overlapping autosaves and continues after a rejected save', async () => { + const firstSave = Promise.withResolvers(); + const calls: string[] = []; + let activeSaves = 0; + let maximumActiveSaves = 0; + const save = vi.fn(async (organizationId: string) => { + calls.push(organizationId); + activeSaves++; + maximumActiveSaves = Math.max(maximumActiveSaves, activeSaves); + + try { + if (organizationId === 'first') { + await firstSave.promise; + throw new Error('save failed'); + } + } finally { + activeSaves--; + } + }); + const serializedSave = createSerializedBillingInformationSave(save); + + const first = serializedSave('first'); + const second = serializedSave('second'); + + await vi.waitFor(() => expect(calls).toEqual(['first'])); + firstSave.resolve(); + await expect(first).rejects.toThrow('save failed'); + await second; + + expect(calls).toEqual(['first', 'second']); + expect(maximumActiveSaves).toBe(1); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts new file mode 100644 index 0000000000..2b5cba0c5f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts @@ -0,0 +1,74 @@ +import type { ViewOrganization } from './models'; +import type { OrganizationBillingInformationFormData } from './schemas'; + +export const organizationBillingInformationDataKeys = { + address: 'billing_address', + name: 'billing_name', + vatId: 'billing_vat_id', + vatNumber: 'billing_vat_number' +} as const; + +export interface OrganizationBillingInformationChange { + key: (typeof organizationBillingInformationDataKeys)[keyof typeof organizationBillingInformationDataKeys]; + value: null | string; +} + +export interface OrganizationBillingInformationWriter { + remove: (key: OrganizationBillingInformationChange['key']) => Promise; + set: (key: OrganizationBillingInformationChange['key'], value: string) => Promise; +} + +export function createSerializedBillingInformationSave(save: (organizationId: string) => Promise) { + let pendingSave = Promise.resolve(); + + return (organizationId: string): Promise => { + const nextSave = pendingSave.then(() => save(organizationId)); + pendingSave = nextSave.catch(() => undefined); + return nextSave; + }; +} + +export function getOrganizationBillingInformation(organization?: null | Pick): OrganizationBillingInformationFormData { + const data = organization?.data; + + return { + address: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.address]), + name: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.name]), + vatId: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.vatId]), + vatNumber: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.vatNumber]) + }; +} + +export function getOrganizationBillingInformationChanges( + current: OrganizationBillingInformationFormData, + next: OrganizationBillingInformationFormData +): OrganizationBillingInformationChange[] { + return (Object.keys(organizationBillingInformationDataKeys) as (keyof OrganizationBillingInformationFormData)[]).flatMap((field) => { + const currentValue = normalizeOrganizationBillingInformationValue(current[field]); + const nextValue = normalizeOrganizationBillingInformationValue(next[field]); + + return currentValue === nextValue ? [] : [{ key: organizationBillingInformationDataKeys[field], value: nextValue }]; + }); +} + +export function normalizeOrganizationBillingInformationValue(value: string): null | string { + const trimmedValue = value.trim(); + return trimmedValue || null; +} + +export async function saveOrganizationBillingInformationChanges( + changes: OrganizationBillingInformationChange[], + writer: OrganizationBillingInformationWriter +): Promise { + for (const change of changes) { + if (change.value === null) { + await writer.remove(change.key); + } else { + await writer.set(change.key, change.value); + } + } +} + +function getOrganizationBillingInformationValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.stories.ts new file mode 100644 index 0000000000..06386916bf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.stories.ts @@ -0,0 +1,65 @@ +import type { InvoiceGridModel } from '$features/organizations/models'; +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import BillingInvoices from './billing-invoices.svelte'; + +const invoices: InvoiceGridModel[] = [ + { + date: '2026-07-01T14:32:00Z', + id: '671f17bb3d274d1f38a5c201', + paid: true, + status: 'paid', + total: 199 + }, + { + date: '2026-06-01T14:29:00Z', + id: '665dbff6bc16d969f98f44c2', + paid: true, + status: 'paid', + total: 199 + }, + { + date: '2026-05-01T14:26:00Z', + id: '6632d98891c861130ca2b4f5', + paid: false, + status: 'open', + total: 199 + } +]; + +const meta = { + component: BillingInvoices, + parameters: { + layout: 'padded' + }, + tags: ['autodocs'], + title: 'Features/Organizations/BillingInvoices' +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Populated: Story = { + args: { + invoices + } +}; + +export const Empty: Story = { + args: { + invoices: [] + } +}; + +export const Loading: Story = { + args: { + isLoading: true + } +}; + +export const Error: Story = { + args: { + hasError: true + } +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.svelte new file mode 100644 index 0000000000..4fcb015af9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.svelte @@ -0,0 +1,95 @@ + + +{#if isLoading} +
+ + + +
+{:else if hasError} + +{:else} +
+ + + + Payment Number + Date + Amount + Status + Actions + + + + {#if invoices.length > 0} + {#each invoices as invoice (invoice.id)} + + onopeninvoice(invoice.id)}> + {invoice.id} + + onopeninvoice(invoice.id)}> + + + onopeninvoice(invoice.id)}> + + + onopeninvoice(invoice.id)}> + {getInvoiceStatusLabel(invoice.status, invoice.total)} + + + + + {#snippet child({ props })} + + {/snippet} + + + onopeninvoice(invoice.id)}> + + View Payment + + {@render stripeInvoiceAction?.(invoice)} + + + + + {/each} + {:else} + + + No invoices were found. + + + {/if} + + +
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.svelte.test.ts new file mode 100644 index 0000000000..c52e35eb54 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/billing-invoices.svelte.test.ts @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import BillingInvoices from './billing-invoices.svelte'; + +const paidInvoiceId = '671f17bb3d274d1f38a5c201'; +const invoices = [ + { + date: '2026-07-01T14:32:00Z', + id: paidInvoiceId, + paid: true, + status: 'paid', + total: 199 + }, + { + date: '2026-06-01T14:29:00Z', + id: '665dbff6bc16d969f98f44c2', + paid: false, + status: 'open', + total: 199 + } +]; + +describe('BillingInvoices', () => { + it('renders invoice amounts and statuses and opens a selected invoice', async () => { + const onopeninvoice = vi.fn(); + + render(BillingInvoices, { invoices, onopeninvoice }); + + expect(screen.getByText('Paid')).toBeTruthy(); + expect(screen.getByText('Payment due')).toBeTruthy(); + + await fireEvent.click(screen.getByText(paidInvoiceId)); + + expect(onopeninvoice).toHaveBeenCalledWith(paidInvoiceId); + + await fireEvent.click(screen.getAllByRole('button', { name: 'Actions' })[0]!); + await fireEvent.click(await screen.findByText('View Payment')); + + expect(onopeninvoice).toHaveBeenNthCalledWith(2, paidInvoiceId); + }); + + it('renders the empty state', () => { + render(BillingInvoices); + + expect(screen.getByText('No invoices were found.')).toBeTruthy(); + }); + + it('renders the loading state', () => { + render(BillingInvoices, { isLoading: true }); + + expect(screen.getByRole('status', { name: 'Loading invoices' })).toBeTruthy(); + }); + + it('renders the error state', () => { + render(BillingInvoices, { hasError: true }); + + expect(screen.getByText('Unable to load invoice data.')).toBeTruthy(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts index c380dca5e7..0ac5691083 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/schemas.ts @@ -4,6 +4,14 @@ import { SuspensionCode } from './models'; export { type NewOrganizationFormData, NewOrganizationSchema } from '$generated/schemas'; +export const OrganizationBillingInformationSchema = object({ + address: string(), + name: string(), + vatId: string(), + vatNumber: string() +}); +export type OrganizationBillingInformationFormData = Infer; + export const SetBonusOrganizationSchema = object({ bonusEvents: number().int('Bonus events must be a whole number'), expires: date().optional() diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte index c1b5879f7a..2805892238 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte @@ -1,28 +1,41 @@ -
+{#snippet stripeInvoiceAction(invoice: InvoiceGridModel)} + + handleViewStripeInvoice(invoice.id)}> + + View Stripe Invoice + + +{/snippet} + +
+ Billing information and invoices + {#if organizationQuery.isLoading} -
+
{:else if organizationQuery.error} {:else} -
+
+
{ + e.preventDefault(); + e.stopPropagation(); + void submitBillingInformationForm(organizationId); + }} + > + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + + + {#snippet children(field)} + + Billing name + { + field.handleChange(e.currentTarget.value); + debouncedFormSubmit(organizationId); + }} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#snippet children(field)} + + VAT ID + { + field.handleChange(e.currentTarget.value); + debouncedFormSubmit(organizationId); + }} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#snippet children(field)} + + Billing address +