diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index a6b4721..aba25fc 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -168,12 +168,12 @@ export class CsvBookingsController { // ADMIN users bypass shipment limits if (req.user.role !== 'ADMIN') { - // Check shipment limit (Bronze plan = 12/year) + // Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year) const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const maxShipments = subscription.plan.maxShipmentsPerYear; if (maxShipments !== -1) { const currentYear = new Date().getFullYear(); - const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( organizationId, currentYear ); @@ -228,6 +228,35 @@ export class CsvBookingsController { return await this.csvBookingService.getUserBookings(userId, page, limit); } + /** + * Get the reservation (paid shipment) quota for the current organization. + * + * Uses the organization's real plan (not the ADMIN PLATINIUM override) and + * counts only PAID shipments this year, so the UI can show an upgrade prompt. + * + * GET /api/v1/csv-bookings/reservation-quota + */ + @Get('reservation-quota') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get the paid-reservation quota for the current organization' }) + @ApiResponse({ status: 200, description: 'Quota retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getReservationQuota( + @Request() req: any + ): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> { + const organizationId = req.user.organizationId; + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + const max = subscription.plan.maxShipmentsPerYear; + const unlimited = max === -1; + const currentYear = new Date().getFullYear(); + const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( + organizationId, + currentYear + ); + return { max, used, unlimited, limitReached: !unlimited && used >= max }; + } + /** * Get booking statistics for user * diff --git a/apps/backend/src/domain/ports/out/shipment-counter.port.ts b/apps/backend/src/domain/ports/out/shipment-counter.port.ts index 0aaad05..e489eb1 100644 --- a/apps/backend/src/domain/ports/out/shipment-counter.port.ts +++ b/apps/backend/src/domain/ports/out/shipment-counter.port.ts @@ -12,4 +12,14 @@ export interface ShipmentCounterPort { * Count all shipments (bookings + CSV bookings) created by an organization in a given year. */ countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise; + + /** + * Count only PAID shipments (fee paid / payment declared / accepted) created by + * an organization in a given year. Unpaid drafts (PENDING_PAYMENT), rejected and + * cancelled bookings are excluded. + */ + countPaidShipmentsForOrganizationInYear( + organizationId: string, + year: number + ): Promise; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts index f60e0b5..ef99d3a 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts @@ -29,4 +29,24 @@ export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort { .andWhere('csv_booking.created_at < :end', { end: startOfNextYear }) .getCount(); } + + async countPaidShipmentsForOrganizationInYear( + organizationId: string, + year: number + ): Promise { + const startOfYear = new Date(year, 0, 1); + const startOfNextYear = new Date(year + 1, 0, 1); + + // "Paid" = payment completed / declared / accepted. Unpaid drafts + // (PENDING_PAYMENT), rejected and cancelled bookings do not count. + const PAID_STATUSES = ['PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED']; + + return this.csvBookingRepository + .createQueryBuilder('csv_booking') + .where('csv_booking.organization_id = :organizationId', { organizationId }) + .andWhere('csv_booking.created_at >= :start', { start: startOfYear }) + .andWhere('csv_booking.created_at < :end', { end: startOfNextYear }) + .andWhere('csv_booking.status IN (:...statuses)', { statuses: PAID_STATUSES }) + .getCount(); + } } diff --git a/apps/frontend/src/hooks/useReservationQuota.ts b/apps/frontend/src/hooks/useReservationQuota.ts index e7e0922..c9f7c9d 100644 --- a/apps/frontend/src/hooks/useReservationQuota.ts +++ b/apps/frontend/src/hooks/useReservationQuota.ts @@ -1,46 +1,40 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { listCsvBookings } from '@/lib/api'; -import { useSubscription } from '@/lib/context/subscription-context'; +import { getReservationQuota } from '@/lib/api/bookings'; +import { useAuth } from '@/lib/context/auth-context'; /** - * Reservation (shipment) quota for the current plan. + * Reservation (paid shipment) quota for the current plan. * - * The entry (free) plan is limited to a number of reservations per calendar - * year (`maxShipmentsPerYear`, e.g. 5). Higher plans are unlimited (-1). This - * hook lets the UI proactively prompt an upgrade once the limit is reached, - * instead of letting the user go through the whole flow only to be blocked. + * The free (Bronze) plan is limited to a number of PAID reservations per year + * (`maxShipmentsPerYear`, e.g. 5); higher plans are unlimited. The value comes + * from the backend, which uses the organization's real plan (not the ADMIN + * PLATINIUM override) and counts only paid shipments — so the UI can prompt an + * upgrade once the limit is reached instead of failing at the end of the flow. */ export function useReservationQuota() { - const { subscription, loading: subLoading } = useSubscription(); + const { isAuthenticated } = useAuth(); const { data, isLoading } = useQuery({ - // Same query key as the bookings list so the request is shared/cached. - queryKey: ['csv-bookings'], - queryFn: () => listCsvBookings({ page: 1, limit: 1000 }), + queryKey: ['reservation-quota'], + queryFn: getReservationQuota, + enabled: isAuthenticated, + staleTime: 30_000, }); - const max = subscription?.planDetails?.maxShipmentsPerYear ?? -1; - const unlimited = max === -1; - - const currentYear = new Date().getFullYear(); - const bookings = data?.bookings ?? []; - const used = bookings.filter( - b => b.createdAt && new Date(b.createdAt).getFullYear() === currentYear - ).length; - - const limitReached = !unlimited && used >= max; - const blocked = limitReached; + const max = data?.max ?? -1; + const used = data?.used ?? 0; + const unlimited = data?.unlimited ?? true; + const limitReached = data?.limitReached ?? false; return { - loading: subLoading || isLoading, - plan: subscription?.plan ?? null, + loading: isLoading, max, used, remaining: unlimited ? Infinity : Math.max(0, max - used), unlimited, limitReached, - blocked, + blocked: limitReached, }; } diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index 095b620..3ca3dca 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -256,6 +256,21 @@ export async function getCsvBookingStats(): Promise { return get('/api/v1/csv-bookings/stats'); } +/** + * Reservation (paid shipment) quota for the current organization. + * GET /api/v1/csv-bookings/reservation-quota + */ +export interface ReservationQuotaResponse { + max: number; + used: number; + unlimited: boolean; + limitReached: boolean; +} + +export async function getReservationQuota(): Promise { + return get('/api/v1/csv-bookings/reservation-quota'); +} + /** * Cancel a pending CSV booking * PATCH /api/v1/csv-bookings/:id/cancel