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:
parent
f7427825d1
commit
0dc56ff1eb
@ -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
|
||||
*
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user