feat(invoices): polymorphic billing entity with snapshot clientName
Wires the billingEntityType/billingEntityId columns (added in PR 1) through
the invoice validator and service. Clients can now be billed as either a
client or a company; clientName becomes a snapshot derived from the entity
at create time.
- createInvoiceSchema: replace clientName with billingEntity {type,id}
- listInvoicesSchema: add billingEntityType/billingEntityId filters
- createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware)
falls back to entity primary email/address when not supplied
- listInvoices: honor new billing-entity filters
- updateInvoice: unchanged — billing entity is fixed after create
- invoice wizard step 1: temporary billing-entity id input (Task 10.2
replaces this with a proper picker)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,13 @@ export default function NewInvoicePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = methods;
|
||||||
|
|
||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
const lineItems = watchedValues.lineItems ?? [];
|
const lineItems = watchedValues.lineItems ?? [];
|
||||||
@@ -87,7 +93,7 @@ export default function NewInvoicePage() {
|
|||||||
async function goNext() {
|
async function goNext() {
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
const valid = await methods.trigger([
|
const valid = await methods.trigger([
|
||||||
'clientName',
|
'billingEntity',
|
||||||
'billingEmail',
|
'billingEmail',
|
||||||
'billingAddress',
|
'billingAddress',
|
||||||
'dueDate',
|
'dueDate',
|
||||||
@@ -112,11 +118,7 @@ export default function NewInvoicePage() {
|
|||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push(`/${portSlug}/invoices`)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold">New Invoice</h1>
|
<h1 className="text-xl font-semibold">New Invoice</h1>
|
||||||
@@ -131,22 +133,16 @@ export default function NewInvoicePage() {
|
|||||||
step > s.id
|
step > s.id
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: step === s.id
|
: step === s.id
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'bg-muted text-muted-foreground'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
|
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={`text-sm ${step === s.id ? 'font-medium' : 'text-muted-foreground'}`}>
|
||||||
className={`text-sm ${
|
|
||||||
step === s.id ? 'font-medium' : 'text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s.label}
|
{s.label}
|
||||||
</span>
|
</span>
|
||||||
{idx < STEPS.length - 1 && (
|
{idx < STEPS.length - 1 && <div className="w-8 h-px bg-border mx-1" />}
|
||||||
<div className="w-8 h-px bg-border mx-1" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,17 +157,36 @@ export default function NewInvoicePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="clientName">
|
<Label htmlFor="billingEntityType">
|
||||||
Client Name <span className="text-destructive">*</span>
|
Billing Entity <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="grid grid-cols-2 gap-2">
|
||||||
id="clientName"
|
<Select
|
||||||
{...register('clientName')}
|
defaultValue="client"
|
||||||
placeholder="Client or company name"
|
onValueChange={(v) =>
|
||||||
/>
|
setValue('billingEntity.type', v as 'client' | 'company')
|
||||||
{errors.clientName && (
|
}
|
||||||
<p className="text-xs text-destructive">{errors.clientName.message}</p>
|
>
|
||||||
|
<SelectTrigger id="billingEntityType">
|
||||||
|
<SelectValue placeholder="Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="client">Client</SelectItem>
|
||||||
|
<SelectItem value="company">Company</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input {...register('billingEntity.id')} placeholder="Entity ID" />
|
||||||
|
</div>
|
||||||
|
{errors.billingEntity && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.billingEntity.message ??
|
||||||
|
errors.billingEntity.id?.message ??
|
||||||
|
errors.billingEntity.type?.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Picker UI is coming in Task 10.2 — for now paste the client or company ID.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -202,11 +217,7 @@ export default function NewInvoicePage() {
|
|||||||
<Label htmlFor="dueDate">
|
<Label htmlFor="dueDate">
|
||||||
Due Date <span className="text-destructive">*</span>
|
Due Date <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input id="dueDate" type="date" {...register('dueDate')} />
|
||||||
id="dueDate"
|
|
||||||
type="date"
|
|
||||||
{...register('dueDate')}
|
|
||||||
/>
|
|
||||||
{errors.dueDate && (
|
{errors.dueDate && (
|
||||||
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
|
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -216,7 +227,9 @@ export default function NewInvoicePage() {
|
|||||||
<Label>Payment Terms</Label>
|
<Label>Payment Terms</Label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue="net30"
|
defaultValue="net30"
|
||||||
onValueChange={(v) => setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])}
|
onValueChange={(v) =>
|
||||||
|
setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select terms" />
|
<SelectValue placeholder="Select terms" />
|
||||||
@@ -284,8 +297,10 @@ export default function NewInvoicePage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Client</span>
|
<span className="text-muted-foreground">Billing Entity</span>
|
||||||
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
|
<p className="font-medium mt-0.5">
|
||||||
|
{watchedValues.billingEntity?.type}: {watchedValues.billingEntity?.id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Due Date</span>
|
<span className="text-muted-foreground">Due Date</span>
|
||||||
@@ -293,9 +308,7 @@ export default function NewInvoicePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payment Terms</span>
|
<span className="text-muted-foreground">Payment Terms</span>
|
||||||
<p className="font-medium mt-0.5 capitalize">
|
<p className="font-medium mt-0.5 capitalize">{watchedValues.paymentTerms}</p>
|
||||||
{watchedValues.paymentTerms}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Currency</span>
|
<span className="text-muted-foreground">Currency</span>
|
||||||
@@ -354,12 +367,7 @@ export default function NewInvoicePage() {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={goBack} disabled={step === 1}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={goBack}
|
|
||||||
disabled={step === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,20 +2,17 @@ import { eq, and, desc, like, lt, sql, gte, lte, inArray, ne } from 'drizzle-orm
|
|||||||
import type { PgColumn } from 'drizzle-orm/pg-core';
|
import type { PgColumn } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import { invoices, invoiceLineItems, invoiceExpenses, expenses } from '@/lib/db/schema/financial';
|
||||||
invoices,
|
|
||||||
invoiceLineItems,
|
|
||||||
invoiceExpenses,
|
|
||||||
expenses,
|
|
||||||
} from '@/lib/db/schema/financial';
|
|
||||||
import { files } from '@/lib/db/schema/documents';
|
import { files } from '@/lib/db/schema/documents';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { clients, clientAddresses } from '@/lib/db/schema/clients';
|
||||||
|
import { companies, companyAddresses } from '@/lib/db/schema/companies';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
import { withTransaction } from '@/lib/db/utils';
|
import { withTransaction } from '@/lib/db/utils';
|
||||||
import { NotFoundError, ConflictError } from '@/lib/errors';
|
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { generatePdf } from '@/lib/pdf/generate';
|
import { generatePdf } from '@/lib/pdf/generate';
|
||||||
@@ -50,9 +47,7 @@ async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<str
|
|||||||
const [existing] = await tx
|
const [existing] = await tx
|
||||||
.select({ invoiceNumber: invoices.invoiceNumber })
|
.select({ invoiceNumber: invoices.invoiceNumber })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(
|
.where(and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}-%`)))
|
||||||
and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}-%`)),
|
|
||||||
)
|
|
||||||
.orderBy(desc(invoices.invoiceNumber))
|
.orderBy(desc(invoices.invoiceNumber))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -64,6 +59,88 @@ async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<str
|
|||||||
return `${prefix}-${String(seq).padStart(3, '0')}`;
|
return `${prefix}-${String(seq).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Resolve billing entity (polymorphic client | company) ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the billing entity referenced on invoice create and derive the
|
||||||
|
* display name + fallback billing email/address. Scoped to the tenant (portId);
|
||||||
|
* throws ValidationError on missing / cross-tenant lookups.
|
||||||
|
*
|
||||||
|
* Runs inside the caller's transaction so the lookup is consistent with the
|
||||||
|
* rest of the create operation.
|
||||||
|
*/
|
||||||
|
async function resolveBillingEntity(
|
||||||
|
tx: typeof db,
|
||||||
|
portId: string,
|
||||||
|
entity: { type: 'client' | 'company'; id: string },
|
||||||
|
): Promise<{
|
||||||
|
clientName: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
billingAddress: string | null;
|
||||||
|
}> {
|
||||||
|
if (entity.type === 'client') {
|
||||||
|
const client = await tx.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, entity.id), eq(clients.portId, portId)),
|
||||||
|
with: {
|
||||||
|
contacts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!client) throw new ValidationError('billing entity (client) not found');
|
||||||
|
|
||||||
|
// Prefer primary email contact, fall back to any email contact
|
||||||
|
const emailContact =
|
||||||
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
||||||
|
client.contacts?.find((c) => c.channel === 'email');
|
||||||
|
|
||||||
|
const addressRow = await tx.query.clientAddresses.findFirst({
|
||||||
|
where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
|
||||||
|
});
|
||||||
|
const billingAddress = addressRow
|
||||||
|
? [
|
||||||
|
addressRow.streetAddress,
|
||||||
|
addressRow.city,
|
||||||
|
addressRow.stateProvince,
|
||||||
|
addressRow.postalCode,
|
||||||
|
addressRow.country,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientName: client.fullName,
|
||||||
|
billingEmail: emailContact?.value ?? null,
|
||||||
|
billingAddress: billingAddress || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await tx.query.companies.findFirst({
|
||||||
|
where: and(eq(companies.id, entity.id), eq(companies.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!company) throw new ValidationError('billing entity (company) not found');
|
||||||
|
|
||||||
|
const addressRow = await tx.query.companyAddresses.findFirst({
|
||||||
|
where: and(eq(companyAddresses.companyId, company.id), eq(companyAddresses.isPrimary, true)),
|
||||||
|
});
|
||||||
|
const billingAddress = addressRow
|
||||||
|
? [
|
||||||
|
addressRow.streetAddress,
|
||||||
|
addressRow.city,
|
||||||
|
addressRow.stateProvince,
|
||||||
|
addressRow.postalCode,
|
||||||
|
addressRow.country,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientName: company.name,
|
||||||
|
billingEmail: company.billingEmail ?? null,
|
||||||
|
billingAddress: billingAddress || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
||||||
@@ -81,6 +158,12 @@ export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
|||||||
if (query.dateTo) {
|
if (query.dateTo) {
|
||||||
filters.push(lte(invoices.dueDate, query.dateTo));
|
filters.push(lte(invoices.dueDate, query.dateTo));
|
||||||
}
|
}
|
||||||
|
if (query.billingEntityType) {
|
||||||
|
filters.push(eq(invoices.billingEntityType, query.billingEntityType));
|
||||||
|
}
|
||||||
|
if (query.billingEntityId) {
|
||||||
|
filters.push(eq(invoices.billingEntityId, query.billingEntityId));
|
||||||
|
}
|
||||||
|
|
||||||
return buildListQuery({
|
return buildListQuery({
|
||||||
table: invoices,
|
table: invoices,
|
||||||
@@ -139,14 +222,20 @@ export async function createInvoice(
|
|||||||
meta: ServiceAuditMeta,
|
meta: ServiceAuditMeta,
|
||||||
) {
|
) {
|
||||||
const invoice = await withTransaction(async (tx) => {
|
const invoice = await withTransaction(async (tx) => {
|
||||||
|
// Resolve the polymorphic billing entity (client | company). Throws
|
||||||
|
// ValidationError if the entity is missing or belongs to another tenant.
|
||||||
|
const entitySnapshot = await resolveBillingEntity(tx, portId, data.billingEntity);
|
||||||
|
// clientName is always entity-derived on create (it's a snapshot).
|
||||||
|
// Caller-supplied billingEmail/billingAddress win over entity-derived values.
|
||||||
|
const effectiveClientName = entitySnapshot.clientName;
|
||||||
|
const effectiveBillingEmail = data.billingEmail ?? entitySnapshot.billingEmail;
|
||||||
|
const effectiveBillingAddress = data.billingAddress ?? entitySnapshot.billingAddress;
|
||||||
|
|
||||||
const invoiceNumber = await generateInvoiceNumber(portId, tx);
|
const invoiceNumber = await generateInvoiceNumber(portId, tx);
|
||||||
|
|
||||||
// Calculate subtotal from line items
|
// Calculate subtotal from line items
|
||||||
const lineItemsData = data.lineItems ?? [];
|
const lineItemsData = data.lineItems ?? [];
|
||||||
const subtotal = lineItemsData.reduce(
|
const subtotal = lineItemsData.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0);
|
||||||
(sum, li) => sum + li.quantity * li.unitPrice,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// BR-042: net10 discount — read from systemSettings
|
// BR-042: net10 discount — read from systemSettings
|
||||||
let discountPct = 0;
|
let discountPct = 0;
|
||||||
@@ -155,10 +244,7 @@ export async function createInvoice(
|
|||||||
.select({ value: systemSettings.value })
|
.select({ value: systemSettings.value })
|
||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(systemSettings.key, 'invoice_net10_discount'), eq(systemSettings.portId, portId)),
|
||||||
eq(systemSettings.key, 'invoice_net10_discount'),
|
|
||||||
eq(systemSettings.portId, portId),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -182,17 +268,12 @@ export async function createInvoice(
|
|||||||
.from(invoiceExpenses)
|
.from(invoiceExpenses)
|
||||||
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
|
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(inArray(invoiceExpenses.expenseId, expenseIds), sql`${invoices.status} != 'draft'`),
|
||||||
inArray(invoiceExpenses.expenseId, expenseIds),
|
|
||||||
sql`${invoices.status} != 'draft'`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (alreadyLinked.length > 0) {
|
if (alreadyLinked.length > 0) {
|
||||||
throw new ConflictError(
|
throw new ConflictError('One or more expenses are already linked to a non-draft invoice');
|
||||||
'One or more expenses are already linked to a non-draft invoice',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +282,11 @@ export async function createInvoice(
|
|||||||
.values({
|
.values({
|
||||||
portId,
|
portId,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
clientName: data.clientName,
|
billingEntityType: data.billingEntity.type,
|
||||||
billingEmail: data.billingEmail ?? null,
|
billingEntityId: data.billingEntity.id,
|
||||||
billingAddress: data.billingAddress ?? null,
|
clientName: effectiveClientName,
|
||||||
|
billingEmail: effectiveBillingEmail,
|
||||||
|
billingAddress: effectiveBillingAddress,
|
||||||
dueDate: data.dueDate,
|
dueDate: data.dueDate,
|
||||||
paymentTerms: data.paymentTerms ?? 'net30',
|
paymentTerms: data.paymentTerms ?? 'net30',
|
||||||
currency: data.currency ?? 'USD',
|
currency: data.currency ?? 'USD',
|
||||||
@@ -297,10 +380,7 @@ export async function updateInvoice(
|
|||||||
// Recalculate totals if line items changed
|
// Recalculate totals if line items changed
|
||||||
if (data.lineItems !== undefined) {
|
if (data.lineItems !== undefined) {
|
||||||
const lineItemsData = data.lineItems;
|
const lineItemsData = data.lineItems;
|
||||||
const subtotal = lineItemsData.reduce(
|
const subtotal = lineItemsData.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0);
|
||||||
(sum, li) => sum + li.quantity * li.unitPrice,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const paymentTerms = data.paymentTerms ?? existing.paymentTerms;
|
const paymentTerms = data.paymentTerms ?? existing.paymentTerms;
|
||||||
let discountPct = 0;
|
let discountPct = 0;
|
||||||
@@ -364,17 +444,15 @@ export async function updateInvoice(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (alreadyLinked.length > 0) {
|
if (alreadyLinked.length > 0) {
|
||||||
throw new ConflictError(
|
throw new ConflictError('One or more expenses are already linked to a non-draft invoice');
|
||||||
'One or more expenses are already linked to a non-draft invoice',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
|
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
|
||||||
if (data.expenseIds.length > 0) {
|
if (data.expenseIds.length > 0) {
|
||||||
await tx.insert(invoiceExpenses).values(
|
await tx
|
||||||
data.expenseIds.map((expenseId) => ({ invoiceId: id, expenseId })),
|
.insert(invoiceExpenses)
|
||||||
);
|
.values(data.expenseIds.map((expenseId) => ({ invoiceId: id, expenseId })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,11 +494,7 @@ export async function updateInvoice(
|
|||||||
|
|
||||||
// ─── Delete (draft only) ──────────────────────────────────────────────────
|
// ─── Delete (draft only) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function deleteInvoice(
|
export async function deleteInvoice(id: string, portId: string, meta: ServiceAuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
const existing = await getInvoiceById(id, portId);
|
const existing = await getInvoiceById(id, portId);
|
||||||
if (existing.status !== 'draft') {
|
if (existing.status !== 'draft') {
|
||||||
throw new ConflictError('Only draft invoices can be deleted');
|
throw new ConflictError('Only draft invoices can be deleted');
|
||||||
@@ -429,9 +503,7 @@ export async function deleteInvoice(
|
|||||||
await withTransaction(async (tx) => {
|
await withTransaction(async (tx) => {
|
||||||
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
|
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
|
||||||
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
|
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
|
||||||
await tx
|
await tx.delete(invoices).where(and(eq(invoices.id, id), eq(invoices.portId, portId)));
|
||||||
.delete(invoices)
|
|
||||||
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
@@ -453,11 +525,7 @@ export async function deleteInvoice(
|
|||||||
|
|
||||||
// ─── Generate PDF ─────────────────────────────────────────────────────────
|
// ─── Generate PDF ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function generateInvoicePdf(
|
export async function generateInvoicePdf(id: string, portId: string, meta: ServiceAuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
const invoice = await getInvoiceById(id, portId);
|
const invoice = await getInvoiceById(id, portId);
|
||||||
|
|
||||||
const [port] = await db
|
const [port] = await db
|
||||||
@@ -519,11 +587,7 @@ export async function generateInvoicePdf(
|
|||||||
|
|
||||||
// ─── Send invoice ─────────────────────────────────────────────────────────
|
// ─── Send invoice ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendInvoice(
|
export async function sendInvoice(id: string, portId: string, meta: ServiceAuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
const invoice = await getInvoiceById(id, portId);
|
const invoice = await getInvoiceById(id, portId);
|
||||||
|
|
||||||
// Generate PDF if not exists
|
// Generate PDF if not exists
|
||||||
@@ -621,11 +685,7 @@ export async function detectOverdue(portId: string) {
|
|||||||
.select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber, dueDate: invoices.dueDate })
|
.select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber, dueDate: invoices.dueDate })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(invoices.portId, portId), eq(invoices.status, 'sent'), lt(invoices.dueDate, today)),
|
||||||
eq(invoices.portId, portId),
|
|
||||||
eq(invoices.status, 'sent'),
|
|
||||||
lt(invoices.dueDate, today),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (overdueInvoices.length === 0) return;
|
if (overdueInvoices.length === 0) return;
|
||||||
@@ -636,9 +696,10 @@ export async function detectOverdue(portId: string) {
|
|||||||
.set({ status: 'overdue', updatedAt: new Date() })
|
.set({ status: 'overdue', updatedAt: new Date() })
|
||||||
.where(eq(invoices.id, inv.id));
|
.where(eq(invoices.id, inv.id));
|
||||||
|
|
||||||
const daysPastDue = Math.max(1, Math.ceil(
|
const daysPastDue = Math.max(
|
||||||
(Date.now() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24),
|
1,
|
||||||
));
|
Math.ceil((Date.now() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24)),
|
||||||
|
);
|
||||||
emitToRoom(`port:${portId}`, 'invoice:overdue', {
|
emitToRoom(`port:${portId}`, 'invoice:overdue', {
|
||||||
invoiceId: inv.id,
|
invoiceId: inv.id,
|
||||||
invoiceNumber: inv.invoiceNumber,
|
invoiceNumber: inv.invoiceNumber,
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
|||||||
|
|
||||||
export const createInvoiceSchema = z
|
export const createInvoiceSchema = z
|
||||||
.object({
|
.object({
|
||||||
clientName: z.string().min(1).max(200),
|
billingEntity: z.object({
|
||||||
|
type: z.enum(['client', 'company']),
|
||||||
|
id: z.string().min(1),
|
||||||
|
}),
|
||||||
billingEmail: z.string().email().optional(),
|
billingEmail: z.string().email().optional(),
|
||||||
billingAddress: z.string().max(500).optional(),
|
billingAddress: z.string().max(500).optional(),
|
||||||
dueDate: z.string().min(1),
|
dueDate: z.string().min(1),
|
||||||
@@ -35,9 +38,7 @@ export const updateInvoiceSchema = z.object({
|
|||||||
billingEmail: z.string().email().optional(),
|
billingEmail: z.string().email().optional(),
|
||||||
billingAddress: z.string().max(500).optional(),
|
billingAddress: z.string().max(500).optional(),
|
||||||
dueDate: z.string().min(1).optional(),
|
dueDate: z.string().min(1).optional(),
|
||||||
paymentTerms: z
|
paymentTerms: z.enum(['immediate', 'net10', 'net15', 'net30', 'net45', 'net60']).optional(),
|
||||||
.enum(['immediate', 'net10', 'net15', 'net30', 'net45', 'net60'])
|
|
||||||
.optional(),
|
|
||||||
currency: z.string().length(3).optional(),
|
currency: z.string().length(3).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
lineItems: z
|
lineItems: z
|
||||||
@@ -63,6 +64,8 @@ export const listInvoicesSchema = baseListQuerySchema.extend({
|
|||||||
clientName: z.string().optional(),
|
clientName: z.string().optional(),
|
||||||
dateFrom: z.string().optional(),
|
dateFrom: z.string().optional(),
|
||||||
dateTo: z.string().optional(),
|
dateTo: z.string().optional(),
|
||||||
|
billingEntityType: z.enum(['client', 'company']).optional(),
|
||||||
|
billingEntityId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
|
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
|
||||||
|
|||||||
224
tests/integration/invoices-billing-entity.test.ts
Normal file
224
tests/integration/invoices-billing-entity.test.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* invoices.service billing-entity integration tests.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - createInvoice with billingEntity=client snapshots clientName from client.fullName
|
||||||
|
* - createInvoice with billingEntity=company snapshots clientName from company.name
|
||||||
|
* - createInvoice throws ValidationError when client does not exist
|
||||||
|
* - createInvoice throws ValidationError when company is in another tenant (cross-tenant)
|
||||||
|
* - createInvoice uses entity primary email + address when none provided
|
||||||
|
* - createInvoice allows caller to override billingEmail and billingAddress
|
||||||
|
*
|
||||||
|
* Uses dynamic imports (PR 8 pattern) so env is loaded before service modules
|
||||||
|
* touch `db`.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
|
||||||
|
describe('invoices.service — billing entity', () => {
|
||||||
|
let createInvoice: typeof import('@/lib/services/invoices').createInvoice;
|
||||||
|
|
||||||
|
let makePort: typeof import('../helpers/factories').makePort;
|
||||||
|
let makeClient: typeof import('../helpers/factories').makeClient;
|
||||||
|
let makeCompany: typeof import('../helpers/factories').makeCompany;
|
||||||
|
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
|
||||||
|
|
||||||
|
let db: typeof import('@/lib/db').db;
|
||||||
|
let clientContacts: typeof import('@/lib/db/schema/clients').clientContacts;
|
||||||
|
let clientAddresses: typeof import('@/lib/db/schema/clients').clientAddresses;
|
||||||
|
let companyAddresses: typeof import('@/lib/db/schema/companies').companyAddresses;
|
||||||
|
|
||||||
|
let ValidationError: typeof import('@/lib/errors').ValidationError;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const svc = await import('@/lib/services/invoices');
|
||||||
|
createInvoice = svc.createInvoice;
|
||||||
|
|
||||||
|
const factories = await import('../helpers/factories');
|
||||||
|
makePort = factories.makePort;
|
||||||
|
makeClient = factories.makeClient;
|
||||||
|
makeCompany = factories.makeCompany;
|
||||||
|
makeAuditMeta = factories.makeAuditMeta;
|
||||||
|
|
||||||
|
const dbMod = await import('@/lib/db');
|
||||||
|
db = dbMod.db;
|
||||||
|
|
||||||
|
const clientsSchema = await import('@/lib/db/schema/clients');
|
||||||
|
clientContacts = clientsSchema.clientContacts;
|
||||||
|
clientAddresses = clientsSchema.clientAddresses;
|
||||||
|
|
||||||
|
const companiesSchema = await import('@/lib/db/schema/companies');
|
||||||
|
companyAddresses = companiesSchema.companyAddresses;
|
||||||
|
|
||||||
|
const errors = await import('@/lib/errors');
|
||||||
|
ValidationError = errors.ValidationError;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an invoice with billingEntity=client and snapshots clientName from client.fullName', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Alice Admiral' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = await createInvoice(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
billingEntity: { type: 'client', id: client.id },
|
||||||
|
dueDate: '2026-12-31',
|
||||||
|
paymentTerms: 'net30',
|
||||||
|
currency: 'USD',
|
||||||
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 500 }],
|
||||||
|
},
|
||||||
|
makeAuditMeta({ portId: port.id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invoice.id).toBeTruthy();
|
||||||
|
expect(invoice.billingEntityType).toBe('client');
|
||||||
|
expect(invoice.billingEntityId).toBe(client.id);
|
||||||
|
expect(invoice.clientName).toBe('Alice Admiral');
|
||||||
|
expect(invoice.portId).toBe(port.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an invoice with billingEntity=company and snapshots clientName from company.name', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const company = await makeCompany({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { name: 'Poseidon Holdings Ltd' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = await createInvoice(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
billingEntity: { type: 'company', id: company.id },
|
||||||
|
dueDate: '2026-12-31',
|
||||||
|
paymentTerms: 'net30',
|
||||||
|
currency: 'USD',
|
||||||
|
lineItems: [{ description: 'Marina services', quantity: 2, unitPrice: 1250 }],
|
||||||
|
},
|
||||||
|
makeAuditMeta({ portId: port.id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invoice.billingEntityType).toBe('company');
|
||||||
|
expect(invoice.billingEntityId).toBe(company.id);
|
||||||
|
expect(invoice.clientName).toBe('Poseidon Holdings Ltd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationError when billing entity (client) does not exist', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createInvoice(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
billingEntity: { type: 'client', id: 'nonexistent-client-id' },
|
||||||
|
dueDate: '2026-12-31',
|
||||||
|
paymentTerms: 'net30',
|
||||||
|
currency: 'USD',
|
||||||
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
||||||
|
},
|
||||||
|
makeAuditMeta({ portId: port.id }),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationError when billing entity (company) is in a different port', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const companyInB = await makeCompany({ portId: portB.id });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createInvoice(
|
||||||
|
portA.id,
|
||||||
|
{
|
||||||
|
billingEntity: { type: 'company', id: companyInB.id },
|
||||||
|
dueDate: '2026-12-31',
|
||||||
|
paymentTerms: 'net30',
|
||||||
|
currency: 'USD',
|
||||||
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
||||||
|
},
|
||||||
|
makeAuditMeta({ portId: portA.id }),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses entity primary email + address when none provided in the request', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Bob Bosun' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a primary email contact and a primary address
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: client.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'bob@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(clientAddresses).values({
|
||||||
|
clientId: client.id,
|
||||||
|
portId: port.id,
|
||||||
|
label: 'Primary',
|
||||||
|
streetAddress: '1 Pier Road',
|
||||||
|
city: 'Harbor City',
|
||||||
|
stateProvince: 'CA',
|
||||||
|
postalCode: '90000',
|
||||||
|
country: 'USA',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = await createInvoice(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
billingEntity: { type: 'client', id: client.id },
|
||||||
|
dueDate: '2026-12-31',
|
||||||
|
paymentTerms: 'net30',
|
||||||
|
currency: 'USD',
|
||||||
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
||||||
|
},
|
||||||
|
makeAuditMeta({ portId: port.id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invoice.billingEmail).toBe('bob@example.com');
|
||||||
|
expect(invoice.billingAddress).toBe('1 Pier Road, Harbor City, CA, 90000, USA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows caller to override billingEmail and billingAddress', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const company = await makeCompany({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { name: 'Nautical Corp', billingEmail: 'billing@nautical.example' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(companyAddresses).values({
|
||||||
|
companyId: company.id,
|
||||||
|
portId: port.id,
|
||||||
|
label: 'Primary',
|
||||||
|
streetAddress: '2 Ocean Blvd',
|
||||||
|
city: 'Portville',
|
||||||
|
stateProvince: 'FL',
|
||||||
|
postalCode: '33101',
|
||||||
|
country: 'USA',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = await createInvoice(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
billingEntity: { type: 'company', id: company.id },
|
||||||
|
billingEmail: 'override@example.com',
|
||||||
|
billingAddress: 'Custom address line',
|
||||||
|
dueDate: '2026-12-31',
|
||||||
|
paymentTerms: 'net30',
|
||||||
|
currency: 'USD',
|
||||||
|
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
|
||||||
|
},
|
||||||
|
makeAuditMeta({ portId: port.id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invoice.billingEmail).toBe('override@example.com');
|
||||||
|
expect(invoice.billingAddress).toBe('Custom address line');
|
||||||
|
// clientName snapshot is still entity-derived (not overridable on create)
|
||||||
|
expect(invoice.clientName).toBe('Nautical Corp');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,7 +194,7 @@ describe('updateBerthStatusSchema', () => {
|
|||||||
|
|
||||||
describe('createInvoiceSchema', () => {
|
describe('createInvoiceSchema', () => {
|
||||||
const validInvoice = {
|
const validInvoice = {
|
||||||
clientName: 'Bob',
|
billingEntity: { type: 'client' as const, id: 'client-123' },
|
||||||
dueDate: '2026-06-01',
|
dueDate: '2026-06-01',
|
||||||
lineItems: [{ description: 'Berth fee', quantity: 1, unitPrice: 5000 }],
|
lineItems: [{ description: 'Berth fee', quantity: 1, unitPrice: 5000 }],
|
||||||
};
|
};
|
||||||
@@ -203,9 +203,17 @@ describe('createInvoiceSchema', () => {
|
|||||||
expect(createInvoiceSchema.safeParse(validInvoice).success).toBe(true);
|
expect(createInvoiceSchema.safeParse(validInvoice).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a valid invoice with billingEntity type=company', () => {
|
||||||
|
const result = createInvoiceSchema.safeParse({
|
||||||
|
...validInvoice,
|
||||||
|
billingEntity: { type: 'company' as const, id: 'company-123' },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts invoice with only expenseIds', () => {
|
it('accepts invoice with only expenseIds', () => {
|
||||||
const result = createInvoiceSchema.safeParse({
|
const result = createInvoiceSchema.safeParse({
|
||||||
clientName: 'Bob',
|
billingEntity: { type: 'client' as const, id: 'client-123' },
|
||||||
dueDate: '2026-06-01',
|
dueDate: '2026-06-01',
|
||||||
expenseIds: ['exp-1'],
|
expenseIds: ['exp-1'],
|
||||||
});
|
});
|
||||||
@@ -213,12 +221,34 @@ describe('createInvoiceSchema', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invoice with neither lineItems nor expenseIds', () => {
|
it('rejects invoice with neither lineItems nor expenseIds', () => {
|
||||||
const result = createInvoiceSchema.safeParse({ clientName: 'Bob', dueDate: '2026-06-01' });
|
const result = createInvoiceSchema.safeParse({
|
||||||
|
billingEntity: { type: 'client' as const, id: 'client-123' },
|
||||||
|
dueDate: '2026-06-01',
|
||||||
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects empty clientName', () => {
|
it('rejects missing billingEntity', () => {
|
||||||
const result = createInvoiceSchema.safeParse({ ...validInvoice, clientName: '' });
|
const result = createInvoiceSchema.safeParse({
|
||||||
|
dueDate: '2026-06-01',
|
||||||
|
lineItems: [{ description: 'Fee', quantity: 1, unitPrice: 1 }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects billingEntity with invalid type', () => {
|
||||||
|
const result = createInvoiceSchema.safeParse({
|
||||||
|
...validInvoice,
|
||||||
|
billingEntity: { type: 'unknown', id: 'id-1' },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects billingEntity with empty id', () => {
|
||||||
|
const result = createInvoiceSchema.safeParse({
|
||||||
|
...validInvoice,
|
||||||
|
billingEntity: { type: 'client', id: '' },
|
||||||
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user