Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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.
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -26,6 +27,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)),
Expand Down Expand Up @@ -130,6 +132,11 @@ export interface GetPlansRequest {
};
}

export interface OrganizationDataParams {
key: string;
organizationId: string;
}

export interface OrganizationIconRequest {
route: {
id: string | undefined;
Expand All @@ -142,6 +149,10 @@ export interface PatchOrganizationRequest {
};
}

export interface PostOrganizationDataParams extends OrganizationDataParams {
value: string;
}

export interface PostSetBonusOrganizationParams {
bonusEvents: number;
expires?: Date;
Expand Down Expand Up @@ -225,6 +236,35 @@ export function deleteOrganization(request: DeleteOrganizationRequest) {
}));
}

export function deleteOrganizationDataMutation() {
const queryClient = useQueryClient();

return createMutation<boolean, ProblemDetails, OrganizationDataParams>(() => ({
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 }) => {
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();

Expand Down Expand Up @@ -462,6 +502,34 @@ export function postOrganization() {
}));
}

export function postOrganizationDataMutation() {
const queryClient = useQueryClient();

return createMutation<boolean, ProblemDetails, PostOrganizationDataParams>(() => ({
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 }) => {
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();

Expand Down Expand Up @@ -579,17 +647,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<undefined | ViewOrganization>(queryKeys.id(id, mode), (organization) => (organization ? updater(organization) : organization));
}

queryClient.setQueriesData<FetchClientResponse<ViewOrganization[]> | 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;
})
};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>();
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<void>();
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);
});
});
Original file line number Diff line number Diff line change
@@ -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<unknown>;
set: (key: OrganizationBillingInformationChange['key'], value: string) => Promise<unknown>;
}

export function createSerializedBillingInformationSave(save: (organizationId: string) => Promise<void>) {
let pendingSave = Promise.resolve();

return (organizationId: string): Promise<void> => {
const nextSave = pendingSave.then(() => save(organizationId));
pendingSave = nextSave.catch(() => undefined);
return nextSave;
};
}

export function getOrganizationBillingInformation(organization?: null | Pick<ViewOrganization, 'data'>): 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<void> {
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 : '';
}
Loading
Loading