Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c8b96b9
Initial plan
Copilot May 31, 2026
58d6703
feat: add organization billing information settings
Copilot Jun 1, 2026
f0cf8cb
fix: refine billing settings autosave
Copilot Jun 1, 2026
23a867d
chore: polish billing settings labels
Copilot Jun 1, 2026
ec3e7b7
fix: align billing settings form patterns
ejsmith Jun 28, 2026
ad94cf1
fix: sort client module exports
ejsmith Jun 29, 2026
5a4544a
Harden Svelte billing information autosave
niemyjski Jul 10, 2026
6036bf4
fix: harden billing information autosave
niemyjski Jul 10, 2026
67dace3
fix: harden billing information autosave
niemyjski Jul 26, 2026
7e6e122
test: add billing invoice stories and screenshots
niemyjski Jul 26, 2026
0a6797f
fix: prevent stale billing information reads
niemyjski Jul 30, 2026
abc8e3f
style: order billing data mutation hooks
niemyjski Jul 30, 2026
452c05a
test: align billing invoice fixtures
niemyjski Aug 5, 2026
450c20d
Fix billing client lint after base merge
niemyjski Aug 19, 2026
30686b4
Fix billing client lint after base merge
niemyjski Aug 19, 2026
356c79f
Preserve billing edits across navigation and cache refreshes
niemyjski Sep 16, 2026
b3ce7c2
Merge remote-tracking branch 'origin/main' into HEAD
niemyjski Sep 22, 2026
5f4c592
Fix website Deno version for current Lume
niemyjski Sep 22, 2026
a63ee16
Preserve pending billing queries during organization data saves
niemyjski Oct 7, 2026
1f99c1b
Merge remote-tracking branch 'origin/main' into HEAD
niemyjski Oct 13, 2026
2268f5b
Fix billing browser test for root application route
niemyjski Oct 13, 2026
49453ce
Merge main and adapt billing settings to current frontend imports
niemyjski Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/docs/img/pr-2275/invoices-storybook.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
128 changes: 128 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/billing-information.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { 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<void> | 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(`/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<void>();
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$`));
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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<unknown>;
onSettled: () => Promise<void>;
onSuccess: (result: boolean, variables: { key: string; organizationId: string; value: string }) => Promise<void>;
}[]
}));

vi.mock('#features/auth/index.svelte.ts', () => ({
accessToken: { current: 'token' }
}));

vi.mock('#features/shared/api/api.svelte.ts', () => ({
fetchApiJson: vi.fn()
}));

vi.mock('#features/users/api.svelte.ts', () => ({
queryKeys: { me: () => ['User', 'me'] }
}));

vi.mock('@foundatiofx/fetchclient', () => ({
useFetchClient: vi.fn()
}));

vi.mock('@tanstack/svelte-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/svelte-query')>()),
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.each([
['save', postOrganizationDataMutation],
['clear', deleteOrganizationDataMutation]
] as const)('keeps pending invoices and plans while cancelling stale organization reads on %s', async (_, createDataMutation) => {
const organizationId = 'organization-id';
createDataMutation();
const mutation = mutationOptions[0]!;

for (const readTiming of ['before', 'during']) {
if (readTiming === 'during') {
await mutation.onMutate({ organizationId });
}

const reads = [
{ cancelled: true, queryKey: queryKeys.id(organizationId, undefined) },
{ cancelled: true, queryKey: queryKeys.id(organizationId, 'stats') },
{ cancelled: false, queryKey: queryKeys.invoices(organizationId) },
{ cancelled: false, queryKey: queryKeys.plans(organizationId) },
{ cancelled: false, queryKey: queryKeys.id('other-organization-id', undefined) }
].map(({ cancelled, queryKey }) => {
const response = { data: [{ id: 'synthetic-result' }] };
const pendingRead = Promise.withResolvers<typeof response>();
const aborted = vi.fn();
const queryFn = vi.fn(({ signal }: { signal: AbortSignal }) => {
signal.addEventListener('abort', aborted);
return pendingRead.promise;
});
const observer = new QueryObserver(queryClient, { queryFn, queryKey });
const unsubscribe = observer.subscribe(() => {});
return { aborted, cancelled, observer, pendingRead, queryFn, response, unsubscribe };
});

try {
if (readTiming === 'before') {
await mutation.onMutate({ organizationId });
}
await mutation.onSuccess(true, { key: 'billing_name', organizationId, value: 'Saved name' });
await mutation.onSettled();

for (const read of reads) {
expect(read.aborted).toHaveBeenCalledTimes(read.cancelled ? 1 : 0);
if (!read.cancelled) {
expect(read.observer.getCurrentResult().isFetching).toBe(true);
read.pendingRead.resolve(read.response);
await vi.waitFor(() => expect(read.observer.getCurrentResult().status).toBe('success'));
expect(read.observer.getCurrentResult().data).toEqual(read.response);
expect(read.queryFn).toHaveBeenCalledOnce();
}
}
} finally {
for (const read of reads) {
read.unsubscribe();
read.pendingRead.resolve(read.response);
}
queryClient.clear();
}
}
});

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<typeof oldResponse>();
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<typeof response>();
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);
}
});
});
Loading
Loading