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 ecc2301730..fe0d2cf519 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,5 +1,5 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; -import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; +import type { BillingPlan, ChangePlanRequest, ChangePlanResult, StringValueFromBody } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; @@ -26,6 +26,7 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me export const queryKeys = { adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] 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) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)), @@ -54,6 +55,11 @@ export interface ChangePlanMutationRequest { }; } +export interface DeleteOrganizationDataParams { + key: string; + organizationId: string; +} + export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -142,6 +148,12 @@ export interface PatchOrganizationRequest { }; } +export interface PostOrganizationDataParams { + key: string; + organizationId: string; + value: string; +} + export interface PostSetBonusOrganizationParams { bonusEvents: number; expires?: Date; @@ -225,6 +237,35 @@ export function deleteOrganization(request: DeleteOrganizationRequest) { })); } +export function deleteOrganizationData() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ key, organizationId }: DeleteOrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.delete(`organizations/${organizationId}/data/${encodeURIComponent(key)}`); + return response.ok; + }, + mutationKey: queryKeys.data(undefined), + onError: (_, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(organizationId, undefined) }); + }, + onSuccess: (_, { key, organizationId }) => { + updateOrganizationQueryData(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(); @@ -462,6 +503,32 @@ export function postOrganization() { })); } +export function postOrganizationData() { + 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 }); + return response.ok; + }, + mutationKey: queryKeys.data(undefined), + onError: (_, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(organizationId, undefined) }); + }, + onSuccess: (_, { key, organizationId, value }) => { + updateOrganizationQueryData(queryClient, organizationId, (organization) => ({ + ...organization, + data: { + ...(organization.data ?? {}), + [key]: value + } + })); + } + })); +} + export function postSetBonusOrganization() { const queryClient = useQueryClient(); @@ -579,17 +646,23 @@ export function uploadOrganizationIcon(request: OrganizationIconRequest) { } function updateOrganizationCache(queryClient: QueryClient, id: string | undefined, organization: ViewOrganization) { - queryClient.setQueryData(queryKeys.id(id, 'stats'), organization); - queryClient.setQueryData(queryKeys.id(id, undefined), organization); + updateOrganizationQueryData(queryClient, id, () => organization); +} + +function updateOrganizationQueryData(queryClient: QueryClient, id: string | undefined, updater: (organization: ViewOrganization) => ViewOrganization) { + for (const mode of [undefined, 'stats'] as const) { + queryClient.setQueryData(queryKeys.id(id, mode), (organization) => (organization ? updater(organization) : organization)); + } + queryClient.setQueriesData | undefined>({ queryKey: queryKeys.type }, (response) => { - if (!Array.isArray(response?.data) || !response.data.some((existingOrganization) => existingOrganization.id === organization.id)) { + if (!Array.isArray(response?.data) || !response.data.some((organization) => organization.id === id)) { return response; } return { ...response, - data: response.data.map((existingOrganization) => { - return existingOrganization.id === organization.id ? organization : existingOrganization; + data: response.data.map((organization) => { + return organization.id === id ? updater(organization) : organization; }) }; }); 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..e858598cd0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + getOrganizationBillingInformation, + getOrganizationBillingInformationChanges, + normalizeOrganizationBillingInformationValue, + organizationBillingInformationDataKeys +} 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' } + ]); + }); +}); 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..3d44e25af8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts @@ -0,0 +1,52 @@ +import type { ViewOrganization } from './models'; + +export const organizationBillingInformationDataKeys = { + address: 'billing_address', + name: 'billing_name', + vatId: 'billing_vat_id', + vatNumber: 'billing_vat_number' +} as const; + +export interface OrganizationBillingInformation { + address: string; + name: string; + vatId: string; + vatNumber: string; +} + +export interface OrganizationBillingInformationChange { + key: (typeof organizationBillingInformationDataKeys)[keyof typeof organizationBillingInformationDataKeys]; + value: null | string; +} + +export function getOrganizationBillingInformation(organization?: null | Pick): OrganizationBillingInformation { + 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: OrganizationBillingInformation, + next: OrganizationBillingInformation +): OrganizationBillingInformationChange[] { + return (Object.keys(organizationBillingInformationDataKeys) as (keyof OrganizationBillingInformation)[]).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; +} + +function getOrganizationBillingInformationValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} 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 da1aa1f9f0..711a3d057e 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,26 +1,37 @@ -
+
+ Billing information and invoices + {#if organizationQuery.isLoading} -
+
{:else if organizationQuery.error} {:else} -
+
+
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + 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 +