feat(bookings): reservation quota based on org plan + PAID bookings

- Nouvel endpoint GET /csv-bookings/reservation-quota: limite = vrai plan de
  l'org (pas l'override ADMIN PLATINIUM), used = bookings PAYES de l'annee
  (PENDING_BANK_TRANSFER/PENDING/ACCEPTED), limitReached.
- ShipmentCounter: countPaidShipmentsForOrganizationInYear.
- create-check compte desormais les payes (au lieu de tous).
- Frontend useReservationQuota consomme l'endpoint (fiable meme pour un admin
  dont l'overview renvoie PLATINIUM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-03 17:36:15 +02:00
parent f7427825d1
commit 0dc56ff1eb
5 changed files with 95 additions and 27 deletions

View File

@ -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
*

View File

@ -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<number>;
/**
* 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<number>;
}

View File

@ -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<number> {
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();
}
}

View File

@ -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,
};
}

View File

@ -256,6 +256,21 @@ export async function getCsvBookingStats(): Promise<CsvBookingStatsResponse> {
return get<CsvBookingStatsResponse>('/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<ReservationQuotaResponse> {
return get<ReservationQuotaResponse>('/api/v1/csv-bookings/reservation-quota');
}
/**
* Cancel a pending CSV booking
* PATCH /api/v1/csv-bookings/:id/cancel