fix remuneration
This commit is contained in:
parent
6b9ad95811
commit
96aa140207
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
this.logger.log(
|
||||||
);
|
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
<div className="border-t pt-3 flex justify-between text-sm">
|
{/* Transport cost breakdown — Fret + FOB (paid to the carrier) */}
|
||||||
<span className="text-gray-500">Prix transport</span>
|
{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">
|
<span className="font-bold text-gray-900">
|
||||||
{formatPrice(booking.priceEUR, 'EUR')}
|
{formatPrice(transportTotalUSD, 'USD')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user