fix remuneration

This commit is contained in:
David 2026-06-18 00:17:44 +02:00
parent 6b9ad95811
commit 96aa140207
23 changed files with 493 additions and 122 deletions

View File

@ -756,6 +756,12 @@ export class AdminController {
routeDescription: booking.getRouteDescription(), routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(), isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency), price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur,
freightTotal: booking.freightTotal,
freightCurrency: booking.freightCurrency,
fobTotal: booking.fobTotal,
fobCurrency: booking.fobCurrency,
}; };
} }

View File

@ -104,6 +104,44 @@ export class CreateCsvBookingDto {
@IsEnum(['USD', 'EUR']) @IsEnum(['USD', 'EUR'])
primaryCurrency: string; primaryCurrency: string;
@ApiPropertyOptional({
description: 'Freight charge total (in freightCurrency)',
example: 420.0,
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
freightTotal?: number;
@ApiPropertyOptional({
description: 'Currency of the freight charge',
enum: ['USD', 'EUR'],
example: 'USD',
})
@IsOptional()
@IsEnum(['USD', 'EUR'])
freightCurrency?: string;
@ApiPropertyOptional({
description: 'FOB charges total (in fobCurrency)',
example: 245.0,
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
fobTotal?: number;
@ApiPropertyOptional({
description: 'Currency of the FOB charges',
enum: ['USD', 'EUR'],
example: 'EUR',
})
@IsOptional()
@IsEnum(['USD', 'EUR'])
fobCurrency?: string;
@ApiProperty({ @ApiProperty({
description: 'Transit time in days', description: 'Transit time in days',
example: 28, example: 28,
@ -365,6 +403,30 @@ export class CsvBookingResponseDto {
example: 313.27, example: 313.27,
}) })
commissionAmountEur?: number; commissionAmountEur?: number;
@ApiPropertyOptional({
description: 'Freight charge total (in freightCurrency)',
example: 420.0,
})
freightTotal?: number;
@ApiPropertyOptional({
description: 'Currency of the freight charge',
example: 'USD',
})
freightCurrency?: string;
@ApiPropertyOptional({
description: 'FOB charges total (in fobCurrency)',
example: 245.0,
})
fobTotal?: number;
@ApiPropertyOptional({
description: 'Currency of the FOB charges',
example: 'EUR',
})
fobCurrency?: string;
} }
/** /**

View File

@ -207,14 +207,14 @@ export class PlanDetailsDto {
maxLicenses: number; maxLicenses: number;
@ApiProperty({ @ApiProperty({
example: 249, example: 299,
description: 'Monthly price in EUR', description: 'Monthly price in EUR',
}) })
monthlyPriceEur: number; monthlyPriceEur: number;
@ApiProperty({ @ApiProperty({
example: 2739, example: 3289,
description: 'Yearly price in EUR (11 months)', description: 'Yearly price in EUR (11 months — 1 month deducted)',
}) })
yearlyPriceEur: number; yearlyPriceEur: number;
@ -225,10 +225,10 @@ export class PlanDetailsDto {
maxShipmentsPerYear: number; maxShipmentsPerYear: number;
@ApiProperty({ @ApiProperty({
example: 3, example: 10,
description: 'Commission rate percentage on shipments', description: 'Per-booking fee in EUR (-1 for custom / on quote)',
}) })
commissionRatePercent: number; bookingFeeEur: number;
@ApiProperty({ @ApiProperty({
example: 'email', example: 'email',

View File

@ -160,18 +160,16 @@ export class CsvBookingService {
// Upload documents to S3 // Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId); const documents = await this.uploadDocuments(files, bookingId);
// Calculate commission based on organization's subscription plan // Flat per-booking service fee (forfait par booking) based on the org's plan.
let commissionRate = 5; // default Bronze // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
let commissionAmountEur = 0; // booking skips the payment gate and the carrier is notified immediately.
try { const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const requiresPayment = bookingFeeEur > 0;
commissionRate = subscription.plan.commissionRatePercent; const initialStatus = requiresPayment
} catch (error: any) { ? CsvBookingStatus.PENDING_PAYMENT
this.logger.error(`Failed to get subscription for commission: ${error?.message}`); : CsvBookingStatus.PENDING;
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Create domain entity in PENDING_PAYMENT status (no email sent yet) // Create domain entity (no email sent yet when a payment is required)
const booking = new CsvBooking( const booking = new CsvBooking(
bookingId, bookingId,
userId, userId,
@ -188,7 +186,7 @@ export class CsvBookingService {
dto.primaryCurrency, dto.primaryCurrency,
dto.transitDays, dto.transitDays,
dto.containerType, dto.containerType,
CsvBookingStatus.PENDING_PAYMENT, initialStatus,
documents, documents,
confirmationToken, confirmationToken,
new Date(), new Date(),
@ -196,8 +194,13 @@ export class CsvBookingService {
dto.notes, dto.notes,
undefined, undefined,
bookingNumber, bookingNumber,
commissionRate, undefined, // commissionRate — flat fee, no percentage
commissionAmountEur requiresPayment ? bookingFeeEur : 0,
undefined, // stripePaymentIntentId — set after payment
dto.freightTotal,
dto.freightCurrency,
dto.fobTotal,
dto.fobCurrency
); );
// Save to database // Save to database
@ -214,15 +217,37 @@ export class CsvBookingService {
} }
this.logger.log( this.logger.log(
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}` `CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: ${initialStatus}, booking fee: ${bookingFeeEur > 0 ? `${bookingFeeEur}` : 'none (custom)'}`
); );
// NO email sent to carrier yet - will be sent after commission payment if (requiresPayment) {
// NO notification yet - will be created after payment confirmation // Carrier email + notification are sent after the booking fee is paid
} else {
// No fee to collect — notify the carrier immediately
await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword);
await this.notifyBookingRequestSent(booking);
}
return this.toResponseDto(savedBooking); return this.toResponseDto(savedBooking);
} }
/**
* Resolve the flat per-booking fee (forfait par booking) for an organization
* from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the
* plan has a custom fee (-1, e.g. Platinium "sur mesure") or on error such
* bookings are not auto-charged.
*/
private async resolveBookingFeeEur(organizationId: string): Promise<number> {
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const fee = subscription.plan.bookingFeeEur;
return fee > 0 ? fee : 0;
} catch (error: any) {
this.logger.error(`Failed to resolve booking fee: ${error?.message}`);
return 0;
}
}
/** /**
* Create a Stripe Checkout session for commission payment * Create a Stripe Checkout session for commission payment
*/ */
@ -250,7 +275,7 @@ export class CsvBookingService {
const commissionAmountEur = booking.commissionAmountEur || 0; const commissionAmountEur = booking.commissionAmountEur || 0;
if (commissionAmountEur <= 0) { if (commissionAmountEur <= 0) {
throw new BadRequestException('Commission amount is invalid'); throw new BadRequestException('Booking fee amount is invalid');
} }
const amountCents = Math.round(commissionAmountEur * 100); const amountCents = Math.round(commissionAmountEur * 100);
@ -261,13 +286,13 @@ export class CsvBookingService {
currency: 'eur', currency: 'eur',
customerEmail: userEmail, customerEmail: userEmail,
organizationId: booking.organizationId, organizationId: booking.organizationId,
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`, bookingDescription: `Frais de booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`,
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`, successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`, cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
}); });
this.logger.log( this.logger.log(
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR` `Created Stripe booking-fee checkout for booking ${bookingId}: ${amountCents} cents EUR`
); );
return { return {
@ -332,7 +357,22 @@ export class CsvBookingService {
const bookingNumber = ormBooking?.bookingNumber; const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = await this.syncDocumentPassword(booking.id); const documentPassword = await this.syncDocumentPassword(booking.id);
// NOW send email to carrier // NOW send email to carrier + notify the user (booking fee has been paid)
await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword);
await this.notifyBookingRequestSent(booking);
return this.toResponseDto(updatedBooking);
}
/**
* Send the booking request email to the carrier.
* Used both when no booking fee is due (at creation) and after the fee is paid.
*/
private async sendCarrierBookingRequest(
booking: CsvBooking,
bookingNumber: string | null | undefined,
documentPassword: string | null | undefined
): Promise<void> {
try { try {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id, bookingId: booking.id,
@ -359,8 +399,12 @@ export class CsvBookingService {
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
} }
}
// Create notification for user /**
* Notify the user that their booking request has been sent to the carrier.
*/
private async notifyBookingRequestSent(booking: CsvBooking): Promise<void> {
try { try {
const notification = Notification.create({ const notification = Notification.create({
id: uuidv4(), id: uuidv4(),
@ -369,15 +413,13 @@ export class CsvBookingService {
type: NotificationType.CSV_BOOKING_REQUEST_SENT, type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM, priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent', title: 'Booking Request Sent',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`, message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully.`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName }, metadata: { bookingId: booking.id, carrierName: booking.carrierName },
}); });
await this.notificationRepository.save(notification); await this.notificationRepository.save(notification);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
} }
return this.toResponseDto(updatedBooking);
} }
/** /**
@ -795,20 +837,12 @@ export class CsvBookingService {
// Accept the booking (domain logic validates status) // Accept the booking (domain logic validates status)
booking.accept(); booking.accept();
// Apply commission based on organization's subscription plan // Apply the flat per-booking service fee (forfait par booking) from the org's plan
try { const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
const subscription = await this.subscriptionService.getOrCreateSubscription( booking.applyBookingFee(bookingFeeEur);
booking.organizationId
);
const commissionRate = subscription.plan.commissionRatePercent;
const baseAmountEur = booking.priceEUR;
booking.applyCommission(commissionRate, baseAmountEur);
this.logger.log( this.logger.log(
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}` `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
); );
} catch (error: any) {
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
}
// Save updated booking // Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking); const updatedBooking = await this.csvBookingRepository.update(booking);
@ -1382,6 +1416,10 @@ export class CsvBookingService {
price: booking.getPriceInCurrency(primaryCurrency), price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate, commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur, commissionAmountEur: booking.commissionAmountEur,
freightTotal: booking.freightTotal,
freightCurrency: booking.freightCurrency,
fobTotal: booking.fobTotal,
fobCurrency: booking.fobCurrency,
}; };
} }

View File

@ -674,7 +674,7 @@ export class SubscriptionService {
monthlyPriceEur: plan.monthlyPriceEur, monthlyPriceEur: plan.monthlyPriceEur,
yearlyPriceEur: plan.yearlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur,
maxShipmentsPerYear: plan.maxShipmentsPerYear, maxShipmentsPerYear: plan.maxShipmentsPerYear,
commissionRatePercent: plan.commissionRatePercent, bookingFeeEur: plan.bookingFeeEur,
supportLevel: plan.supportLevel, supportLevel: plan.supportLevel,
statusBadge: plan.statusBadge, statusBadge: plan.statusBadge,
planFeatures: [...plan.planFeatures], planFeatures: [...plan.planFeatures],

View File

@ -484,4 +484,24 @@ describe('CsvBooking Entity', () => {
expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test
}); });
}); });
describe('applyBookingFee', () => {
it('should set a flat per-booking fee and clear the percentage rate', () => {
const booking = createValidBooking();
booking.applyBookingFee(10);
expect(booking.commissionAmountEur).toBe(10);
expect(booking.commissionRate).toBeUndefined();
});
it('should store no fee when the plan fee is custom or zero', () => {
const booking = createValidBooking();
booking.applyBookingFee(-1);
expect(booking.commissionAmountEur).toBe(0);
expect(booking.commissionRate).toBeUndefined();
});
});
}); });

View File

@ -85,7 +85,13 @@ export class CsvBooking {
public readonly bookingNumber?: string, public readonly bookingNumber?: string,
public commissionRate?: number, public commissionRate?: number,
public commissionAmountEur?: number, public commissionAmountEur?: number,
public stripePaymentIntentId?: string public stripePaymentIntentId?: string,
// Detailed transport cost breakdown (informational — paid to the carrier,
// not collected by Xpeditis). Freight and FOB may be in different currencies.
public readonly freightTotal?: number,
public readonly freightCurrency?: string,
public readonly fobTotal?: number,
public readonly fobCurrency?: string
) { ) {
this.validate(); this.validate();
} }
@ -151,12 +157,27 @@ export class CsvBooking {
/** /**
* Apply commission to the booking * Apply commission to the booking
*
* @deprecated Commission has been replaced by a flat per-booking fee.
* Use {@link applyBookingFee} instead.
*/ */
applyCommission(ratePercent: number, baseAmountEur: number): void { applyCommission(ratePercent: number, baseAmountEur: number): void {
this.commissionRate = ratePercent; this.commissionRate = ratePercent;
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100; this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
} }
/**
* Apply the flat per-booking service fee (forfait par booking).
*
* Replaces the percentage-based commission: the amount is the subscription
* plan's fixed booking fee, so the percentage rate is cleared. A fee <= 0
* (e.g. Platinium "sur mesure") means no automatic charge.
*/
applyBookingFee(feeEur: number): void {
this.commissionRate = undefined;
this.commissionAmountEur = feeEur > 0 ? feeEur : 0;
}
/** /**
* Mark commission payment as completed transition to PENDING * Mark commission payment as completed transition to PENDING
* *
@ -434,7 +455,11 @@ export class CsvBooking {
bookingNumber?: string, bookingNumber?: string,
commissionRate?: number, commissionRate?: number,
commissionAmountEur?: number, commissionAmountEur?: number,
stripePaymentIntentId?: string stripePaymentIntentId?: string,
freightTotal?: number,
freightCurrency?: string,
fobTotal?: number,
fobCurrency?: string
): CsvBooking { ): CsvBooking {
// Create instance without calling constructor validation // Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype); const booking = Object.create(CsvBooking.prototype);
@ -466,6 +491,10 @@ export class CsvBooking {
booking.commissionRate = commissionRate; booking.commissionRate = commissionRate;
booking.commissionAmountEur = commissionAmountEur; booking.commissionAmountEur = commissionAmountEur;
booking.stripePaymentIntentId = stripePaymentIntentId; booking.stripePaymentIntentId = stripePaymentIntentId;
booking.freightTotal = freightTotal;
booking.freightCurrency = freightCurrency;
booking.fobTotal = fobTotal;
booking.fobCurrency = fobCurrency;
return booking; return booking;
} }

View File

@ -80,10 +80,10 @@ export class Subscription {
} }
/** /**
* Get the commission rate for this subscription's plan * Get the per-booking fee for this subscription's plan
*/ */
get commissionRatePercent(): number { get bookingFeeEur(): number {
return this.props.plan.commissionRatePercent; return this.props.plan.bookingFeeEur;
} }
/** /**

View File

@ -233,8 +233,30 @@ describe('SubscriptionPlan Value Object', () => {
it('should return correct prices for SILVER plan', () => { it('should return correct prices for SILVER plan', () => {
const plan = SubscriptionPlan.silver(); const plan = SubscriptionPlan.silver();
expect(plan.monthlyPriceEur).toBe(249); expect(plan.monthlyPriceEur).toBe(299);
expect(plan.yearlyPriceEur).toBe(2739); // Annual billing = 11 months (1 month deducted)
expect(plan.yearlyPriceEur).toBe(plan.monthlyPriceEur * 11);
expect(plan.yearlyPriceEur).toBe(3289);
});
it('should bill the yearly price over 11 months for GOLD plan', () => {
const plan = SubscriptionPlan.gold();
expect(plan.monthlyPriceEur).toBe(799);
expect(plan.yearlyPriceEur).toBe(plan.monthlyPriceEur * 11);
expect(plan.yearlyPriceEur).toBe(8789);
});
it('should limit BRONZE to 5 bookings per year', () => {
expect(SubscriptionPlan.bronze().maxShipmentsPerYear).toBe(5);
});
it('should expose per-booking fees per plan', () => {
expect(SubscriptionPlan.bronze().bookingFeeEur).toBe(15);
expect(SubscriptionPlan.silver().bookingFeeEur).toBe(10);
expect(SubscriptionPlan.gold().bookingFeeEur).toBe(5);
expect(SubscriptionPlan.platinium().bookingFeeEur).toBe(-1);
expect(SubscriptionPlan.platinium().hasCustomBookingFee()).toBe(true);
expect(SubscriptionPlan.gold().hasCustomBookingFee()).toBe(false);
}); });
it('should return features for GOLD plan', () => { it('should return features for GOLD plan', () => {

View File

@ -2,10 +2,16 @@
* Subscription Plan Value Object * Subscription Plan Value Object
* *
* Represents the different subscription plans available for organizations. * Represents the different subscription plans available for organizations.
* Each plan has a maximum number of licenses, shipment limits, commission rates, * Each plan has a maximum number of licenses, shipment limits, a per-booking fee,
* feature flags, and support levels. * feature flags, and support levels.
* *
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom) * Plans: BRONZE (free), SILVER (299EUR/mo), GOLD (799EUR/mo), PLATINIUM (custom)
*
* Annual billing: one month is deducted, so the yearly price is billed over
* 11 months (yearlyPriceEur = monthlyPriceEur * 11).
*
* Each plan also has a per-booking fee (bookingFeeEur). For BRONZE it applies to
* bookings beyond the yearly limit ("scaling"); for PLATINIUM it is custom (-1).
*/ */
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
@ -29,9 +35,9 @@ interface PlanDetails {
readonly name: string; readonly name: string;
readonly maxLicenses: number; // -1 means unlimited readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number; readonly monthlyPriceEur: number;
readonly yearlyPriceEur: number; readonly yearlyPriceEur: number; // monthlyPriceEur * 11 (annual billing = 11 months)
readonly maxShipmentsPerYear: number; // -1 means unlimited readonly maxShipmentsPerYear: number; // -1 means unlimited
readonly commissionRatePercent: number; readonly bookingFeeEur: number; // per-booking fee; -1 means custom (on quote)
readonly statusBadge: StatusBadge; readonly statusBadge: StatusBadge;
readonly supportLevel: SupportLevel; readonly supportLevel: SupportLevel;
readonly planFeatures: readonly PlanFeature[]; readonly planFeatures: readonly PlanFeature[];
@ -44,20 +50,25 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
maxLicenses: 1, maxLicenses: 1,
monthlyPriceEur: 0, monthlyPriceEur: 0,
yearlyPriceEur: 0, yearlyPriceEur: 0,
maxShipmentsPerYear: 12, maxShipmentsPerYear: 5,
commissionRatePercent: 5, bookingFeeEur: 15,
statusBadge: 'none', statusBadge: 'none',
supportLevel: 'none', supportLevel: 'none',
planFeatures: PLAN_FEATURES.BRONZE, planFeatures: PLAN_FEATURES.BRONZE,
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'], features: [
'1 utilisateur',
'5 bookings par an',
'Recherche de tarifs basique',
'Frais de booking pour scaler : 15 €',
],
}, },
SILVER: { SILVER: {
name: 'Silver', name: 'Silver',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 249, monthlyPriceEur: 299,
yearlyPriceEur: 2739, yearlyPriceEur: 3289,
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 3, bookingFeeEur: 10,
statusBadge: 'silver', statusBadge: 'silver',
supportLevel: 'email', supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER, planFeatures: PLAN_FEATURES.SILVER,
@ -69,15 +80,16 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
'Gestion des utilisateurs', 'Gestion des utilisateurs',
'Import CSV', 'Import CSV',
'Support par email', 'Support par email',
'Forfait par booking : 10 €',
], ],
}, },
GOLD: { GOLD: {
name: 'Gold', name: 'Gold',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 899, monthlyPriceEur: 799,
yearlyPriceEur: 9889, yearlyPriceEur: 8789,
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 2, bookingFeeEur: 5,
statusBadge: 'gold', statusBadge: 'gold',
supportLevel: 'direct', supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD, planFeatures: PLAN_FEATURES.GOLD,
@ -87,6 +99,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
'Toutes les fonctionnalités Silver', 'Toutes les fonctionnalités Silver',
'Intégration API', 'Intégration API',
'Assistance commerciale directe', 'Assistance commerciale directe',
'Forfait par booking : 5 €',
], ],
}, },
PLATINIUM: { PLATINIUM: {
@ -95,7 +108,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
monthlyPriceEur: 0, // custom pricing monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 1, bookingFeeEur: -1, // custom / on quote
statusBadge: 'platinium', statusBadge: 'platinium',
supportLevel: 'dedicated_kam', supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM, planFeatures: PLAN_FEATURES.PLATINIUM,
@ -105,6 +118,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
'Key Account Manager dédié', 'Key Account Manager dédié',
'Interface personnalisable', 'Interface personnalisable',
'Contrats tarifaires cadre', 'Contrats tarifaires cadre',
'Forfait par booking : sur mesure',
], ],
}, },
}; };
@ -209,8 +223,12 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].maxShipmentsPerYear; return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
} }
get commissionRatePercent(): number { get bookingFeeEur(): number {
return PLAN_DETAILS[this.plan].commissionRatePercent; return PLAN_DETAILS[this.plan].bookingFeeEur;
}
hasCustomBookingFee(): boolean {
return PLAN_DETAILS[this.plan].bookingFeeEur === -1;
} }
get statusBadge(): StatusBadge { get statusBadge(): StatusBadge {

View File

@ -169,6 +169,20 @@ export class CsvBookingOrmEntity {
}) })
commissionAmountEur: number | null; commissionAmountEur: number | null;
// Transport cost breakdown (informational — paid to the carrier, not Xpeditis).
// Freight and FOB may be quoted in different currencies.
@Column({ name: 'freight_total', type: 'decimal', precision: 12, scale: 2, nullable: true })
freightTotal: number | null;
@Column({ name: 'freight_currency', type: 'varchar', length: 3, nullable: true })
freightCurrency: string | null;
@Column({ name: 'fob_total', type: 'decimal', precision: 12, scale: 2, nullable: true })
fobTotal: number | null;
@Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true })
fobCurrency: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date; createdAt: Date;

