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(),
isExpired: booking.isExpired(),
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'])
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({
description: 'Transit time in days',
example: 28,
@ -365,6 +403,30 @@ export class CsvBookingResponseDto {
example: 313.27,
})
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;
@ApiProperty({
example: 249,
example: 299,
description: 'Monthly price in EUR',
})
monthlyPriceEur: number;
@ApiProperty({
example: 2739,
description: 'Yearly price in EUR (11 months)',
example: 3289,
description: 'Yearly price in EUR (11 months — 1 month deducted)',
})
yearlyPriceEur: number;
@ -225,10 +225,10 @@ export class PlanDetailsDto {
maxShipmentsPerYear: number;
@ApiProperty({
example: 3,
description: 'Commission rate percentage on shipments',
example: 10,
description: 'Per-booking fee in EUR (-1 for custom / on quote)',
})
commissionRatePercent: number;
bookingFeeEur: number;
@ApiProperty({
example: 'email',

View File

@ -160,18 +160,16 @@ export class CsvBookingService {
// Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId);
// Calculate commission based on organization's subscription plan
let commissionRate = 5; // default Bronze
let commissionAmountEur = 0;
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
commissionRate = subscription.plan.commissionRatePercent;
} catch (error: any) {
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Flat per-booking service fee (forfait par booking) based on the org's plan.
// A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
// booking skips the payment gate and the carrier is notified immediately.
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
const requiresPayment = bookingFeeEur > 0;
const initialStatus = requiresPayment
? CsvBookingStatus.PENDING_PAYMENT
: CsvBookingStatus.PENDING;
// 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(
bookingId,
userId,
@ -188,7 +186,7 @@ export class CsvBookingService {
dto.primaryCurrency,
dto.transitDays,
dto.containerType,
CsvBookingStatus.PENDING_PAYMENT,
initialStatus,
documents,
confirmationToken,
new Date(),
@ -196,8 +194,13 @@ export class CsvBookingService {
dto.notes,
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
undefined, // commissionRate — flat fee, no percentage
requiresPayment ? bookingFeeEur : 0,
undefined, // stripePaymentIntentId — set after payment
dto.freightTotal,
dto.freightCurrency,
dto.fobTotal,
dto.fobCurrency
);
// Save to database
@ -214,15 +217,37 @@ export class CsvBookingService {
}
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
// NO notification yet - will be created after payment confirmation
if (requiresPayment) {
// 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);
}
/**
* 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
*/
@ -250,7 +275,7 @@ export class CsvBookingService {
const commissionAmountEur = booking.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);
@ -261,13 +286,13 @@ export class CsvBookingService {
currency: 'eur',
customerEmail: userEmail,
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}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
});
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 {
@ -332,7 +357,22 @@ export class CsvBookingService {
const bookingNumber = ormBooking?.bookingNumber;
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 {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
@ -359,8 +399,12 @@ export class CsvBookingService {
} catch (error: any) {
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 {
const notification = Notification.create({
id: uuidv4(),
@ -369,15 +413,13 @@ export class CsvBookingService {
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM,
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 },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
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)
booking.accept();
// Apply commission based on organization's subscription plan
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
booking.organizationId
);
const commissionRate = subscription.plan.commissionRatePercent;
const baseAmountEur = booking.priceEUR;
booking.applyCommission(commissionRate, baseAmountEur);
this.logger.log(
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}`
);
} catch (error: any) {
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
}
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
booking.applyBookingFee(bookingFeeEur);
this.logger.log(
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
);
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
@ -1382,6 +1416,10 @@ export class CsvBookingService {
price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
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,
yearlyPriceEur: plan.yearlyPriceEur,
maxShipmentsPerYear: plan.maxShipmentsPerYear,
commissionRatePercent: plan.commissionRatePercent,
bookingFeeEur: plan.bookingFeeEur,
supportLevel: plan.supportLevel,
statusBadge: plan.statusBadge,
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
});
});
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 commissionRate?: 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();
}
@ -151,12 +157,27 @@ export class CsvBooking {
/**
* 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 {
this.commissionRate = ratePercent;
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
*
@ -434,7 +455,11 @@ export class CsvBooking {
bookingNumber?: string,
commissionRate?: number,
commissionAmountEur?: number,
stripePaymentIntentId?: string
stripePaymentIntentId?: string,
freightTotal?: number,
freightCurrency?: string,
fobTotal?: number,
fobCurrency?: string
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
@ -466,6 +491,10 @@ export class CsvBooking {
booking.commissionRate = commissionRate;
booking.commissionAmountEur = commissionAmountEur;
booking.stripePaymentIntentId = stripePaymentIntentId;
booking.freightTotal = freightTotal;
booking.freightCurrency = freightCurrency;
booking.fobTotal = fobTotal;
booking.fobCurrency = fobCurrency;
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 {
return this.props.plan.commissionRatePercent;
get bookingFeeEur(): number {
return this.props.plan.bookingFeeEur;
}
/**

View File

@ -233,8 +233,30 @@ describe('SubscriptionPlan Value Object', () => {
it('should return correct prices for SILVER plan', () => {
const plan = SubscriptionPlan.silver();
expect(plan.monthlyPriceEur).toBe(249);
expect(plan.yearlyPriceEur).toBe(2739);
expect(plan.monthlyPriceEur).toBe(299);
// 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', () => {

View File

@ -2,10 +2,16 @@
* Subscription Plan Value Object
*
* 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.
*
* 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';
@ -29,9 +35,9 @@ interface PlanDetails {
readonly name: string;
readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number;
readonly yearlyPriceEur: number;
readonly yearlyPriceEur: number; // monthlyPriceEur * 11 (annual billing = 11 months)
readonly maxShipmentsPerYear: number; // -1 means unlimited
readonly commissionRatePercent: number;
readonly bookingFeeEur: number; // per-booking fee; -1 means custom (on quote)
readonly statusBadge: StatusBadge;
readonly supportLevel: SupportLevel;
readonly planFeatures: readonly PlanFeature[];
@ -44,20 +50,25 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
maxLicenses: 1,
monthlyPriceEur: 0,
yearlyPriceEur: 0,
maxShipmentsPerYear: 12,
commissionRatePercent: 5,
maxShipmentsPerYear: 5,
bookingFeeEur: 15,
statusBadge: 'none',
supportLevel: 'none',
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: {
name: 'Silver',
maxLicenses: 5,
monthlyPriceEur: 249,
yearlyPriceEur: 2739,
monthlyPriceEur: 299,
yearlyPriceEur: 3289,
maxShipmentsPerYear: -1,
commissionRatePercent: 3,
bookingFeeEur: 10,
statusBadge: 'silver',
supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER,
@ -69,15 +80,16 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
'Gestion des utilisateurs',
'Import CSV',
'Support par email',
'Forfait par booking : 10 €',
],
},
GOLD: {
name: 'Gold',
maxLicenses: 20,
monthlyPriceEur: 899,
yearlyPriceEur: 9889,
monthlyPriceEur: 799,
yearlyPriceEur: 8789,
maxShipmentsPerYear: -1,
commissionRatePercent: 2,
bookingFeeEur: 5,
statusBadge: 'gold',
supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD,
@ -87,6 +99,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
'Toutes les fonctionnalités Silver',
'Intégration API',
'Assistance commerciale directe',
'Forfait par booking : 5 €',
],
},
PLATINIUM: {
@ -95,7 +108,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing
maxShipmentsPerYear: -1,
commissionRatePercent: 1,
bookingFeeEur: -1, // custom / on quote
statusBadge: 'platinium',
supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM,
@ -105,6 +118,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
'Key Account Manager dédié',
'Interface personnalisable',
'Contrats tarifaires cadre',
'Forfait par booking : sur mesure',
],
},
};
@ -209,8 +223,12 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
}
get commissionRatePercent(): number {
return PLAN_DETAILS[this.plan].commissionRatePercent;
get bookingFeeEur(): number {
return PLAN_DETAILS[this.plan].bookingFeeEur;
}
hasCustomBookingFee(): boolean {
return PLAN_DETAILS[this.plan].bookingFeeEur === -1;
}
get statusBadge(): StatusBadge {

View File

@ -169,6 +169,20 @@ export class CsvBookingOrmEntity {
})
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' })
createdAt: Date;

View File

@ -45,7 +45,11 @@ export class CsvBookingMapper {
ormEntity.bookingNumber ?? undefined,
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : 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,
commissionRate: domain.commissionRate ?? 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,
unit_amount: input.amountCents,
product_data: {
name: 'Commission Xpeditis',
name: 'Frais de booking Xpeditis',
description: input.bookingDescription,
},
},

View File

@ -19,6 +19,11 @@ interface Booking {
priceEUR?: number;
priceUSD?: number;
primaryCurrency?: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
commissionAmountEur?: number;
createdAt?: string;
requestedAt?: string;
updatedAt?: string;
@ -116,6 +121,9 @@ export default function AdminBookingsPage() {
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
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
.filter(booking => {
@ -471,7 +479,7 @@ export default function AdminBookingsPage() {
{/* Details Modal */}
{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="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">
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
<button
@ -514,6 +522,7 @@ export default function AdminBookingsPage() {
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-4">
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.routeSection')}
@ -592,11 +601,56 @@ export default function AdminBookingsPage() {
</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">
<h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.priceSection')}
</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">
{selectedBooking.priceEUR != null && (
<div>
@ -641,6 +695,7 @@ export default function AdminBookingsPage() {
)}
</div>
</div>
</div>
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
<div className="border-t pt-4">

View File

@ -44,6 +44,10 @@ interface BookingData {
status: string;
commissionRate?: number;
commissionAmountEur?: number;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
}
interface OrgData {
@ -188,10 +192,23 @@ export default function PayCommissionPage() {
if (!booking) return null;
const commissionAmount = booking.commissionAmountEur || 0;
const commissionRate = booking.commissionRate || 0;
const bookingFeeAmount = booking.commissionAmountEur || 0;
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 (
<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">
@ -204,9 +221,9 @@ export default function PayCommissionPage() {
Retour aux bookings
</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">
Finalisez votre booking en réglant la commission de service
Finalisez votre booking en réglant le forfait par booking
</p>
{/* SIRET/SIREN non vérifié — bannière de demande */}
@ -374,7 +391,7 @@ export default function PayCommissionPage() {
) : (
<>
<CreditCard className="h-5 w-5" />
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span>
<span>Payer {formatPrice(bookingFeeAmount, 'EUR')} par carte</span>
</>
)}
</button>
@ -397,7 +414,7 @@ export default function PayCommissionPage() {
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
{
label: 'Montant',
value: formatPrice(commissionAmount, 'EUR'),
value: formatPrice(bookingFeeAmount, 'EUR'),
key: 'amount',
bold: true,
},
@ -500,22 +517,45 @@ export default function PayCommissionPage() {
<span className="text-gray-500">Transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
<div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Prix transport</span>
{/* Transport cost breakdown — Fret + FOB (paid to the carrier) */}
{hasBreakdown && (
<>
{freightTotal != null && (
<div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Fret</span>
<span className="font-semibold text-gray-900">
{formatPrice(freightTotal, freightCurrency)}
</span>
</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(booking.priceEUR, 'EUR')}
{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>
{/* Commission box */}
{/* Booking fee box */}
<div className="bg-blue-600 rounded-xl p-5 text-white">
<p className="text-sm text-blue-100 mb-1">
Commission ({commissionRate}% du prix transport)
</p>
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
<p className="text-sm text-blue-100 mb-1">Forfait par booking à régler maintenant</p>
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
<p className="text-xs text-blue-200 mt-1">
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}%
Montant fixe par booking (hors fret et frais FOB)
</p>
</div>

View File

@ -81,8 +81,8 @@ export default function PaymentSuccessPage() {
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
<p className="text-gray-600 mb-6">
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec
votre demande de booking.
Votre forfait booking a ete paye avec succes. Un email a ete envoye au transporteur
avec votre demande de booking.
</p>
<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('priceEUR', priceEUR.toString());
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('containerType', formData.containerType);

View File

@ -209,7 +209,7 @@ export default function LandingPage() {
monthlyPrice: 0,
yearlyPrice: 0,
yearlyMonthly: 0,
commission: '5%',
bookingFee: 15,
ctaLink: '/register',
highlighted: false,
accentColor: 'from-amber-600 to-yellow-500',
@ -219,10 +219,10 @@ export default function LandingPage() {
{
key: 'silver',
badge: 'popular',
monthlyPrice: 249,
yearlyPrice: 2739,
yearlyMonthly: 228,
commission: '3%',
monthlyPrice: 299,
yearlyPrice: 3289,
yearlyMonthly: 274,
bookingFee: 10,
ctaLink: '/register',
highlighted: true,
accentColor: 'from-slate-400 to-slate-500',
@ -232,10 +232,10 @@ export default function LandingPage() {
{
key: 'gold',
badge: null,
monthlyPrice: 899,
yearlyPrice: 9889,
yearlyMonthly: 824,
commission: '2%',
monthlyPrice: 799,
yearlyPrice: 8789,
yearlyMonthly: 732,
bookingFee: 5,
ctaLink: '/register',
highlighted: false,
accentColor: 'from-yellow-400 to-amber-400',
@ -248,7 +248,7 @@ export default function LandingPage() {
monthlyPrice: null,
yearlyPrice: null,
yearlyMonthly: null,
commission: '1%',
bookingFee: -1,
ctaLink: '/contact',
highlighted: false,
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 planUsers = t(`pricing.plans.${plan.key}.users` 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 planFeatures = planFeaturesByKey[plan.key] ?? [];
@ -777,11 +781,11 @@ export default function LandingPage() {
</span>
</div>
<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
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
>
{t('pricing.commission', { rate: plan.commission })}
{planBookingFee}
</span>
</div>
</div>

View File

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

View File

@ -203,7 +203,8 @@
"perMonth": "/month",
"billedYearly": "Billed {price}€/year",
"saveWithYearly": "Save 1 month with yearly",
"commission": "Commission {rate}",
"bookingFee": "{price} / booking",
"bookingFeeCustom": "Custom booking fee",
"noCommitment": "No commitment · Cancel at any time",
"questions": "Questions?",
"contactSales": "Contact our sales team",
@ -214,7 +215,7 @@
"name": "Bronze",
"description": "To discover the platform",
"users": "1 user",
"shipments": "12 shipments / year",
"shipments": "5 bookings / year",
"support": "No support",
"cta": "Start for free"
},
@ -1050,6 +1051,10 @@
"weight": "Weight",
"volume": "Volume",
"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",
"createdAt": "Created on",
"updatedAt": "Updated",
@ -3405,7 +3410,7 @@
"stats": {
"users": "Users",
"shipments": "Shipments",
"commission": "Commission",
"bookingFee": "Fee / booking",
"support": "Support"
},
"features": {
@ -3421,8 +3426,10 @@
},
"values": {
"unlimited": "Unlimited",
"shipmentsPerYear": "12/year",
"shipmentsPerYear": "5/year",
"shipmentsUnlimited": "Unlimited",
"bookingFeePerBooking": "{price} / booking",
"bookingFeeCustom": "Custom",
"supportNone": "None",
"supportEmail": "Email",
"supportDirect": "Direct",

View File

@ -203,7 +203,8 @@
"perMonth": "/mois",
"billedYearly": "Facturé {price}€/an",
"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",
"questions": "Des questions ?",
"contactSales": "Contactez notre équipe commerciale",
@ -214,7 +215,7 @@
"name": "Bronze",
"description": "Pour découvrir la plateforme",
"users": "1 utilisateur",
"shipments": "12 expéditions / an",
"shipments": "5 bookings / an",
"support": "Aucun support",
"cta": "Commencer gratuitement"
},
@ -1050,6 +1051,10 @@
"weight": "Poids",
"volume": "Volume",
"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",
"createdAt": "Créée le",
"updatedAt": "Mise à jour",
@ -3411,7 +3416,7 @@
"stats": {
"users": "Utilisateurs",
"shipments": "Expéditions",
"commission": "Commission",
"bookingFee": "Forfait / booking",
"support": "Support"
},
"features": {
@ -3427,8 +3432,10 @@
},
"values": {
"unlimited": "Illimité",
"shipmentsPerYear": "12/an",
"shipmentsPerYear": "5/an",
"shipmentsUnlimited": "Illimitées",
"bookingFeePerBooking": "{price} / booking",
"bookingFeeCustom": "Sur mesure",
"supportNone": "Aucun",
"supportEmail": "Email",
"supportDirect": "Direct",

View File

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