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 // ADMIN users bypass shipment limits
if (req.user.role !== 'ADMIN') { 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 subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear; const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) { if (maxShipments !== -1) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
organizationId, organizationId,
currentYear currentYear
); );
@ -228,6 +228,35 @@ export class CsvBookingsController {
return await this.csvBookingService.getUserBookings(userId, page, limit); 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 * 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. * Count all shipments (bookings + CSV bookings) created by an organization in a given year.
*/ */
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>; 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 }) .andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
.getCount(); .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'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api'; import { getReservationQuota } from '@/lib/api/bookings';
import { useSubscription } from '@/lib/context/subscription-context'; 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 * The free (Bronze) plan is limited to a number of PAID reservations per year
* year (`maxShipmentsPerYear`, e.g. 5). Higher plans are unlimited (-1). This * (`maxShipmentsPerYear`, e.g. 5); higher plans are unlimited. The value comes
* hook lets the UI proactively prompt an upgrade once the limit is reached, * from the backend, which uses the organization's real plan (not the ADMIN
* instead of letting the user go through the whole flow only to be blocked. * 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() { export function useReservationQuota() {
const { subscription, loading: subLoading } = useSubscription(); const { isAuthenticated } = useAuth();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
// Same query key as the bookings list so the request is shared/cached. queryKey: ['reservation-quota'],
queryKey: ['csv-bookings'], queryFn: getReservationQuota,
queryFn: () => listCsvBookings({ page: 1, limit: 1000 }), enabled: isAuthenticated,
staleTime: 30_000,
}); });
const max = subscription?.planDetails?.maxShipmentsPerYear ?? -1; const max = data?.max ?? -1;
const unlimited = max === -1; const used = data?.used ?? 0;
const unlimited = data?.unlimited ?? true;
const currentYear = new Date().getFullYear(); const limitReached = data?.limitReached ?? false;
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;
return { return {
loading: subLoading || isLoading, loading: isLoading,
plan: subscription?.plan ?? null,
max, max,
used, used,
remaining: unlimited ? Infinity : Math.max(0, max - used), remaining: unlimited ? Infinity : Math.max(0, max - used),
unlimited, unlimited,
limitReached, limitReached,
blocked, blocked: limitReached,
}; };
} }

View File

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