From c56f35a482934b688224598c1a069511140839c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 23:54:49 +0000 Subject: [PATCH 1/4] Initial plan From 6b5761543b1fae3656b9e3ba69c32ee6a27bcdb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:13:53 +0000 Subject: [PATCH 2/4] feat: add organization billing information settings Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../lib/features/organizations/api.svelte.ts | 89 +++++++++++- .../organizations/billing-information.test.ts | 67 +++++++++ .../organizations/billing-information.ts | 35 +++++ .../[organizationId]/billing/+page.svelte | 137 +++++++++++++++++- .../OrganizationControllerTests.cs | 49 +++++++ 5 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts 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 96d51972c1..9c7cb7e10c 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'; @@ -24,6 +24,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, id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)), ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, @@ -51,6 +52,16 @@ export interface ChangePlanMutationRequest { }; } +export interface DeleteOrganizationDataParams { + key: string; +} + +export interface DeleteOrganizationDataRequest { + route: { + id: string | undefined; + }; +} + export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -133,6 +144,17 @@ export interface PatchOrganizationRequest { }; } +export interface PostOrganizationDataParams { + key: string; + value: string; +} + +export interface PostOrganizationDataRequest { + route: { + id: string | undefined; + }; +} + export interface PostSetBonusOrganizationParams { bonusEvents: number; expires?: Date; @@ -216,6 +238,35 @@ export function deleteOrganization(request: DeleteOrganizationRequest) { })); } +export function deleteOrganizationData(request: DeleteOrganizationDataRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async ({ key }: DeleteOrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.delete(`organizations/${request.route.id}/data/${encodeURIComponent(key)}`); + return response.ok; + }, + mutationKey: queryKeys.data(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: (_, { key }) => { + updateOrganizationQueryData(queryClient, request.route.id, (organization) => { + if (!organization.data) { + return organization; + } + + const data = { ...organization.data }; + delete data[key]; + + return { ...organization, data }; + }); + } + })); +} + export function deleteOrganizationUser(request: DeleteOrganizationUserRequest) { const queryClient = useQueryClient(); return createMutation(() => ({ @@ -424,6 +475,32 @@ export function postOrganization() { })); } +export function postOrganizationData(request: PostOrganizationDataRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async ({ key, value }: PostOrganizationDataParams) => { + const client = useFetchClient(); + const response = await client.post(`organizations/${request.route.id}/data/${encodeURIComponent(key)}`, { value }); + return response.ok; + }, + mutationKey: queryKeys.data(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: (_, { key, value }) => { + updateOrganizationQueryData(queryClient, request.route.id, (organization) => ({ + ...organization, + data: { + ...(organization.data ?? {}), + [key]: value + } + })); + } + })); +} + export function postSetBonusOrganization() { const queryClient = useQueryClient(); @@ -521,3 +598,13 @@ export function setOrganizationFeature(request: SetOrganizationFeatureRequest) { } })); } + +function updateOrganizationQueryData( + queryClient: ReturnType, + 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)); + } +} 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..dccdd3395c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { getOrganizationBillingInformation, 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(); + }); +}); 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..0be8fee37b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/billing-information.ts @@ -0,0 +1,35 @@ +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 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 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/routes/(app)/organization/[organizationId]/billing/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte index da1aa1f9f0..17cf0c8a18 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 @@ -2,20 +2,29 @@ import { resolve } from '$app/paths'; import ErrorMessage from '$comp/error-message.svelte'; import DateTime from '$comp/formatters/date-time.svelte'; - import { A } from '$comp/typography'; + import { A, H4, Large, Muted } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; + import { Input } from '$comp/ui/input'; import { Skeleton } from '$comp/ui/skeleton'; import * as Table from '$comp/ui/table'; + import { Textarea } from '$comp/ui/textarea'; import { env } from '$env/dynamic/public'; import { ChangePlanDialog } from '$features/billing'; - import { getInvoicesQuery, getOrganizationQuery } from '$features/organizations/api.svelte'; + import { deleteOrganizationData, getInvoicesQuery, getOrganizationQuery, postOrganizationData } from '$features/organizations/api.svelte'; + import { + getOrganizationBillingInformation, + normalizeOrganizationBillingInformationValue, + organizationBillingInformationDataKeys + } from '$features/organizations/billing-information'; import { organization } from '$features/organizations/context.svelte'; import GlobalUser from '$features/users/components/global-user.svelte'; import CreditCard from '@lucide/svelte/icons/credit-card'; import File from '@lucide/svelte/icons/file'; import MoreHorizontal from '@lucide/svelte/icons/more-horizontal'; import { queryParamsState } from 'kit-query-params'; + import { toast } from 'svelte-sonner'; + import { debounce } from 'throttle-debounce'; const organizationQuery = getOrganizationQuery({ route: { @@ -33,7 +42,24 @@ } }); + const updateOrganizationData = postOrganizationData({ + route: { + get id() { + return organization.current; + } + } + }); + + const removeOrganizationData = deleteOrganizationData({ + route: { + get id() { + return organization.current; + } + } + }); + const canChangePlan = $derived(organizationQuery.isSuccess && !!env.PUBLIC_STRIPE_PUBLISHABLE_KEY); + const billingInformation = $derived(getOrganizationBillingInformation(organizationQuery.data)); const params = queryParamsState({ default: { changePlan: false }, @@ -42,6 +68,16 @@ }); let changePlanDialogOpen = $state(!!params.changePlan); + let toastId = $state(); + let billingName = $state(''); + let billingAddress = $state(''); + let billingVatNumber = $state(''); + let billingVatId = $state(''); + + const billingNameIsDirty = $derived(billingName !== billingInformation.name); + const billingAddressIsDirty = $derived(billingAddress !== billingInformation.address); + const billingVatNumberIsDirty = $derived(billingVatNumber !== billingInformation.vatNumber); + const billingVatIdIsDirty = $derived(billingVatId !== billingInformation.vatId); function handleChangePlan() { changePlanDialogOpen = true; @@ -60,6 +96,69 @@ function handleViewStripeInvoice(invoiceId: string) { window.open(`https://manage.stripe.com/invoices/in_${encodeURIComponent(invoiceId)}`, '_blank'); } + + async function updateOrRemoveOrganizationBillingInformation(key: string, value: string, label: string) { + toast.dismiss(toastId); + + try { + const normalizedValue = normalizeOrganizationBillingInformationValue(value); + if (normalizedValue) { + await updateOrganizationData.mutateAsync({ key, value: normalizedValue }); + } else { + await removeOrganizationData.mutateAsync({ key }); + } + + toastId = toast.success(`Successfully updated ${label}.`); + } catch { + toastId = toast.error(`Error updating ${label}. Please try again.`); + } + } + + async function saveBillingName() { + if (!billingNameIsDirty) { + return; + } + + await updateOrRemoveOrganizationBillingInformation(organizationBillingInformationDataKeys.name, billingName, 'billing name'); + } + + async function saveBillingAddress() { + if (!billingAddressIsDirty) { + return; + } + + await updateOrRemoveOrganizationBillingInformation(organizationBillingInformationDataKeys.address, billingAddress, 'billing address'); + } + + async function saveBillingVatNumber() { + if (!billingVatNumberIsDirty) { + return; + } + + await updateOrRemoveOrganizationBillingInformation(organizationBillingInformationDataKeys.vatNumber, billingVatNumber, 'VAT number'); + } + + async function saveBillingVatId() { + if (!billingVatIdIsDirty) { + return; + } + + await updateOrRemoveOrganizationBillingInformation(organizationBillingInformationDataKeys.vatId, billingVatId, 'VAT ID'); + } + + const debouncedSaveBillingName = debounce(500, saveBillingName); + const debouncedSaveBillingAddress = debounce(500, saveBillingAddress); + const debouncedSaveBillingVatNumber = debounce(500, saveBillingVatNumber); + const debouncedSaveBillingVatId = debounce(500, saveBillingVatId); + + $effect(() => { + if (organizationQuery.dataUpdatedAt) { + billingName = billingInformation.name; + billingAddress = billingInformation.address; + billingVatNumber = billingInformation.vatNumber; + billingVatId = billingInformation.vatId; + } + });
@@ -72,6 +171,40 @@ {:else}
+
+
+

Billing information

+ Add the details that should appear on billing documents for this organization. +
+ +
+
+ Organization name + +
+ +
+ VAT ID + +
+ +
+ Organization address +