| import { |
| cancelScheduledInvoiceSchema, |
| createInvoiceSchema, |
| deleteInvoiceSchema, |
| draftInvoiceSchema, |
| duplicateInvoiceSchema, |
| getInvoiceByIdSchema, |
| getInvoiceByTokenSchema, |
| getInvoicesSchema, |
| invoiceSummarySchema, |
| remindInvoiceSchema, |
| searchInvoiceNumberSchema, |
| updateInvoiceSchema, |
| updateScheduledInvoiceSchema, |
| } from "@api/schemas/invoice"; |
| import { |
| createTRPCRouter, |
| protectedProcedure, |
| publicProcedure, |
| } from "@api/trpc/init"; |
| import { parseInputValue } from "@api/utils/parse"; |
| import { UTCDate } from "@date-fns/utc"; |
| import { |
| deleteInvoice, |
| draftInvoice, |
| duplicateInvoice, |
| getAverageDaysToPayment, |
| getAverageInvoiceSize, |
| getCustomerById, |
| getInactiveClientsCount, |
| getInvoiceById, |
| getInvoiceSummary, |
| getInvoices, |
| getInvoiceTemplate, |
| getMostActiveClient, |
| getNewCustomersCount, |
| getNextInvoiceNumber, |
| getPaymentStatus, |
| getTeamById, |
| getTopRevenueClient, |
| getTrackerProjectById, |
| getTrackerRecordsByRange, |
| getUserById, |
| searchInvoiceNumber, |
| updateInvoice, |
| } from "@midday/db/queries"; |
| import { DEFAULT_TEMPLATE } from "@midday/invoice"; |
| import { verify } from "@midday/invoice/token"; |
| import { transformCustomerToContent } from "@midday/invoice/utils"; |
| import { decodeJobId, getQueue, triggerJob } from "@midday/job-client"; |
| import { createLoggerWithContext } from "@midday/logger"; |
| import { TRPCError } from "@trpc/server"; |
| import { addDays, format, parseISO } from "date-fns"; |
| import { v4 as uuidv4 } from "uuid"; |
| import { z } from "zod"; |
|
|
| const logger = createLoggerWithContext("trpc:invoice"); |
|
|
| |
| const defaultTemplate = DEFAULT_TEMPLATE; |
|
|
| export const invoiceRouter = createTRPCRouter({ |
| get: protectedProcedure |
| .input(getInvoicesSchema.optional()) |
| .query(async ({ input, ctx: { db, teamId } }) => { |
| return getInvoices(db, { |
| teamId: teamId!, |
| ...input, |
| }); |
| }), |
|
|
| getById: protectedProcedure |
| .input(getInvoiceByIdSchema) |
| .query(async ({ input, ctx: { db, teamId } }) => { |
| return getInvoiceById(db, { |
| id: input.id, |
| teamId: teamId!, |
| }); |
| }), |
|
|
| getInvoiceByToken: publicProcedure |
| .input(getInvoiceByTokenSchema) |
| .query(async ({ input, ctx: { db } }) => { |
| const { id } = (await verify(decodeURIComponent(input.token))) as { |
| id: string; |
| }; |
|
|
| if (!id) { |
| throw new TRPCError({ code: "NOT_FOUND" }); |
| } |
|
|
| return getInvoiceById(db, { |
| id, |
| }); |
| }), |
|
|
| paymentStatus: protectedProcedure.query(async ({ ctx: { db, teamId } }) => { |
| return getPaymentStatus(db, teamId!); |
| }), |
|
|
| searchInvoiceNumber: protectedProcedure |
| .input(searchInvoiceNumberSchema) |
| .query(async ({ input, ctx: { db, teamId } }) => { |
| return searchInvoiceNumber(db, { |
| teamId: teamId!, |
| query: input.query, |
| }); |
| }), |
|
|
| invoiceSummary: protectedProcedure |
| .input(invoiceSummarySchema.optional()) |
| .query(async ({ ctx: { db, teamId }, input }) => { |
| return getInvoiceSummary(db, { |
| teamId: teamId!, |
| statuses: input?.statuses, |
| }); |
| }), |
|
|
| createFromTracker: protectedProcedure |
| .input( |
| z.object({ |
| projectId: z.string().uuid(), |
| dateFrom: z.string(), |
| dateTo: z.string(), |
| }), |
| ) |
| .mutation(async ({ ctx: { db, teamId, session }, input }) => { |
| const { projectId, dateFrom, dateTo } = input; |
|
|
| |
| const [projectData, trackerData] = await Promise.all([ |
| getTrackerProjectById(db, { id: projectId, teamId: teamId! }), |
| getTrackerRecordsByRange(db, { |
| teamId: teamId!, |
| projectId, |
| from: dateFrom, |
| to: dateTo, |
| }), |
| ]); |
|
|
| if (!projectData) { |
| throw new TRPCError({ |
| code: "NOT_FOUND", |
| message: "PROJECT_NOT_FOUND", |
| }); |
| } |
|
|
| |
| if (!projectData.billable) { |
| throw new TRPCError({ |
| code: "BAD_REQUEST", |
| message: "PROJECT_NOT_BILLABLE", |
| }); |
| } |
|
|
| |
| if (!projectData.rate || projectData.rate <= 0) { |
| throw new TRPCError({ |
| code: "BAD_REQUEST", |
| message: "PROJECT_NO_RATE", |
| }); |
| } |
|
|
| |
| const allEntries = Object.values(trackerData.result || {}).flat(); |
| const totalDuration = allEntries.reduce( |
| (sum, entry) => sum + (entry.duration || 0), |
| 0, |
| ); |
| const totalHours = Math.round((totalDuration / 3600) * 100) / 100; |
|
|
| if (totalHours === 0) { |
| throw new TRPCError({ |
| code: "BAD_REQUEST", |
| message: "NO_TRACKED_HOURS", |
| }); |
| } |
|
|
| |
| const [nextInvoiceNumber, template, team, fullCustomer, user] = |
| await Promise.all([ |
| getNextInvoiceNumber(db, teamId!), |
| getInvoiceTemplate(db, teamId!), |
| getTeamById(db, teamId!), |
| projectData.customerId |
| ? getCustomerById(db, { |
| id: projectData.customerId, |
| teamId: teamId!, |
| }) |
| : null, |
| getUserById(db, session?.user.id!), |
| ]); |
|
|
| const invoiceId = uuidv4(); |
| const currency = projectData.currency || team?.baseCurrency || "USD"; |
| const amount = totalHours * Number(projectData.rate); |
|
|
| |
| const userDateFormat = |
| template?.dateFormat ?? user?.dateFormat ?? defaultTemplate.dateFormat; |
|
|
| |
| |
| const formattedDateFrom = format(parseISO(dateFrom), userDateFormat); |
| const formattedDateTo = format(parseISO(dateTo), userDateFormat); |
| const dateRangeDescription = `${projectData.name} (${formattedDateFrom} - ${formattedDateTo})`; |
|
|
| |
| const templateData = { |
| ...defaultTemplate, |
| currency: currency.toUpperCase(), |
| ...(template |
| ? Object.fromEntries( |
| Object.entries(template).map(([key, value]) => [ |
| key, |
| value === null ? undefined : value, |
| ]), |
| ) |
| : {}), |
| size: (template?.size === "a4" || template?.size === "letter" |
| ? template.size |
| : defaultTemplate.size) as "a4" | "letter", |
| deliveryType: (template?.deliveryType === "create" || |
| template?.deliveryType === "create_and_send" |
| ? template.deliveryType |
| : defaultTemplate.deliveryType) as |
| | "create" |
| | "create_and_send" |
| | undefined, |
| }; |
|
|
| const invoiceData = { |
| id: invoiceId, |
| teamId: teamId!, |
| userId: session?.user.id!, |
| customerId: projectData.customerId, |
| customerName: fullCustomer?.name, |
| invoiceNumber: nextInvoiceNumber, |
| currency: currency.toUpperCase(), |
| amount, |
| lineItems: [ |
| { |
| name: dateRangeDescription, |
| quantity: totalHours, |
| price: Number(projectData.rate), |
| vat: 0, |
| }, |
| ], |
| issueDate: new Date().toISOString(), |
| dueDate: addDays( |
| new Date(), |
| template?.paymentTermsDays ?? 30, |
| ).toISOString(), |
| template: templateData, |
| fromDetails: (template?.fromDetails || null) as string | null, |
| paymentDetails: (template?.paymentDetails || null) as string | null, |
| customerDetails: fullCustomer |
| ? JSON.stringify(transformCustomerToContent(fullCustomer)) |
| : null, |
| noteDetails: null, |
| topBlock: null, |
| bottomBlock: null, |
| vat: null, |
| tax: null, |
| discount: null, |
| subtotal: null, |
| }; |
|
|
| return draftInvoice(db, invoiceData); |
| }), |
|
|
| defaultSettings: protectedProcedure.query( |
| async ({ ctx: { db, teamId, session, geo } }) => { |
| |
| const [nextInvoiceNumber, template, team, user] = await Promise.all([ |
| getNextInvoiceNumber(db, teamId!), |
| getInvoiceTemplate(db, teamId!), |
| getTeamById(db, teamId!), |
| getUserById(db, session?.user.id!), |
| ]); |
|
|
| const locale = user?.locale ?? geo?.locale ?? "en"; |
| const timezone = user?.timezone ?? geo?.timezone ?? "America/New_York"; |
| const currency = |
| template?.currency ?? team?.baseCurrency ?? defaultTemplate.currency; |
| const dateFormat = |
| template?.dateFormat ?? user?.dateFormat ?? defaultTemplate.dateFormat; |
| const logoUrl = template?.logoUrl ?? defaultTemplate.logoUrl; |
| const countryCode = geo?.country ?? "US"; |
|
|
| |
| const size = ["US", "CA"].includes(countryCode) ? "letter" : "a4"; |
|
|
| |
| const includeTax = ["US", "CA", "AU", "NZ", "SG", "MY", "IN"].includes( |
| countryCode, |
| ); |
|
|
| const savedTemplate = { |
| id: template?.id, |
| name: template?.name ?? "Default", |
| isDefault: template?.isDefault ?? true, |
| title: template?.title ?? defaultTemplate.title, |
| logoUrl, |
| currency, |
| size: template?.size ?? defaultTemplate.size, |
| includeTax: template?.includeTax ?? includeTax, |
| includeVat: template?.includeVat ?? !includeTax, |
| includeDiscount: |
| template?.includeDiscount ?? defaultTemplate.includeDiscount, |
| includeDecimals: |
| template?.includeDecimals ?? defaultTemplate.includeDecimals, |
| includeUnits: template?.includeUnits ?? defaultTemplate.includeUnits, |
| includeQr: template?.includeQr ?? defaultTemplate.includeQr, |
| includeLineItemTax: |
| template?.includeLineItemTax ?? defaultTemplate.includeLineItemTax, |
| lineItemTaxLabel: |
| template?.lineItemTaxLabel ?? defaultTemplate.lineItemTaxLabel, |
| includePdf: template?.includePdf ?? defaultTemplate.includePdf, |
| sendCopy: template?.sendCopy ?? defaultTemplate.sendCopy, |
| customerLabel: template?.customerLabel ?? defaultTemplate.customerLabel, |
| fromLabel: template?.fromLabel ?? defaultTemplate.fromLabel, |
| invoiceNoLabel: |
| template?.invoiceNoLabel ?? defaultTemplate.invoiceNoLabel, |
| subtotalLabel: template?.subtotalLabel ?? defaultTemplate.subtotalLabel, |
| issueDateLabel: |
| template?.issueDateLabel ?? defaultTemplate.issueDateLabel, |
| totalSummaryLabel: |
| template?.totalSummaryLabel ?? defaultTemplate.totalSummaryLabel, |
| dueDateLabel: template?.dueDateLabel ?? defaultTemplate.dueDateLabel, |
| discountLabel: template?.discountLabel ?? defaultTemplate.discountLabel, |
| descriptionLabel: |
| template?.descriptionLabel ?? defaultTemplate.descriptionLabel, |
| priceLabel: template?.priceLabel ?? defaultTemplate.priceLabel, |
| quantityLabel: template?.quantityLabel ?? defaultTemplate.quantityLabel, |
| totalLabel: template?.totalLabel ?? defaultTemplate.totalLabel, |
| vatLabel: template?.vatLabel ?? defaultTemplate.vatLabel, |
| taxLabel: template?.taxLabel ?? defaultTemplate.taxLabel, |
| paymentLabel: template?.paymentLabel ?? defaultTemplate.paymentLabel, |
| noteLabel: template?.noteLabel ?? defaultTemplate.noteLabel, |
| dateFormat, |
| deliveryType: template?.deliveryType ?? defaultTemplate.deliveryType, |
| taxRate: template?.taxRate ?? defaultTemplate.taxRate, |
| vatRate: template?.vatRate ?? defaultTemplate.vatRate, |
| fromDetails: template?.fromDetails ?? defaultTemplate.fromDetails, |
| paymentDetails: |
| template?.paymentDetails ?? defaultTemplate.paymentDetails, |
| noteDetails: template?.noteDetails ?? defaultTemplate.noteDetails, |
| timezone, |
| locale, |
| paymentEnabled: |
| template?.paymentEnabled ?? defaultTemplate.paymentEnabled, |
| paymentTermsDays: template?.paymentTermsDays ?? 30, |
| emailSubject: template?.emailSubject ?? null, |
| emailHeading: template?.emailHeading ?? null, |
| emailBody: template?.emailBody ?? null, |
| emailButtonText: template?.emailButtonText ?? null, |
| }; |
|
|
| |
| const paymentTermsDays = savedTemplate.paymentTermsDays ?? 30; |
|
|
| return { |
| |
| id: uuidv4(), |
| currency, |
| status: "draft", |
| size, |
| includeTax: savedTemplate?.includeTax ?? includeTax, |
| includeVat: savedTemplate?.includeVat ?? !includeTax, |
| includeDiscount: false, |
| includeDecimals: false, |
| includePdf: false, |
| sendCopy: false, |
| includeUnits: false, |
| includeQr: true, |
| invoiceNumber: nextInvoiceNumber, |
| timezone, |
| locale, |
| fromDetails: savedTemplate.fromDetails, |
| paymentDetails: savedTemplate.paymentDetails, |
| customerDetails: undefined, |
| noteDetails: savedTemplate.noteDetails, |
| customerId: undefined, |
| issueDate: new UTCDate().toISOString(), |
| dueDate: addDays(new UTCDate(), paymentTermsDays).toISOString(), |
| lineItems: [{ name: "", quantity: 0, price: 0, vat: 0 }], |
| tax: undefined, |
| token: undefined, |
| discount: undefined, |
| subtotal: undefined, |
| topBlock: undefined, |
| bottomBlock: undefined, |
| amount: undefined, |
| customerName: undefined, |
| logoUrl: undefined, |
| vat: undefined, |
| template: savedTemplate, |
| }; |
| }, |
| ), |
|
|
| update: protectedProcedure |
| .input(updateInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId, session } }) => { |
| return updateInvoice(db, { |
| ...input, |
| teamId: teamId!, |
| userId: session.user.id, |
| }); |
| }), |
|
|
| delete: protectedProcedure |
| .input(deleteInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId } }) => { |
| return deleteInvoice(db, { |
| id: input.id, |
| teamId: teamId!, |
| }); |
| }), |
|
|
| draft: protectedProcedure |
| .input(draftInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId, session } }) => { |
| |
| const invoiceNumber = |
| input.invoiceNumber || (await getNextInvoiceNumber(db, teamId!)); |
|
|
| return draftInvoice(db, { |
| ...input, |
| invoiceNumber, |
| teamId: teamId!, |
| userId: session?.user.id!, |
| paymentDetails: parseInputValue(input.paymentDetails), |
| fromDetails: parseInputValue(input.fromDetails), |
| customerDetails: parseInputValue(input.customerDetails), |
| noteDetails: parseInputValue(input.noteDetails), |
| }); |
| }), |
|
|
| create: protectedProcedure |
| .input(createInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId, session } }) => { |
| |
| if (input.deliveryType === "scheduled") { |
| if (!input.scheduledAt) { |
| throw new TRPCError({ |
| code: "BAD_REQUEST", |
| message: "scheduledAt is required for scheduled delivery", |
| }); |
| } |
|
|
| |
| const scheduledDate = new Date(input.scheduledAt); |
| const now = new Date(); |
|
|
| if (scheduledDate <= now) { |
| throw new TRPCError({ |
| code: "BAD_REQUEST", |
| message: "scheduledAt must be in the future", |
| }); |
| } |
|
|
| |
| const existingInvoice = await getInvoiceById(db, { |
| id: input.id, |
| teamId: teamId!, |
| }); |
|
|
| let scheduledJobId: string | null = null; |
|
|
| try { |
| |
| const delayMs = scheduledDate.getTime() - now.getTime(); |
|
|
| |
| const scheduledRun = await triggerJob( |
| "schedule-invoice", |
| { |
| invoiceId: input.id, |
| }, |
| "invoices", |
| { |
| delay: delayMs, |
| }, |
| ); |
|
|
| if (!scheduledRun?.id) { |
| throw new Error( |
| "Failed to create scheduled job - no job ID returned", |
| ); |
| } |
|
|
| scheduledJobId = scheduledRun.id; |
|
|
| |
| |
| if (existingInvoice?.scheduledJobId) { |
| const queue = getQueue("invoices"); |
| |
| const { jobId: rawJobId } = decodeJobId( |
| existingInvoice.scheduledJobId, |
| ); |
| const existingJob = await queue.getJob(rawJobId); |
| if (existingJob) { |
| await existingJob.remove().catch((err) => { |
| |
| |
| logger.error("Failed to remove old scheduled job", { |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| }); |
| } |
| } |
| } catch (error) { |
| throw new TRPCError({ |
| code: "SERVICE_UNAVAILABLE", |
| cause: error, |
| }); |
| } |
|
|
| |
| if (!scheduledJobId) { |
| throw new TRPCError({ |
| code: "SERVICE_UNAVAILABLE", |
| }); |
| } |
|
|
| |
| const data = await updateInvoice(db, { |
| id: input.id, |
| status: "scheduled", |
| scheduledAt: input.scheduledAt, |
| scheduledJobId, |
| teamId: teamId!, |
| }); |
|
|
| if (!data) { |
| |
| try { |
| const queue = getQueue("invoices"); |
| const { jobId: rawJobId } = decodeJobId(scheduledJobId); |
| const job = await queue.getJob(rawJobId); |
| if (job) { |
| await job.remove(); |
| } |
| } catch (err) { |
| logger.error("Failed to clean up orphaned scheduled job", { |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| } |
|
|
| throw new TRPCError({ |
| code: "NOT_FOUND", |
| message: "Invoice not found", |
| }); |
| } |
|
|
| |
| triggerJob( |
| "notification", |
| { |
| type: "invoice_scheduled", |
| teamId: teamId!, |
| invoiceId: input.id, |
| invoiceNumber: data.invoiceNumber!, |
| scheduledAt: input.scheduledAt, |
| customerName: data.customerName ?? undefined, |
| }, |
| "notifications", |
| ).catch(() => { |
| |
| }); |
|
|
| return data; |
| } |
|
|
| const data = await updateInvoice(db, { |
| id: input.id, |
| status: "unpaid", |
| teamId: teamId!, |
| userId: session.user.id, |
| }); |
|
|
| if (!data) { |
| throw new TRPCError({ |
| code: "NOT_FOUND", |
| message: "Invoice not found", |
| }); |
| } |
|
|
| await triggerJob( |
| "generate-invoice", |
| { |
| invoiceId: data.id, |
| deliveryType: input.deliveryType, |
| }, |
| "invoices", |
| ); |
|
|
| return data; |
| }), |
|
|
| remind: protectedProcedure |
| .input(remindInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId } }) => { |
| await triggerJob( |
| "send-invoice-reminder", |
| { |
| invoiceId: input.id, |
| }, |
| "invoices", |
| ); |
|
|
| return updateInvoice(db, { |
| id: input.id, |
| teamId: teamId!, |
| reminderSentAt: input.date, |
| }); |
| }), |
|
|
| duplicate: protectedProcedure |
| .input(duplicateInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, session, teamId } }) => { |
| const nextInvoiceNumber = await getNextInvoiceNumber(db, teamId!); |
|
|
| return duplicateInvoice(db, { |
| id: input.id, |
| userId: session?.user.id!, |
| invoiceNumber: nextInvoiceNumber!, |
| teamId: teamId!, |
| }); |
| }), |
|
|
| updateSchedule: protectedProcedure |
| .input(updateScheduledInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId } }) => { |
| |
| const invoice = await getInvoiceById(db, { |
| id: input.id, |
| teamId: teamId!, |
| }); |
|
|
| if (!invoice || !invoice.scheduledJobId) { |
| throw new TRPCError({ |
| code: "NOT_FOUND", |
| message: "Scheduled invoice not found", |
| }); |
| } |
|
|
| |
| const scheduledDate = new Date(input.scheduledAt); |
| const now = new Date(); |
|
|
| if (scheduledDate <= now) { |
| throw new TRPCError({ |
| code: "BAD_REQUEST", |
| message: "scheduledAt must be in the future", |
| }); |
| } |
|
|
| |
| const delayMs = scheduledDate.getTime() - now.getTime(); |
|
|
| |
| const scheduledRun = await triggerJob( |
| "schedule-invoice", |
| { |
| invoiceId: input.id, |
| }, |
| "invoices", |
| { |
| delay: delayMs, |
| }, |
| ); |
|
|
| if (!scheduledRun?.id) { |
| throw new TRPCError({ |
| code: "SERVICE_UNAVAILABLE", |
| message: "Failed to create scheduled job", |
| }); |
| } |
|
|
| |
| const updatedInvoice = await updateInvoice(db, { |
| id: input.id, |
| scheduledAt: input.scheduledAt, |
| scheduledJobId: scheduledRun.id, |
| teamId: teamId!, |
| }); |
|
|
| if (!updatedInvoice) { |
| |
| const queue = getQueue("invoices"); |
| const { jobId: newRawJobId } = decodeJobId(scheduledRun.id); |
| const newJob = await queue.getJob(newRawJobId); |
| if (newJob) { |
| await newJob.remove().catch((err) => { |
| logger.error("Failed to clean up orphaned scheduled job", { |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| }); |
| } |
|
|
| throw new TRPCError({ |
| code: "NOT_FOUND", |
| message: "Invoice not found or update failed", |
| }); |
| } |
|
|
| |
| |
| const queue = getQueue("invoices"); |
| |
| const { jobId: rawJobId } = decodeJobId(invoice.scheduledJobId); |
| const existingJob = await queue.getJob(rawJobId); |
| if (existingJob) { |
| await existingJob.remove().catch((err) => { |
| |
| |
| logger.error("Failed to remove old scheduled job", { |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| }); |
| } |
|
|
| return updatedInvoice; |
| }), |
|
|
| cancelSchedule: protectedProcedure |
| .input(cancelScheduledInvoiceSchema) |
| .mutation(async ({ input, ctx: { db, teamId } }) => { |
| |
| const invoice = await getInvoiceById(db, { |
| id: input.id, |
| teamId: teamId!, |
| }); |
|
|
| if (!invoice) { |
| throw new TRPCError({ |
| code: "NOT_FOUND", |
| message: "Scheduled invoice not found", |
| }); |
| } |
|
|
| if (invoice.scheduledJobId) { |
| |
| const queue = getQueue("invoices"); |
| |
| const { jobId: rawJobId } = decodeJobId(invoice.scheduledJobId); |
| const job = await queue.getJob(rawJobId); |
| if (job) { |
| await job.remove(); |
| } |
| } |
|
|
| |
| const updatedInvoice = await updateInvoice(db, { |
| id: input.id, |
| status: "draft", |
| scheduledAt: null, |
| scheduledJobId: null, |
| teamId: teamId!, |
| }); |
|
|
| return updatedInvoice; |
| }), |
|
|
| mostActiveClient: protectedProcedure.query( |
| async ({ ctx: { db, teamId } }) => { |
| return getMostActiveClient(db, { teamId: teamId! }); |
| }, |
| ), |
|
|
| inactiveClientsCount: protectedProcedure.query( |
| async ({ ctx: { db, teamId } }) => { |
| return getInactiveClientsCount(db, { teamId: teamId! }); |
| }, |
| ), |
|
|
| averageDaysToPayment: protectedProcedure.query( |
| async ({ ctx: { db, teamId } }) => { |
| return getAverageDaysToPayment(db, { teamId: teamId! }); |
| }, |
| ), |
|
|
| averageInvoiceSize: protectedProcedure.query( |
| async ({ ctx: { db, teamId } }) => { |
| return getAverageInvoiceSize(db, { teamId: teamId! }); |
| }, |
| ), |
|
|
| topRevenueClient: protectedProcedure.query( |
| async ({ ctx: { db, teamId } }) => { |
| return getTopRevenueClient(db, { teamId: teamId! }); |
| }, |
| ), |
|
|
| newCustomersCount: protectedProcedure.query( |
| async ({ ctx: { db, teamId } }) => { |
| return getNewCustomersCount(db, { teamId: teamId! }); |
| }, |
| ), |
| }); |
|
|