View File

@ -45,7 +45,11 @@ export class CsvBookingMapper {
ormEntity.bookingNumber ?? undefined, ormEntity.bookingNumber ?? undefined,
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined, ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined, ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
ormEntity.stripePaymentIntentId ?? undefined ormEntity.stripePaymentIntentId ?? undefined,
ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined,
ormEntity.freightCurrency ?? undefined,
ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined,
ormEntity.fobCurrency ?? undefined
); );
} }
@ -79,6 +83,10 @@ export class CsvBookingMapper {
stripePaymentIntentId: domain.stripePaymentIntentId ?? null, stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null, commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null, commissionAmountEur: domain.commissionAmountEur ?? null,
freightTotal: domain.freightTotal ?? null,
freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null,
}; };
} }

View File

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the transport cost breakdown (freight + FOB) to csv_bookings.
*
* These amounts are informational: they are paid to the carrier, not collected
* by Xpeditis (which only charges the flat per-booking fee). Freight and FOB may
* be quoted in different currencies, so each amount keeps its own currency.
*/
export class AddFreightFobToCsvBookings1740000000003 implements MigrationInterface {
name = 'AddFreightFobToCsvBookings1740000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "csv_bookings"
ADD COLUMN IF NOT EXISTS "freight_total" DECIMAL(12,2),
ADD COLUMN IF NOT EXISTS "freight_currency" VARCHAR(3),
ADD COLUMN IF NOT EXISTS "fob_total" DECIMAL(12,2),
ADD COLUMN IF NOT EXISTS "fob_currency" VARCHAR(3)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "csv_bookings"
DROP COLUMN IF EXISTS "fob_currency",
DROP COLUMN IF EXISTS "fob_total",
DROP COLUMN IF EXISTS "freight_currency",
DROP COLUMN IF EXISTS "freight_total"
`);
}
}

View File

@ -138,7 +138,7 @@ export class StripeAdapter implements StripePort {
currency: input.currency, currency: input.currency,
unit_amount: input.amountCents, unit_amount: input.amountCents,
product_data: { product_data: {
name: 'Commission Xpeditis', name: 'Frais de booking Xpeditis',
description: input.bookingDescription, description: input.bookingDescription,
}, },
}, },

View File

@ -19,6 +19,11 @@ interface Booking {
priceEUR?: number; priceEUR?: number;
priceUSD?: number; priceUSD?: number;
primaryCurrency?: string; primaryCurrency?: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
commissionAmountEur?: number;
createdAt?: string; createdAt?: string;
requestedAt?: string; requestedAt?: string;
updatedAt?: string; updatedAt?: string;
@ -116,6 +121,9 @@ export default function AdminBookingsPage() {
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`; const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
const formatMoney = (amount: number, currency: string) =>
new Intl.NumberFormat(dateLocale, { style: 'currency', currency }).format(amount);
const filteredBookings = bookings const filteredBookings = bookings
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
.filter(booking => { .filter(booking => {
@ -471,7 +479,7 @@ export default function AdminBookingsPage() {
{/* Details Modal */} {/* Details Modal */}
{showDetailsModal && selectedBooking && ( {showDetailsModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full"> <div className="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2> <h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
<button <button
@ -514,6 +522,7 @@ export default function AdminBookingsPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-4">
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3"> <h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.routeSection')} {t('modal.routeSection')}
@ -592,11 +601,56 @@ export default function AdminBookingsPage() {
</div> </div>
</div> </div>
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && ( {(selectedBooking.priceEUR != null ||
selectedBooking.priceUSD != null ||
selectedBooking.freightTotal != null ||
selectedBooking.fobTotal != null) && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3"> <h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.priceSection')} {t('modal.priceSection')}
</h3> </h3>
{/* Transport breakdown — Fret + FOB (paid to carrier) */}
{(selectedBooking.freightTotal != null || selectedBooking.fobTotal != null) && (
<div className="space-y-2 mb-4">
{selectedBooking.freightTotal != null && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">{t('modal.freight')}</span>
<span className="font-semibold text-gray-900">
{formatMoney(
selectedBooking.freightTotal,
selectedBooking.freightCurrency ||
selectedBooking.primaryCurrency ||
'USD'
)}
</span>
</div>
)}
{selectedBooking.fobTotal != null && selectedBooking.fobTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">{t('modal.fob')}</span>
<span className="font-semibold text-gray-900">
{formatMoney(
selectedBooking.fobTotal,
selectedBooking.fobCurrency || 'EUR'
)}
</span>
</div>
)}
{selectedBooking.commissionAmountEur != null &&
selectedBooking.commissionAmountEur > 0 && (
<div className="flex justify-between text-sm border-t pt-2">
<span className="text-gray-500">{t('modal.bookingFee')}</span>
<span className="font-semibold text-gray-900">
{formatMoney(selectedBooking.commissionAmountEur, 'EUR')}
</span>
</div>
)}
<p className="text-xs text-gray-400 pt-1">{t('modal.transportNote')}</p>
</div>
)}
{/* Per-currency totals (also covers legacy bookings without a breakdown) */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && ( {selectedBooking.priceEUR != null && (
<div> <div>
@ -641,6 +695,7 @@ export default function AdminBookingsPage() {
)} )}
</div> </div>
</div> </div>
</div>
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && ( {selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
<div className="border-t pt-4"> <div className="border-t pt-4">

View File

@ -44,6 +44,10 @@ interface BookingData {
status: string; status: string;
commissionRate?: number; commissionRate?: number;
commissionAmountEur?: number; commissionAmountEur?: number;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
} }
interface OrgData { interface OrgData {
@ -188,10 +192,23 @@ export default function PayCommissionPage() {
if (!booking) return null; if (!booking) return null;
const commissionAmount = booking.commissionAmountEur || 0; const bookingFeeAmount = booking.commissionAmountEur || 0;
const commissionRate = booking.commissionRate || 0;
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase(); const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
// Transport cost breakdown (informational — paid to the carrier, not collected here).
// Freight and FOB may be in different currencies, so they are shown separately.
const freightTotal = booking.freightTotal ?? null;
const fobTotal = booking.fobTotal ?? null;
const freightCurrency = booking.freightCurrency || booking.primaryCurrency || 'USD';
const fobCurrency = booking.fobCurrency || 'EUR';
const hasBreakdown = freightTotal != null || fobTotal != null;
// Single transport total, always shown in USD = freight + FOB summed.
// New bookings store the freight/FOB breakdown; legacy ones only keep the
// per-currency price sums. In both cases the charges are added together.
const transportTotalUSD = hasBreakdown
? (freightTotal ?? 0) + (fobTotal ?? 0)
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
@ -204,9 +221,9 @@ export default function PayCommissionPage() {
Retour aux bookings Retour aux bookings
</button> </button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement de la commission</h1> <h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
<p className="text-gray-500 mb-8"> <p className="text-gray-500 mb-8">
Finalisez votre booking en réglant la commission de service Finalisez votre booking en réglant le forfait par booking
</p> </p>
{/* SIRET/SIREN non vérifié — bannière de demande */} {/* SIRET/SIREN non vérifié — bannière de demande */}
@ -374,7 +391,7 @@ export default function PayCommissionPage() {
) : ( ) : (
<> <>
<CreditCard className="h-5 w-5" /> <CreditCard className="h-5 w-5" />
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span> <span>Payer {formatPrice(bookingFeeAmount, 'EUR')} par carte</span>
</> </>
)} )}
</button> </button>
@ -397,7 +414,7 @@ export default function PayCommissionPage() {
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true }, { label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
{ {
label: 'Montant', label: 'Montant',
value: formatPrice(commissionAmount, 'EUR'), value: formatPrice(bookingFeeAmount, 'EUR'),
key: 'amount', key: 'amount',
bold: true, bold: true,
}, },
@ -500,22 +517,45 @@ export default function PayCommissionPage() {
<span className="text-gray-500">Transit</span> <span className="text-gray-500">Transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span> <span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div> </div>
{/* Transport cost breakdown — Fret + FOB (paid to the carrier) */}
{hasBreakdown && (
<>
{freightTotal != null && (
<div className="border-t pt-3 flex justify-between text-sm"> <div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Prix transport</span> <span className="text-gray-500">Fret</span>
<span className="font-bold text-gray-900"> <span className="font-semibold text-gray-900">
{formatPrice(booking.priceEUR, 'EUR')} {formatPrice(freightTotal, freightCurrency)}
</span> </span>
</div> </div>
)}
{fobTotal != null && fobTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Frais FOB</span>
<span className="font-semibold text-gray-900">
{formatPrice(fobTotal, fobCurrency)}
</span>
</div>
)}
</>
)}
{/* Single transport total — always in USD (fret + FOB) */}
<div className="flex justify-between text-sm border-t pt-3">
<span className="text-gray-700 font-medium">Total</span>
<span className="font-bold text-gray-900">
{formatPrice(transportTotalUSD, 'USD')}
</span>
</div>
<p className="text-xs text-gray-400 pt-1">
Fret et frais FOB sont réglés directement au transporteur.
</p>
</div> </div>
{/* Commission box */} {/* Booking fee box */}
<div className="bg-blue-600 rounded-xl p-5 text-white"> <div className="bg-blue-600 rounded-xl p-5 text-white">
<p className="text-sm text-blue-100 mb-1"> <p className="text-sm text-blue-100 mb-1">Forfait par booking à régler maintenant</p>
Commission ({commissionRate}% du prix transport) <p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
</p>
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
<p className="text-xs text-blue-200 mt-1"> <p className="text-xs text-blue-200 mt-1">
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}% Montant fixe par booking (hors fret et frais FOB)
</p> </p>
</div> </div>

View File

@ -81,8 +81,8 @@ export default function PaymentSuccessPage() {
</div> </div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2> <h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec Votre forfait booking a ete paye avec succes. Un email a ete envoye au transporteur
votre demande de booking. avec votre demande de booking.
</p> </p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">

View File

@ -181,6 +181,11 @@ function NewBookingPageContent() {
formDataToSend.append('priceUSD', priceUSD.toString()); formDataToSend.append('priceUSD', priceUSD.toString());
formDataToSend.append('priceEUR', priceEUR.toString()); formDataToSend.append('priceEUR', priceEUR.toString());
formDataToSend.append('primaryCurrency', formData.primaryCurrency); formDataToSend.append('primaryCurrency', formData.primaryCurrency);
// Transport cost breakdown (informational — paid to the carrier)
formDataToSend.append('freightTotal', formData.freightTotal.toString());
formDataToSend.append('freightCurrency', formData.freightCurrency);
formDataToSend.append('fobTotal', formData.fobTotal.toString());
formDataToSend.append('fobCurrency', formData.fobCurrency);
formDataToSend.append('transitDays', formData.transitDays.toString()); formDataToSend.append('transitDays', formData.transitDays.toString());
formDataToSend.append('containerType', formData.containerType); formDataToSend.append('containerType', formData.containerType);

View File

@ -209,7 +209,7 @@ export default function LandingPage() {
monthlyPrice: 0, monthlyPrice: 0,
yearlyPrice: 0, yearlyPrice: 0,
yearlyMonthly: 0, yearlyMonthly: 0,
commission: '5%', bookingFee: 15,
ctaLink: '/register', ctaLink: '/register',
highlighted: false, highlighted: false,
accentColor: 'from-amber-600 to-yellow-500', accentColor: 'from-amber-600 to-yellow-500',
@ -219,10 +219,10 @@ export default function LandingPage() {
{ {
key: 'silver', key: 'silver',
badge: 'popular', badge: 'popular',
monthlyPrice: 249, monthlyPrice: 299,
yearlyPrice: 2739, yearlyPrice: 3289,
yearlyMonthly: 228, yearlyMonthly: 274,
commission: '3%', bookingFee: 10,
ctaLink: '/register', ctaLink: '/register',
highlighted: true, highlighted: true,
accentColor: 'from-slate-400 to-slate-500', accentColor: 'from-slate-400 to-slate-500',
@ -232,10 +232,10 @@ export default function LandingPage() {
{ {
key: 'gold', key: 'gold',
badge: null, badge: null,
monthlyPrice: 899, monthlyPrice: 799,
yearlyPrice: 9889, yearlyPrice: 8789,
yearlyMonthly: 824, yearlyMonthly: 732,
commission: '2%', bookingFee: 5,
ctaLink: '/register', ctaLink: '/register',
highlighted: false, highlighted: false,
accentColor: 'from-yellow-400 to-amber-400', accentColor: 'from-yellow-400 to-amber-400',
@ -248,7 +248,7 @@ export default function LandingPage() {
monthlyPrice: null, monthlyPrice: null,
yearlyPrice: null, yearlyPrice: null,
yearlyMonthly: null, yearlyMonthly: null,
commission: '1%', bookingFee: -1,
ctaLink: '/contact', ctaLink: '/contact',
highlighted: false, highlighted: false,
accentColor: 'from-brand-navy to-brand-turquoise', accentColor: 'from-brand-navy to-brand-turquoise',
@ -651,6 +651,10 @@ export default function LandingPage() {
const planDescription = t(`pricing.plans.${plan.key}.description` as any); const planDescription = t(`pricing.plans.${plan.key}.description` as any);
const planUsers = t(`pricing.plans.${plan.key}.users` as any); const planUsers = t(`pricing.plans.${plan.key}.users` as any);
const planShipments = t(`pricing.plans.${plan.key}.shipments` as any); const planShipments = t(`pricing.plans.${plan.key}.shipments` as any);
const planBookingFee =
plan.bookingFee === -1
? t('pricing.bookingFeeCustom')
: t('pricing.bookingFee', { price: `${plan.bookingFee}` });
const planCta = t(`pricing.plans.${plan.key}.cta` as any); const planCta = t(`pricing.plans.${plan.key}.cta` as any);
const planFeatures = planFeaturesByKey[plan.key] ?? []; const planFeatures = planFeaturesByKey[plan.key] ?? [];
@ -777,11 +781,11 @@ export default function LandingPage() {
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" /> <Package className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span <span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`} className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
> >
{t('pricing.commission', { rate: plan.commission })} {planBookingFee}
</span> </span>
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@ interface PlanConfig {
maxUsersKey: 'unlimited' | null; maxUsersKey: 'unlimited' | null;
maxUsers?: number; maxUsers?: number;
shipmentsKey: 'shipmentsPerYear' | 'shipmentsUnlimited'; shipmentsKey: 'shipmentsPerYear' | 'shipmentsUnlimited';
commission: string; bookingFee: number; // -1 means custom / on quote
supportKey: 'supportNone' | 'supportEmail' | 'supportDirect' | 'supportKam'; supportKey: 'supportNone' | 'supportEmail' | 'supportDirect' | 'supportKam';
badge: 'silver' | 'gold' | 'platinium' | null; badge: 'silver' | 'gold' | 'platinium' | null;
ctaStyle: string; ctaStyle: string;
@ -38,7 +38,7 @@ const PLANS: PlanConfig[] = [
maxUsers: 1, maxUsers: 1,
maxUsersKey: null, maxUsersKey: null,
shipmentsKey: 'shipmentsPerYear', shipmentsKey: 'shipmentsPerYear',
commission: '5%', bookingFee: 15,
supportKey: 'supportNone', supportKey: 'supportNone',
badge: null, badge: null,
ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800', ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800',
@ -57,12 +57,12 @@ const PLANS: PlanConfig[] = [
}, },
{ {
key: 'silver', key: 'silver',
monthlyPrice: 249, monthlyPrice: 299,
yearlyPrice: 2739, yearlyPrice: 3289,
maxUsers: 5, maxUsers: 5,
maxUsersKey: null, maxUsersKey: null,
shipmentsKey: 'shipmentsUnlimited', shipmentsKey: 'shipmentsUnlimited',
commission: '3%', bookingFee: 10,
supportKey: 'supportEmail', supportKey: 'supportEmail',
badge: 'silver', badge: 'silver',
ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90', ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90',
@ -81,12 +81,12 @@ const PLANS: PlanConfig[] = [
}, },
{ {
key: 'gold', key: 'gold',
monthlyPrice: 899, monthlyPrice: 799,
yearlyPrice: 9889, yearlyPrice: 8789,
maxUsers: 20, maxUsers: 20,
maxUsersKey: null, maxUsersKey: null,
shipmentsKey: 'shipmentsUnlimited', shipmentsKey: 'shipmentsUnlimited',
commission: '2%', bookingFee: 5,
supportKey: 'supportDirect', supportKey: 'supportDirect',
badge: 'gold', badge: 'gold',
ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600', ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600',
@ -109,7 +109,7 @@ const PLANS: PlanConfig[] = [
yearlyPrice: -1, yearlyPrice: -1,
maxUsersKey: 'unlimited', maxUsersKey: 'unlimited',
shipmentsKey: 'shipmentsUnlimited', shipmentsKey: 'shipmentsUnlimited',
commission: '1%', bookingFee: -1,
supportKey: 'supportKam', supportKey: 'supportKam',
badge: 'platinium', badge: 'platinium',
ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700', ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700',
@ -283,8 +283,12 @@ export default function PricingPage() {
<span className="font-medium">{t(`values.${plan.shipmentsKey}`)}</span> <span className="font-medium">{t(`values.${plan.shipmentsKey}`)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">{t('stats.commission')}</span> <span className="text-gray-500">{t('stats.bookingFee')}</span>
<span className="font-medium">{plan.commission}</span> <span className="font-medium">
{plan.bookingFee === -1
? t('values.bookingFeeCustom')
: t('values.bookingFeePerBooking', { price: formatPrice(plan.bookingFee) })}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">{t('stats.support')}</span> <span className="text-gray-500">{t('stats.support')}</span>

View File

@ -203,7 +203,8 @@
"perMonth": "/month", "perMonth": "/month",
"billedYearly": "Billed {price}€/year", "billedYearly": "Billed {price}€/year",
"saveWithYearly": "Save 1 month with yearly", "saveWithYearly": "Save 1 month with yearly",
"commission": "Commission {rate}", "bookingFee": "{price} / booking",
"bookingFeeCustom": "Custom booking fee",
"noCommitment": "No commitment · Cancel at any time", "noCommitment": "No commitment · Cancel at any time",
"questions": "Questions?", "questions": "Questions?",
"contactSales": "Contact our sales team", "contactSales": "Contact our sales team",
@ -214,7 +215,7 @@
"name": "Bronze", "name": "Bronze",
"description": "To discover the platform", "description": "To discover the platform",
"users": "1 user", "users": "1 user",
"shipments": "12 shipments / year", "shipments": "5 bookings / year",
"support": "No support", "support": "No support",
"cta": "Start for free" "cta": "Start for free"
}, },
@ -1050,6 +1051,10 @@
"weight": "Weight", "weight": "Weight",
"volume": "Volume", "volume": "Volume",
"priceSection": "Price", "priceSection": "Price",
"freight": "Freight",
"fob": "FOB charges",
"bookingFee": "Xpeditis fee",
"transportNote": "Freight and FOB are paid to the carrier; the fee is collected by Xpeditis.",
"datesSection": "Dates", "datesSection": "Dates",
"createdAt": "Created on", "createdAt": "Created on",
"updatedAt": "Updated", "updatedAt": "Updated",
@ -3405,7 +3410,7 @@
"stats": { "stats": {
"users": "Users", "users": "Users",
"shipments": "Shipments", "shipments": "Shipments",
"commission": "Commission", "bookingFee": "Fee / booking",
"support": "Support" "support": "Support"
}, },
"features": { "features": {
@ -3421,8 +3426,10 @@
}, },
"values": { "values": {
"unlimited": "Unlimited", "unlimited": "Unlimited",
"shipmentsPerYear": "12/year", "shipmentsPerYear": "5/year",
"shipmentsUnlimited": "Unlimited", "shipmentsUnlimited": "Unlimited",
"bookingFeePerBooking": "{price} / booking",
"bookingFeeCustom": "Custom",
"supportNone": "None", "supportNone": "None",
"supportEmail": "Email", "supportEmail": "Email",
"supportDirect": "Direct", "supportDirect": "Direct",

View File

@ -203,7 +203,8 @@
"perMonth": "/mois", "perMonth": "/mois",
"billedYearly": "Facturé {price}€/an", "billedYearly": "Facturé {price}€/an",
"saveWithYearly": "Économisez 1 mois avec l'annuel", "saveWithYearly": "Économisez 1 mois avec l'annuel",
"commission": "Commission {rate}", "bookingFee": "Forfait {price} / booking",
"bookingFeeCustom": "Forfait booking sur mesure",
"noCommitment": "Sans engagement · Résiliable à tout moment", "noCommitment": "Sans engagement · Résiliable à tout moment",
"questions": "Des questions ?", "questions": "Des questions ?",
"contactSales": "Contactez notre équipe commerciale", "contactSales": "Contactez notre équipe commerciale",
@ -214,7 +215,7 @@
"name": "Bronze", "name": "Bronze",
"description": "Pour découvrir la plateforme", "description": "Pour découvrir la plateforme",
"users": "1 utilisateur", "users": "1 utilisateur",
"shipments": "12 expéditions / an", "shipments": "5 bookings / an",
"support": "Aucun support", "support": "Aucun support",
"cta": "Commencer gratuitement" "cta": "Commencer gratuitement"
}, },
@ -1050,6 +1051,10 @@
"weight": "Poids", "weight": "Poids",
"volume": "Volume", "volume": "Volume",
"priceSection": "Prix", "priceSection": "Prix",
"freight": "Fret",
"fob": "Frais FOB",
"bookingFee": "Forfait Xpeditis",
"transportNote": "Fret et FOB sont réglés au transporteur ; le forfait est encaissé par Xpeditis.",
"datesSection": "Dates", "datesSection": "Dates",
"createdAt": "Créée le", "createdAt": "Créée le",
"updatedAt": "Mise à jour", "updatedAt": "Mise à jour",
@ -3411,7 +3416,7 @@
"stats": { "stats": {
"users": "Utilisateurs", "users": "Utilisateurs",
"shipments": "Expéditions", "shipments": "Expéditions",
"commission": "Commission", "bookingFee": "Forfait / booking",
"support": "Support" "support": "Support"
}, },
"features": { "features": {
@ -3427,8 +3432,10 @@
}, },
"values": { "values": {
"unlimited": "Illimité", "unlimited": "Illimité",
"shipmentsPerYear": "12/an", "shipmentsPerYear": "5/an",
"shipmentsUnlimited": "Illimitées", "shipmentsUnlimited": "Illimitées",
"bookingFeePerBooking": "{price} / booking",
"bookingFeeCustom": "Sur mesure",
"supportNone": "Aucun", "supportNone": "Aucun",
"supportEmail": "Email", "supportEmail": "Email",
"supportDirect": "Direct", "supportDirect": "Direct",

View File

@ -48,7 +48,7 @@ export interface PlanDetails {
monthlyPriceEur: number; monthlyPriceEur: number;
yearlyPriceEur: number; yearlyPriceEur: number;
maxShipmentsPerYear: number; maxShipmentsPerYear: number;
commissionRatePercent: number; bookingFeeEur: number;
supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam'; supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam';
statusBadge: 'none' | 'silver' | 'gold' | 'platinium'; statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
planFeatures: PlanFeature[]; planFeatures: PlanFeature[];