mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -691,7 +691,13 @@ export class BillingService {
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
this.events
|
||||
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
@@ -823,7 +829,10 @@ export class BillingService {
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
|
||||
where: {
|
||||
id: invoiceId,
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
},
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(
|
||||
@@ -881,7 +890,7 @@ export class BillingService {
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
|
||||
@@ -97,6 +97,9 @@ export class BookingInvoiceService {
|
||||
*/
|
||||
@OnEvent("booking.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case "PREPAID":
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
@@ -143,9 +146,16 @@ export class BookingInvoiceService {
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { FindOptionsWhere } from "typeorm";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { DriversService } from "../drivers/drivers.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { VehiclesService } from "../vehicles/vehicles.service";
|
||||
import { CreateFirstMileDto } from "./dto/create-first-mile.dto";
|
||||
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
|
||||
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
||||
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
||||
import { FirstMileRepository } from "./first-mile.repository";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
||||
|
||||
type FirstMileListFilter = {
|
||||
status?: FirstMileStatus;
|
||||
@@ -26,10 +26,10 @@ type FirstMileListFilter = {
|
||||
};
|
||||
|
||||
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
||||
'status',
|
||||
'advancedPayment',
|
||||
'remainingPayment',
|
||||
'createdAt',
|
||||
"status",
|
||||
"advancedPayment",
|
||||
"remainingPayment",
|
||||
"createdAt",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -43,26 +43,28 @@ export class FirstMileService {
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Look up a booking by its human-readable reference and confirm it has been
|
||||
* paid before any first-mile work proceeds. Throws if the reference is
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||
async acceptBookingByReference(
|
||||
bookingReference: string,
|
||||
): Promise<FirstMile | null> {
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
@@ -89,20 +91,18 @@ export class FirstMileService {
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): Promise<FirstMile> {
|
||||
const label = booking.reference ?? booking.id;
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||
}): Promise<FirstMile | null> {
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await this.findByBookingId(booking.id);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -118,8 +118,9 @@ export class FirstMileService {
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile)
|
||||
? (filter.sortBy as keyof FirstMile)
|
||||
: 'createdAt';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
: "createdAt";
|
||||
const sortOrder =
|
||||
filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
const where: FindOptionsWhere<FirstMile> = {};
|
||||
if (filter.status) where.status = filter.status;
|
||||
@@ -129,7 +130,13 @@ export class FirstMileService {
|
||||
const [data, total] = await this.firstMileRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
order: { [sortBy]: sortOrder },
|
||||
@@ -151,8 +158,12 @@ export class FirstMileService {
|
||||
@OnEvent("firstmile.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
try {
|
||||
await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
|
||||
this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
|
||||
await this.firstMileRepository.update(payload.sourceId, {
|
||||
paid: true,
|
||||
} as any);
|
||||
this.logger.log(
|
||||
`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
|
||||
@@ -163,7 +174,13 @@ export class FirstMileService {
|
||||
async findById(id: string): Promise<FirstMile> {
|
||||
const record = await this.firstMileRepository.findById(id, {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
@@ -183,7 +200,7 @@ export class FirstMileService {
|
||||
|
||||
return this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
status: dto.status ?? "READY_TO_TRANSIT",
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
@@ -197,7 +214,13 @@ export class FirstMileService {
|
||||
const [records] = await this.firstMileRepository.findAndCount({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
take: 1,
|
||||
@@ -213,9 +236,9 @@ export class FirstMileService {
|
||||
// Export bookings always need a first mile (pickup → origin yard); the
|
||||
// pickup address is captured at assignment time, not required upfront.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
booking.tradeDirection === "EXPORT" ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,9 +249,15 @@ export class FirstMileService {
|
||||
const updated = await this.firstMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
|
||||
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
|
||||
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
||||
...(dto.advancedPayment !== undefined
|
||||
? { advancedPayment: dto.advancedPayment }
|
||||
: {}),
|
||||
...(dto.remainingPayment !== undefined
|
||||
? { remainingPayment: dto.remainingPayment }
|
||||
: {}),
|
||||
...(dto.estimatedKm !== undefined
|
||||
? { estimatedKm: dto.estimatedKm }
|
||||
: {}),
|
||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||
@@ -256,37 +285,63 @@ export class FirstMileService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||
private async notifyDriverAssignment(
|
||||
vehicleId: string,
|
||||
record: FirstMile,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle.assignedDriverId) {
|
||||
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Vehicle ${vehicleId} has no assigned driver — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
const driver = await this.driversService.findById(
|
||||
vehicle.assignedDriverId,
|
||||
);
|
||||
if (!driver.phoneNumber) {
|
||||
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||
const booking = (
|
||||
record as FirstMile & {
|
||||
booking?: {
|
||||
reference?: string;
|
||||
firstMilePickupAddress?: string | null;
|
||||
originYard?: { label?: string } | null;
|
||||
};
|
||||
}
|
||||
).booking;
|
||||
|
||||
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||
const driverName =
|
||||
`${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim();
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
|
||||
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
|
||||
(booking?.firstMilePickupAddress
|
||||
? `Pickup: ${booking.firstMilePickupAddress}. `
|
||||
: "") +
|
||||
(booking?.originYard?.label
|
||||
? `Destination: ${booking.originYard.label}.`
|
||||
: "");
|
||||
|
||||
void this.smsClient.sendSms({
|
||||
to: driver.phoneNumber,
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
this.logger.log(
|
||||
`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
this.logger.error(
|
||||
`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +369,7 @@ export class FirstMileService {
|
||||
firstMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
containerType: "CONTAINER",
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,48 +189,53 @@ export class PaymentService {
|
||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
try {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create or update the local intent projection from a provider snapshot. */
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceLine } from "../billing/entities/invoice-line.entity";
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from '../billing/documents/invoice-document.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { WarehouseFeeService } from "./warehouse-fee.service";
|
||||
import {
|
||||
WarehouseFeeInvoiceView,
|
||||
WarehouseFeeType,
|
||||
WarehouseInvoiceItemView,
|
||||
WarehouseInvoiceStatus,
|
||||
WarehouseInvoiceType,
|
||||
} from './warehouse-invoice.types';
|
||||
} from "./warehouse-invoice.types";
|
||||
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
performedBy?: string;
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
billingCurrency?: "ETB" | "USD";
|
||||
}
|
||||
|
||||
export interface PayInvoiceDto {
|
||||
@@ -46,7 +57,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
/** Global statuses considered an "active" invoice for per-inventory dedup. */
|
||||
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
|
||||
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
|
||||
...BLOCKING_STATUSES,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
];
|
||||
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
@@ -121,10 +135,13 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
|
||||
async generateForInventory(
|
||||
inventoryId: string,
|
||||
opts: GenerateOptions = {},
|
||||
): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const [item] = await this.dataSource.query(
|
||||
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||
@@ -137,47 +154,53 @@ export class WarehouseInvoiceService {
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
if (!item)
|
||||
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// Routing through the global invoice requires a billable company + profile,
|
||||
// both of which come from the inventory's booking.
|
||||
if (!item.companyId || !item.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
|
||||
"Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).",
|
||||
);
|
||||
}
|
||||
|
||||
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||
if (await this.hasActiveInvoice(inventoryId)) {
|
||||
throw new ConflictException(
|
||||
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
||||
"An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.",
|
||||
);
|
||||
}
|
||||
|
||||
const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD";
|
||||
const previews = await this.feeService.previewForInventory(
|
||||
inventoryId,
|
||||
billingCurrency,
|
||||
);
|
||||
const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER";
|
||||
|
||||
const items = previews
|
||||
.filter((p) => p.amount > 0)
|
||||
.map((p) => {
|
||||
const feeType: WarehouseFeeType =
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? 'STORAGE_FEE'
|
||||
p.ruleType === "STORAGE_FEE"
|
||||
? "STORAGE_FEE"
|
||||
: isContainer
|
||||
? 'CONTAINER_DEMURRAGE'
|
||||
: 'BULK_DEMURRAGE';
|
||||
? "CONTAINER_DEMURRAGE"
|
||||
: "BULK_DEMURRAGE";
|
||||
return {
|
||||
feeRuleId: p.ruleId,
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
|
||||
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
|
||||
}`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
|
||||
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
|
||||
}`,
|
||||
p.ruleType === "STORAGE_FEE"
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
||||
? " using tiered tariff"
|
||||
: ` after ${p.freeDays} free`
|
||||
}`
|
||||
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
||||
? " using tiered tariff"
|
||||
: ` after ${p.freeDays} free`
|
||||
}`,
|
||||
quantity: p.billableUnits,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
@@ -189,13 +212,19 @@ export class WarehouseInvoiceService {
|
||||
|
||||
const total = items.reduce((s, i) => s + i.amount, 0);
|
||||
if (total <= 0 && !opts.confirmZero) {
|
||||
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||
throw new BadRequestException(
|
||||
"No payable warehouse fee found for this item.",
|
||||
);
|
||||
}
|
||||
|
||||
const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE');
|
||||
const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE');
|
||||
const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE");
|
||||
const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE");
|
||||
const invoiceType: WarehouseInvoiceType =
|
||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||
hasDemurrage && hasStorage
|
||||
? "MIXED_WAREHOUSE_FEES"
|
||||
: hasStorage
|
||||
? "STORAGE_FEE"
|
||||
: "DEMURRAGE";
|
||||
|
||||
const lines: InvoiceLineInput[] = items.map((it) => ({
|
||||
chargeType: it.feeType,
|
||||
@@ -237,18 +266,23 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
return this.queryViews("AND i.source_id = $1", [inventoryId]);
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
|
||||
return this.queryViews("AND inv.booking_id = $1", [bookingId]);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: Partial<
|
||||
Pick<
|
||||
WarehouseFeeInvoiceView,
|
||||
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
|
||||
| "status"
|
||||
| "invoiceType"
|
||||
| "warehouseId"
|
||||
| "facilityId"
|
||||
| "customerId"
|
||||
| "bookingId"
|
||||
>
|
||||
>,
|
||||
): Promise<WarehouseFeeInvoiceView[]> {
|
||||
@@ -259,41 +293,56 @@ export class WarehouseInvoiceService {
|
||||
conditions.push(sql(`$${params.length}`));
|
||||
};
|
||||
|
||||
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
|
||||
if (filter.status)
|
||||
add(
|
||||
(p) => `i.status::text = ${p}`,
|
||||
this.toGlobalStatus(filter.status as WarehouseInvoiceStatus),
|
||||
);
|
||||
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
|
||||
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
|
||||
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
|
||||
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
|
||||
if (filter.warehouseId)
|
||||
add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
|
||||
if (filter.facilityId)
|
||||
add((p) => `w.facility_id = ${p}`, filter.facilityId);
|
||||
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
|
||||
|
||||
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
|
||||
return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params);
|
||||
}
|
||||
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
}
|
||||
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||
throw new BadRequestException(
|
||||
"A receipt is available only after payment is recorded.",
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── State changes ────────────────────────────────────────────────────────
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
throw new BadRequestException("A paid invoice cannot be cancelled.");
|
||||
}
|
||||
await this.billing.cancelInvoice(id);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Record a payment against the invoice (delegates settlement to billing). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
|
||||
async pay(
|
||||
id: string,
|
||||
dto: PayInvoiceDto,
|
||||
): Promise<WarehouseFeeInvoiceDetail> {
|
||||
// Guard that this is a warehouse invoice before recording (404 otherwise).
|
||||
await this.loadWarehouseInvoice(id);
|
||||
await this.billing.recordPayment(id, {
|
||||
@@ -302,7 +351,10 @@ export class WarehouseInvoiceService {
|
||||
reference: dto.reference ?? null,
|
||||
metadata:
|
||||
dto.driverName || dto.driverPhone
|
||||
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
|
||||
? {
|
||||
driverName: dto.driverName ?? null,
|
||||
driverPhone: dto.driverPhone ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const detail = await this.findById(id);
|
||||
@@ -314,12 +366,12 @@ export class WarehouseInvoiceService {
|
||||
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('Invoice is already fully paid.');
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
|
||||
return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, {
|
||||
method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'),
|
||||
platform: dto.platform ?? 'web',
|
||||
return this.billing.payInvoice(invoice.id, {
|
||||
method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"),
|
||||
platform: dto.platform ?? "web",
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
@@ -334,16 +386,20 @@ export class WarehouseInvoiceService {
|
||||
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
|
||||
* double-notifying a counter payment that already sent its SMS.
|
||||
*/
|
||||
@OnEvent('warehouse.invoice.paid')
|
||||
@OnEvent("warehouse.invoice.paid")
|
||||
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (!payload.paymentId) return;
|
||||
const detail = await this.findById(payload.invoiceId);
|
||||
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
|
||||
await this.notifyWarehouseFeePayment(detail, {
|
||||
amount: Number(detail.totalAmount),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
|
||||
async findBlockingInvoice(
|
||||
inventoryId: string,
|
||||
): Promise<WarehouseFeeInvoiceView | null> {
|
||||
const blocking = await this.queryViews(
|
||||
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
|
||||
[inventoryId, BLOCKING_STATUSES],
|
||||
@@ -352,21 +408,31 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
async assertClearanceAllowed(inventoryId: string): Promise<void> {
|
||||
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const invoices = await this.queryViews("AND i.source_id = $1", [
|
||||
inventoryId,
|
||||
]);
|
||||
const blocking = invoices.find(
|
||||
(inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID",
|
||||
);
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (invoices.some((inv) => inv.status === 'PAID')) return;
|
||||
if (invoices.some((inv) => inv.status === "PAID")) return;
|
||||
|
||||
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
|
||||
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
|
||||
const previews = await this.feeService.previewForInventory(
|
||||
inventoryId,
|
||||
"USD",
|
||||
);
|
||||
const payableAmount = previews.reduce(
|
||||
(sum, fee) => sum + Number(fee.amount || 0),
|
||||
0,
|
||||
);
|
||||
if (payableAmount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
|
||||
"Generate and fully pay the warehouse demurrage/storage invoice before terminal release.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -374,7 +440,9 @@ export class WarehouseInvoiceService {
|
||||
// ── Internal: loading & projection ─────────────────────────────────────────
|
||||
|
||||
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
|
||||
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
private async loadWarehouseInvoice(
|
||||
id: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.billing.findById(id);
|
||||
if (invoice.source !== SOURCE) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
@@ -397,7 +465,10 @@ export class WarehouseInvoiceService {
|
||||
* Project warehouse-source global invoices into the historical view, joined to
|
||||
* their inventory item for the typed FKs. Powers every list/filter read.
|
||||
*/
|
||||
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
|
||||
private async queryViews(
|
||||
extraWhere: string,
|
||||
params: unknown[],
|
||||
): Promise<WarehouseFeeInvoiceView[]> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
|
||||
i.source_id AS "sourceId", i.type, i.status,
|
||||
@@ -410,7 +481,7 @@ export class WarehouseInvoiceService {
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
w.facility_id AS "facilityId"
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
|
||||
ORDER BY i.created_at DESC`,
|
||||
@@ -430,7 +501,10 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
|
||||
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
|
||||
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
|
||||
private buildView(
|
||||
inv: ViewSource,
|
||||
ctx: InventoryContext,
|
||||
): WarehouseFeeInvoiceView {
|
||||
const status = this.toWarehouseStatus(inv.status);
|
||||
return {
|
||||
id: inv.id,
|
||||
@@ -457,7 +531,7 @@ export class WarehouseInvoiceService {
|
||||
issuedAt: inv.issuedAt ?? null,
|
||||
dueDate: inv.dueAt ?? null,
|
||||
paidAt: inv.paidAt ?? null,
|
||||
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
|
||||
cancelledAt: status === "CANCELLED" ? inv.updatedAt : null,
|
||||
payments: (inv.payments ?? []).map((p) => ({
|
||||
amount: Number(p.amount),
|
||||
method: p.method ?? null,
|
||||
@@ -479,7 +553,7 @@ export class WarehouseInvoiceService {
|
||||
return {
|
||||
feeRuleId: meta.feeRuleId ?? null,
|
||||
feeType: line.chargeType as WarehouseFeeType,
|
||||
description: line.description ?? '',
|
||||
description: line.description ?? "",
|
||||
quantity: Number(line.quantity),
|
||||
unitRate: Number(line.unitRate),
|
||||
amount: Number(line.amount),
|
||||
@@ -489,32 +563,36 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
|
||||
private toWarehouseStatus(
|
||||
status: Freight.InvoiceStatus | string,
|
||||
): WarehouseInvoiceStatus {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Draft:
|
||||
return 'DRAFT';
|
||||
return "DRAFT";
|
||||
case Freight.InvoiceStatus.PartiallyPaid:
|
||||
return 'PARTIALLY_PAID';
|
||||
return "PARTIALLY_PAID";
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return 'PAID';
|
||||
return "PAID";
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return 'CANCELLED';
|
||||
return "CANCELLED";
|
||||
default:
|
||||
// Issued / Pending / Overdue → an issued, still-owed invoice.
|
||||
return 'ISSUED';
|
||||
return "ISSUED";
|
||||
}
|
||||
}
|
||||
|
||||
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
|
||||
private toGlobalStatus(
|
||||
status: WarehouseInvoiceStatus,
|
||||
): Freight.InvoiceStatus {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
case "DRAFT":
|
||||
return Freight.InvoiceStatus.Draft;
|
||||
case 'PARTIALLY_PAID':
|
||||
case "PARTIALLY_PAID":
|
||||
return Freight.InvoiceStatus.PartiallyPaid;
|
||||
case 'PAID':
|
||||
case "PAID":
|
||||
return Freight.InvoiceStatus.Paid;
|
||||
case 'CANCELLED':
|
||||
case "CANCELLED":
|
||||
return Freight.InvoiceStatus.Cancelled;
|
||||
default:
|
||||
return Freight.InvoiceStatus.Issued;
|
||||
@@ -524,39 +602,54 @@ export class WarehouseInvoiceService {
|
||||
/** Map a warehouse fee invoice view onto the shared document model. */
|
||||
private toDocumentModel(
|
||||
invoice: WarehouseFeeInvoiceDetail,
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
||||
value
|
||||
? new Date(value as string | Date).toLocaleDateString("en-GB")
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind,
|
||||
title: 'Warehouse Fee',
|
||||
title: "Warehouse Fee",
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
|
||||
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
|
||||
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
|
||||
{ label: 'Customer', value: invoice.customerName ?? null },
|
||||
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
|
||||
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
|
||||
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
|
||||
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
|
||||
{ label: "Status", value: invoice.status.replace(/_/g, " ") },
|
||||
{
|
||||
label: 'Yard / Zone',
|
||||
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
|
||||
label: "Invoice type",
|
||||
value: invoice.invoiceType.replace(/_/g, " "),
|
||||
},
|
||||
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
|
||||
{ label: "Booking reference", value: invoice.bookingReference ?? null },
|
||||
{ label: "Customer", value: invoice.customerName ?? null },
|
||||
{
|
||||
label: 'Payment',
|
||||
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
|
||||
label: "Inventory reference",
|
||||
value: invoice.inventoryReference ?? null,
|
||||
},
|
||||
{ label: "Inventory info", value: invoice.inventoryInfo ?? null },
|
||||
{ label: "Clearance", value: invoice.clearanceStatus ?? null },
|
||||
{ label: "Warehouse", value: invoice.warehouseName ?? null },
|
||||
{
|
||||
label: "Yard / Zone",
|
||||
value:
|
||||
[invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") ||
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: "Period",
|
||||
value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`,
|
||||
},
|
||||
{
|
||||
label: "Payment",
|
||||
value: lastPayment
|
||||
? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}`
|
||||
: null,
|
||||
},
|
||||
],
|
||||
categoryHeader: 'Fee type',
|
||||
categoryHeader: "Fee type",
|
||||
lines: invoice.items.map((item) => ({
|
||||
description: item.description ?? null,
|
||||
category: item.feeType ?? null,
|
||||
@@ -566,17 +659,19 @@ export class WarehouseInvoiceService {
|
||||
currency: item.currency ?? invoice.currency,
|
||||
})),
|
||||
totals: [
|
||||
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
|
||||
{ label: 'Tax', amount: Number(invoice.taxAmount) },
|
||||
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: 'Paid', amount: Number(invoice.paidAmount) },
|
||||
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
|
||||
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
|
||||
{ label: "Tax", amount: Number(invoice.taxAmount) },
|
||||
{ label: "Total", amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: "Paid", amount: Number(invoice.paidAmount) },
|
||||
{ label: "Balance", amount: Number(invoice.balanceAmount) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Warehouse-specific display details, derived from the linked inventory item. */
|
||||
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
|
||||
private async getInvoiceDocumentDetails(
|
||||
invoice: ViewSource,
|
||||
): Promise<InvoiceDocumentDetails> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
@@ -612,12 +707,12 @@ export class WarehouseInvoiceService {
|
||||
[invoice.sourceId],
|
||||
);
|
||||
|
||||
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
|
||||
const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID";
|
||||
const clearanceStatus = row?.releaseDate
|
||||
? 'RELEASE ISSUED'
|
||||
? "RELEASE ISSUED"
|
||||
: fullyPaid
|
||||
? 'FEE PAID - READY FOR RELEASE'
|
||||
: 'PENDING PAYMENT';
|
||||
? "FEE PAID - READY FOR RELEASE"
|
||||
: "PENDING PAYMENT";
|
||||
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
@@ -634,7 +729,9 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
|
||||
private async getInventoryContext(
|
||||
inventoryId: string,
|
||||
): Promise<InventoryContext> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
@@ -722,55 +819,89 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
|
||||
private async sendSms(
|
||||
recipient: string | null | undefined,
|
||||
message: string,
|
||||
context: string,
|
||||
): Promise<void> {
|
||||
const phone = recipient?.trim();
|
||||
if (!phone) return;
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
await this.notifications.directSend("sms", phone, message);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
|
||||
this.logger.error(
|
||||
`Failed to send ${context} SMS to ${phone}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
private async notifyWarehouseFeeIssued(
|
||||
invoice: WarehouseFeeInvoiceView,
|
||||
): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(
|
||||
invoice.inventoryId,
|
||||
);
|
||||
const customerName = contacts.customerName?.trim() || "Customer";
|
||||
const bookingReference = contacts.bookingReference
|
||||
? ` Booking: ${contacts.bookingReference}.`
|
||||
: "";
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
const cargoText = cargo ? ` Cargo: ${cargo}.` : '';
|
||||
const cargoText = cargo ? ` Cargo: ${cargo}.` : "";
|
||||
const message =
|
||||
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` +
|
||||
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` +
|
||||
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
|
||||
|
||||
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
||||
await this.sendSms(
|
||||
contacts.customerPhone,
|
||||
message,
|
||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
private async notifyWarehouseFeePayment(
|
||||
invoice: WarehouseFeeInvoiceView,
|
||||
dto: PayInvoiceDto,
|
||||
): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(
|
||||
invoice.inventoryId,
|
||||
);
|
||||
const customerName = contacts.customerName?.trim() || "Customer";
|
||||
const bookingReference = contacts.bookingReference
|
||||
? ` Booking: ${contacts.bookingReference}.`
|
||||
: "";
|
||||
const statusText =
|
||||
invoice.status === 'PAID'
|
||||
? 'fully paid and ready for pickup release'
|
||||
invoice.status === "PAID"
|
||||
? "fully paid and ready for pickup release"
|
||||
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
|
||||
const customerMessage =
|
||||
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
|
||||
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
|
||||
|
||||
await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`);
|
||||
await this.sendSms(
|
||||
contacts.customerPhone,
|
||||
customerMessage,
|
||||
`warehouse fee payment ${invoice.invoiceNumber}`,
|
||||
);
|
||||
|
||||
if (invoice.status !== 'PAID') return;
|
||||
if (invoice.status !== "PAID") return;
|
||||
|
||||
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
|
||||
const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver';
|
||||
const driverName =
|
||||
dto.driverName?.trim() || contacts.driverName || "Driver";
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
const driverMessage =
|
||||
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
|
||||
(contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') +
|
||||
(cargo ? ` Cargo: ${cargo}.` : '') +
|
||||
' Proceed with pickup after gate verification.';
|
||||
(contacts.bookingReference
|
||||
? ` Booking: ${contacts.bookingReference}.`
|
||||
: "") +
|
||||
(cargo ? ` Cargo: ${cargo}.` : "") +
|
||||
" Proceed with pickup after gate verification.";
|
||||
|
||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||
await this.sendSms(
|
||||
driverPhone,
|
||||
driverMessage,
|
||||
`warehouse pickup driver ${invoice.invoiceNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import FaydaSetupWizard from '@/components/FaydaSetupWizard';
|
||||
|
||||
export default function FaydaSetupPage() {
|
||||
return <FaydaSetupWizard />;
|
||||
}
|
||||
104
apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx
Normal file
104
apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Train, MailCheck, ArrowLeft } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await iamAuthApi.forgotPassword(email);
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(
|
||||
msg === 'user_not_found'
|
||||
? 'No account found with that email address.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Reset your password</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Enter your email and we'll send a reset link to the phone number on your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{sent ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
|
||||
<MailCheck className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm">
|
||||
A password reset link has been sent via SMS. Open it to set a new password — the link expires in 30 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login" className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !email}>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { Train } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
@@ -85,6 +86,14 @@ function LoginContent() {
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
@@ -92,7 +101,23 @@ function LoginContent() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
||||
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/fayda-setup"
|
||||
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Already verified with Fayda? Set up your password
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
|
||||
176
apps/edr-passenger-web/portal/src/app/register/page.tsx
Normal file
176
apps/edr-passenger-web/portal/src/app/register/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState } from 'react';
|
||||
import { Train, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
fullName: z.string().min(2, 'Full name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
phone: z.string().min(9, 'Phone number is required'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const registerUser = useAuthStore((s) => s.register);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema as any),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await registerUser({
|
||||
fullName: data.fullName,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
});
|
||||
router.push('/booking/search');
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
setError('An account with this email or phone number already exists.');
|
||||
} else {
|
||||
setError(err.response?.data?.message || 'Registration failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Create account</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Book faster and manage your trips</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full name</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('fullName')}
|
||||
className="input-field"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
autoComplete="name"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.fullName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="input-field"
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone number</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
className="input-field"
|
||||
placeholder="+251912345678"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('confirmPassword')}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Link
|
||||
href="/fayda-setup"
|
||||
className="flex items-center justify-center gap-2 w-full btn-secondary"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Already verified with Fayda? Set up your password
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Already have an account? </span>
|
||||
<Link href="/login" className="text-sm font-medium text-[rgb(20_113_76)] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
apps/edr-passenger-web/portal/src/app/reset-password/page.tsx
Normal file
154
apps/edr-passenger-web/portal/src/app/reset-password/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const email = searchParams.get('email') || '';
|
||||
const userId = searchParams.get('userId') || '';
|
||||
const verificationCode = searchParams.get('verificationCode') || '';
|
||||
const linkValid = Boolean(email && userId && verificationCode);
|
||||
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await iamAuthApi.resetPassword({ userId, email, verificationCode, newPassword, confirmPassword });
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push('/login'), 2000);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(msg || 'Failed to reset password. The link may have expired — request a new one.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Set a new password</h1>
|
||||
{linkValid && !success && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Choose a new password for <span className="font-medium">{email}</span>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{!linkValid ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
This password reset link is invalid or incomplete. Request a new one from the sign-in page.
|
||||
</div>
|
||||
<Link href="/forgot-password" className="btn-primary w-full flex items-center justify-center">
|
||||
Request a new link
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : success ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
|
||||
<CheckCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm">Password reset successfully. Redirecting to sign in…</p>
|
||||
</div>
|
||||
<Link href="/login" className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
Go to sign in
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !newPassword || !confirmPassword}>
|
||||
{loading ? 'Resetting...' : 'Reset password'}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
24
apps/edr-passenger-web/portal/src/app/set-password/page.tsx
Normal file
24
apps/edr-passenger-web/portal/src/app/set-password/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import FaydaSetupWizard from '@/components/FaydaSetupWizard';
|
||||
|
||||
// Landing page for the IAM's Fayda set-password SMS link:
|
||||
// ${FE_BASE_URL}/set-password?email=..&userId=..&verificationCode=..
|
||||
// The wizard starts at step 2 with the code prefilled; the user enters their
|
||||
// phone number (verify-and-login requires it) and a new password.
|
||||
function SetPasswordContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const verificationCode = searchParams.get('verificationCode') || '';
|
||||
|
||||
return <FaydaSetupWizard initialOtp={verificationCode} />;
|
||||
}
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
|
||||
import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import ChangePasswordModal from "@/components/ChangePasswordModal";
|
||||
|
||||
export default function AppHeader() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const { user, isAuthenticated, initialize, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
setIsDark(isDarkMode);
|
||||
}, []);
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
@@ -84,6 +90,67 @@ export default function AppHeader() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Auth */}
|
||||
{isAuthenticated && user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 p-1.5 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
|
||||
{user.fullName?.toUpperCase().charAt(0) || 'U'}
|
||||
</div>
|
||||
<ChevronDown className="hidden sm:block w-4 h-4 text-gray-100" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 z-50 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowChangePassword(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
@@ -115,10 +182,33 @@ export default function AppHeader() {
|
||||
>
|
||||
Help
|
||||
</Link>
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, CheckCircle } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (newPassword === currentPassword) {
|
||||
setError('New password must be different from the current password.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await iamAuthApi.changePassword({
|
||||
oldPassword: currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
setSuccess(true);
|
||||
setTimeout(() => handleClose(), 1500);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || '';
|
||||
if (err.response?.status === 401) {
|
||||
setError('Current password is incorrect.');
|
||||
} else if (msg === 'new_password_same_as_old') {
|
||||
setError('New password must be different from the current password.');
|
||||
} else if (msg === 'new_passwords_do_not_match') {
|
||||
setError('New passwords do not match.');
|
||||
} else {
|
||||
setError(msg || 'Failed to change password. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Portal to <body> with a high z-index: the modal is mounted inside the
|
||||
// sticky z-50 header, whose stacking context would otherwise let page
|
||||
// content (e.g. the trip search widget) render on top of it.
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-6 py-4 flex items-center justify-between rounded-t-2xl">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Change Password</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{success && (
|
||||
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
|
||||
<CheckCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm">Password changed successfully.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => { setCurrentPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={loading || success || !currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
interface FaydaSetupWizardProps {
|
||||
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
|
||||
initialOtp?: string;
|
||||
}
|
||||
|
||||
type Outcome = 'success' | 'hasPassword' | null;
|
||||
|
||||
export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<1 | 2>(initialOtp ? 2 : 1);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [otp, setOtp] = useState(initialOtp || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [outcome, setOutcome] = useState<Outcome>(null);
|
||||
|
||||
const handleRequestCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await iamAuthApi.faydaRequestPasswordSetup(phone);
|
||||
setStep(2);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to send the code. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await iamAuthApi.faydaVerifyAndLogin({ phoneNumber: phone, otp });
|
||||
const data = (res.data as any)?.data ?? res.data;
|
||||
if (!data.requiresPassword) {
|
||||
setOutcome('hasPassword');
|
||||
return;
|
||||
}
|
||||
await iamAuthApi.setFaydaPassword(
|
||||
{ userId: data.iamUserId, newPassword, confirmPassword },
|
||||
data.token,
|
||||
);
|
||||
setOutcome('success');
|
||||
setTimeout(() => router.push('/login'), 2500);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(msg || 'Verification failed. The code may be wrong or expired.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center justify-center gap-2">
|
||||
<ShieldCheck className="w-7 h-7 text-[rgb(20_113_76)] dark:text-emerald-400" />
|
||||
Fayda account setup
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Already verified with Fayda? Set a password to access your account online.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{outcome === 'success' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
|
||||
<CheckCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm">
|
||||
Your password has been set and your account is now active. Redirecting to sign in…
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login" className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
Go to sign in
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : outcome === 'hasPassword' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 px-4 py-3 rounded">
|
||||
<Info className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm">
|
||||
This account already has a password. Sign in with your email or phone number,
|
||||
or use forgot password if you can't remember it.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login" className="btn-primary w-full flex items-center justify-center">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="flex items-center justify-center text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
) : step === 1 ? (
|
||||
<form onSubmit={handleRequestCode} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => { setPhone(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="+251912345678"
|
||||
autoComplete="tel"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The phone number you used during Fayda verification.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !phone}>
|
||||
{loading ? 'Sending...' : 'Send verification code'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||
<div className="flex items-start gap-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 px-4 py-3 rounded">
|
||||
<Info className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm">
|
||||
If this phone number is Fayda-verified, an SMS with a verification code has been sent.
|
||||
Enter it below with your new password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => { setPhone(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="+251912345678"
|
||||
autoComplete="tel"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => { setOtp(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="6-character code from SMS"
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary w-full"
|
||||
disabled={loading || !phone || !otp || !newPassword || !confirmPassword}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify & set password'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep(1); setError(''); setOtp(''); }}
|
||||
className="w-full text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
Didn't get a code? Send again
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{outcome === null && (
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/edr-passenger-web/portal/src/lib/api/auth.ts
Normal file
51
apps/edr-passenger-web/portal/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
// IAM (/v1/auth/*) and Fayda (/auth/fayda/*) endpoints use raw axios instead of
|
||||
// apiClient: apiClient's response interceptor clears the token and redirects to
|
||||
// /login on any 401 for non-public URLs — but the IAM returns 401 when the
|
||||
// current password is wrong on change-password, and OTP failures must surface
|
||||
// as inline errors, not a logout.
|
||||
export const iamAuthApi = {
|
||||
forgotPassword: (email: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
||||
|
||||
// Completes the forgot-password flow using the link sent via SMS:
|
||||
// ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=..
|
||||
resetPassword: (data: {
|
||||
userId: string;
|
||||
email: string;
|
||||
verificationCode: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) => axios.patch(`${API_URL}/v1/auth/set-password`, data),
|
||||
|
||||
changePassword: (data: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) =>
|
||||
axios.patch(`${API_URL}/v1/auth/change-password`, data, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
}),
|
||||
|
||||
faydaRequestPasswordSetup: (phoneNumber: string) =>
|
||||
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),
|
||||
|
||||
faydaVerifyAndLogin: (data: { phoneNumber: string; otp: string }) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: { token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string };
|
||||
}>(`${API_URL}/auth/fayda/verify-and-login`, data),
|
||||
|
||||
// Bearer token comes from faydaVerifyAndLogin's response, not localStorage —
|
||||
// the user is not logged into the portal at this point.
|
||||
setFaydaPassword: (
|
||||
data: { userId: string; newPassword: string; confirmPassword: string },
|
||||
token: string,
|
||||
) =>
|
||||
axios.patch(`${API_URL}/v1/auth/set-fayda-password`, data, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
};
|
||||
@@ -40,10 +40,11 @@ interface AuthState {
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
fullName: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@@ -118,7 +119,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
register: async (data: RegisterData) => {
|
||||
const response: any = await apiClient.post('/auth/register', data);
|
||||
// Shape required by the passenger-api RegisterDto; username = email by convention.
|
||||
const payload = {
|
||||
email: data.email,
|
||||
username: data.email,
|
||||
phoneNumber: data.phone,
|
||||
name: { en: data.fullName, am: data.fullName },
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
};
|
||||
const response: any = await apiClient.post('/auth/register', payload);
|
||||
const { token, user } = response.data || response;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
Reference in New Issue
Block a user