fix: 194 and 183 plane takss

This commit is contained in:
Nathnael
2026-07-20 07:41:25 +00:00
parent 3d4996e4df
commit c7195f077a
13 changed files with 180 additions and 17 deletions

View File

@@ -0,0 +1,54 @@
import {
validate,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { IsTin, normalizeTin } from './is-tin.validator';
class Required {
@IsString()
@IsNotEmpty()
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin!: string;
}
class Optional {
@IsOptional()
@IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin?: string;
}
async function errs(cls: any, tin: any) {
const o = new cls();
o.tin = tin;
return (await validate(o)).length;
}
describe('IsTin', () => {
it('accepts a real 10-digit TIN', async () => {
expect(await errs(Required, '0012345678')).toBe(0);
});
it.each([
['letters', 'ABCDEFGHIJ'],
['symbols', '!!!!!!!!!!'],
['too short', '123'],
['too long', '12345678901'],
['draft TIN', 'D123456789'],
['spaced', '012 345678'],
])('rejects %s', async (_label, value) => {
expect(await errs(Required, value)).toBeGreaterThan(0);
});
it('rejects empty on the required DTO but allows omission on the optional one', async () => {
expect(await errs(Required, '')).toBeGreaterThan(0);
expect(await errs(Optional, undefined)).toBe(0);
});
it('normalizes messy input', () => {
expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678');
expect(normalizeTin('')).toBe('');
});
});

View File

@@ -0,0 +1,54 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/** An Ethiopian TIN is exactly 10 digits. */
export const TIN_REGEX = /^\d{10}$/;
/**
* Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by
* CompaniesService.generateDraftTin(), because the column is NOT NULL + unique.
* Those never travel through a DTO, so this constraint deliberately rejects them
* — a "D…" value arriving on a request body is client-supplied and invalid.
*/
@ValidatorConstraint({ name: 'IsTin', async: false })
export class IsTinConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
return TIN_REGEX.test(value);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be exactly 10 digits`;
}
}
/** Class-validator decorator enforcing the 10-digit TIN format. */
export function IsTin(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsTinConstraint,
});
};
}
/**
* Strip everything that isn't a digit and cap at 10 characters. Tolerant —
* never throws; returns the value unchanged when empty/nullish.
*/
export function normalizeTin(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined || value === null || value === '') return value;
return value.replace(/\D/g, '').slice(0, 10);
}

View File

@@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
kind,
status,
onboardingCompleted,
sortBy = 'name',
sortOrder = 'ASC',
} = query;
const qb = this.repository
@@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
);
}
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
const [items, total] = await qb
.orderBy('company.name', 'ASC')
.orderBy(`company.${sortBy}`, sortOrder)
// Names are not unique and createdAt can tie on bulk imports; the id
// tiebreaker keeps paging stable instead of dropping/repeating rows.
.addOrderBy('company.id', 'ASC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();

View File

@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsTin } from '../../../common/validators/is-tin.validator';
export class CompanyProfileInputDto {
@IsEnum(ProfileType)
@@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
@IsString()
@MaxLength(10)
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin?: string;
@IsOptional()

View File

@@ -1,6 +1,7 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsTin } from '../../../common/validators/is-tin.validator';
export class CreateCompanyDto {
@IsString()
@@ -17,7 +18,7 @@ export class CreateCompanyDto {
@IsString()
@IsNotEmpty()
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin!: string;
@IsOptional()

View File

@@ -1,8 +1,9 @@
import { IsString, IsNotEmpty, Length } from "class-validator";
import { IsString, IsNotEmpty } from "class-validator";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class FetchETradeDto {
@IsString()
@IsNotEmpty()
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
@IsTin({ message: "TIN must be exactly 10 digits" })
tin!: string;
}

View File

@@ -47,4 +47,19 @@ export class ListCompaniesQueryDto {
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
onboardingCompleted?: boolean;
@ApiPropertyOptional({
enum: ["name", "createdAt", "updatedAt"],
default: "name",
description: "Column to order by. Defaults to name for backwards compatibility.",
})
@IsOptional()
@IsIn(["name", "createdAt", "updatedAt"])
sortBy?: "name" | "createdAt" | "updatedAt";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
}

View File

@@ -1,6 +1,7 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsTin } from '../../../common/validators/is-tin.validator';
export class UpdateProfileDto {
@IsOptional()
@@ -34,7 +35,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin?: string;
@IsOptional()

View File

@@ -5,6 +5,7 @@ import {
Card,
Group,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
@@ -63,22 +64,35 @@ const VIEW_FILTERS: Record<
active: { status: "active" },
};
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "name:ASC", label: "Name (AZ)" },
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("createdAt:DESC");
const filter = useMemo(
() => ({
const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
"name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
sortBy,
sortOrder,
...VIEW_FILTERS[view],
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
);
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -291,6 +305,20 @@ export default function CustomersPage() {
{ label: "Active", value: "active" },
]}
/>
<Select
size="sm"
radius="md"
w={160}
allowDeselect={false}
aria-label="Sort customers"
value={sort}
onChange={(v) => {
if (!v) return;
setSort(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={SORT_OPTIONS.map((o) => ({ ...o }))}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>

View File

@@ -188,6 +188,8 @@ export interface CompanyListFilter {
status?: CompanyStatus;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
sortBy?: "name" | "createdAt" | "updatedAt";
sortOrder?: "ASC" | "DESC";
}
/** Standard paginated list envelope (matches the bookings service shape). */

View File

@@ -21,7 +21,7 @@ export const onboardingSchema = z.object({
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
.min(1, "VAT number is required")

View File

@@ -36,7 +36,7 @@ const schema = z.object({
// TRANSPORT
fanNumber: z.string().min(1),
tinNumber: z.string().min(1),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
truckType: z.enum([
"Casoni",
@@ -185,7 +185,7 @@ export default function TransporterOnboarding() {
<Field data-invalid={!!errors.tinNumber}>
<FieldLabel>TIN Number</FieldLabel>
<Input {...register("tinNumber")} />
<Input maxLength={10} inputMode="numeric" {...register("tinNumber")} />
<FieldError errors={[errors.tinNumber]} />
</Field>
</div>

View File

@@ -32,7 +32,7 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
vatNumber: z
.string()