feat: add billing and organization workflows
This commit is contained in:
@@ -1,30 +1,74 @@
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
import { message, superValidate } from 'sveltekit-superforms/server';
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { message, setError, superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { db } from '$lib/server/db';
|
||||
import { contracts, clients } from '$lib/server/db/schema';
|
||||
import { contracts, rooms, services } from '$lib/server/db/schema';
|
||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
|
||||
import {
|
||||
contractCreateSchema,
|
||||
contractEditSchema,
|
||||
contractTransitionSchema
|
||||
} from '$lib/schemas/contracts.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function loadOptions(organizationId: string) {
|
||||
const clientRows = await db
|
||||
.select({ id: clients.id, name: clients.name })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
|
||||
.orderBy(asc(clients.name));
|
||||
const [roomRows, serviceRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: rooms.id,
|
||||
name: rooms.name,
|
||||
type: rooms.type,
|
||||
pricePerMonthGbp: rooms.pricePerMonthGbp
|
||||
})
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.organizationId, organizationId),
|
||||
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
||||
isNull(rooms.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(rooms.name)),
|
||||
db
|
||||
.select({ id: services.id, name: services.name, priceGbp: services.priceGbp })
|
||||
.from(services)
|
||||
.where(and(eq(services.organizationId, organizationId), isNull(services.archivedAt)))
|
||||
.orderBy(asc(services.name))
|
||||
]);
|
||||
|
||||
return {
|
||||
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
|
||||
rooms: [
|
||||
{ label: 'No room', value: '' },
|
||||
...roomRows.map((room) => ({
|
||||
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
|
||||
value: room.id,
|
||||
licenseFeeGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) : 0,
|
||||
depositGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) * 2 : 0
|
||||
}))
|
||||
],
|
||||
services: [
|
||||
{ label: 'No service', value: '' },
|
||||
...serviceRows.map((service) => ({
|
||||
label: service.name,
|
||||
value: service.id,
|
||||
licenseFeeGbp: service.priceGbp
|
||||
}))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const records = await db
|
||||
.select()
|
||||
.select({
|
||||
contract: contracts,
|
||||
roomName: rooms.name,
|
||||
serviceName: services.name
|
||||
})
|
||||
.from(contracts)
|
||||
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
|
||||
.leftJoin(services, eq(contracts.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.clientId, params.id),
|
||||
@@ -32,31 +76,91 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
isNull(contracts.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(contracts.title));
|
||||
.orderBy(asc(contracts.startDate));
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
...record,
|
||||
...record.contract,
|
||||
roomName: record.roomName,
|
||||
serviceName: record.serviceName,
|
||||
formValues: {
|
||||
id: record.id,
|
||||
clientId: record.clientId ?? '',
|
||||
title: record.title ?? '',
|
||||
startDate: record.startDate ?? '',
|
||||
endDate: record.endDate ?? '',
|
||||
status: record.status ?? '',
|
||||
valueGbp: record.valueGbp ?? '',
|
||||
notes: record.notes ?? ''
|
||||
id: record.contract.id,
|
||||
clientId: record.contract.clientId ?? '',
|
||||
roomId: record.contract.roomId ?? '',
|
||||
serviceId: record.contract.serviceId ?? '',
|
||||
licenseFeeGbp: record.contract.licenseFeeGbp,
|
||||
depositGbp: record.contract.depositGbp,
|
||||
startDate: record.contract.startDate ?? '',
|
||||
endDate: record.contract.endDate ?? '',
|
||||
notes: record.contract.notes ?? ''
|
||||
}
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
|
||||
id: 'contracts-create'
|
||||
id: 'contracts-create',
|
||||
errors: false
|
||||
}),
|
||||
editForm: await superValidate(zod4(contractEditSchema), { id: 'contracts-edit' }),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
|
||||
editForm: await superValidate(zod4(contractEditSchema), {
|
||||
id: 'contracts-edit',
|
||||
errors: false
|
||||
}),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'contracts-archive',
|
||||
errors: false
|
||||
}),
|
||||
transitionForm: await superValidate(zod4(contractTransitionSchema), {
|
||||
id: 'contracts-transition',
|
||||
errors: false
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
async function selectionError(
|
||||
organizationId: string,
|
||||
roomId: string,
|
||||
serviceId: string
|
||||
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
|
||||
if (roomId) {
|
||||
const [room] = await db
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, roomId),
|
||||
eq(rooms.organizationId, organizationId),
|
||||
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
||||
isNull(rooms.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!room) {
|
||||
return {
|
||||
field: 'roomId',
|
||||
message: 'Select a valid private office or coworking desk.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceId) {
|
||||
const [service] = await db
|
||||
.select({ id: services.id })
|
||||
.from(services)
|
||||
.where(
|
||||
and(
|
||||
eq(services.id, serviceId),
|
||||
eq(services.organizationId, organizationId),
|
||||
isNull(services.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!service) return { field: 'serviceId', message: 'Select a valid service.' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
@@ -67,17 +171,25 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
const invalidSelection = await selectionError(
|
||||
activeOrganizationId,
|
||||
form.data.roomId,
|
||||
form.data.serviceId
|
||||
);
|
||||
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
|
||||
|
||||
try {
|
||||
await db.insert(contracts).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: activeOrganizationId,
|
||||
clientId: form.data.clientId,
|
||||
title: form.data.title,
|
||||
roomId: form.data.roomId || null,
|
||||
serviceId: form.data.serviceId || null,
|
||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||
depositGbp: form.data.depositGbp,
|
||||
startDate: form.data.startDate,
|
||||
endDate: form.data.endDate || null,
|
||||
status: form.data.status,
|
||||
valueGbp: form.data.valueGbp,
|
||||
endDate: form.data.endDate,
|
||||
status: 'draft',
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
@@ -98,17 +210,24 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
const invalidSelection = await selectionError(
|
||||
activeOrganizationId,
|
||||
form.data.roomId,
|
||||
form.data.serviceId
|
||||
);
|
||||
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(contracts)
|
||||
.set({
|
||||
clientId: form.data.clientId,
|
||||
title: form.data.title,
|
||||
roomId: form.data.roomId || null,
|
||||
serviceId: form.data.serviceId || null,
|
||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||
depositGbp: form.data.depositGbp,
|
||||
startDate: form.data.startDate,
|
||||
endDate: form.data.endDate || null,
|
||||
status: form.data.status,
|
||||
valueGbp: form.data.valueGbp,
|
||||
endDate: form.data.endDate,
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
@@ -126,6 +245,50 @@ export const actions: Actions = {
|
||||
return message(form, 'Contract updated.');
|
||||
},
|
||||
|
||||
transition: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const form = await superValidate(await request.formData(), zod4(contractTransitionSchema), {
|
||||
id: 'contracts-transition'
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
|
||||
|
||||
const [contract] = await db
|
||||
.select({ status: contracts.status })
|
||||
.from(contracts)
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.id, form.data.id),
|
||||
eq(contracts.clientId, params.id),
|
||||
eq(contracts.organizationId, activeOrganizationId),
|
||||
isNull(contracts.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const allowedTransitions: Record<string, readonly string[]> = {
|
||||
draft: ['active', 'void'],
|
||||
active: ['expired']
|
||||
};
|
||||
|
||||
if (!contract || !allowedTransitions[contract.status]?.includes(form.data.targetStatus)) {
|
||||
return message(form, 'That contract status change is not allowed.', { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(contracts)
|
||||
.set({ status: form.data.targetStatus, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.id, form.data.id),
|
||||
eq(contracts.clientId, params.id),
|
||||
eq(contracts.organizationId, activeOrganizationId)
|
||||
)
|
||||
);
|
||||
|
||||
return message(form, `Contract marked ${form.data.targetStatus}.`);
|
||||
},
|
||||
|
||||
archive: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
||||
|
||||
Reference in New Issue
Block a user