From 96aa1402077b7cbdba3a643fe312f4740341e850 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 18 Jun 2026 00:17:44 +0200 Subject: [PATCH] fix remuneration --- .../controllers/admin.controller.ts | 6 + .../src/application/dto/csv-booking.dto.ts | 62 ++++++++++ .../src/application/dto/subscription.dto.ts | 12 +- .../services/csv-booking.service.ts | 116 ++++++++++++------ .../services/subscription.service.ts | 2 +- .../entities/csv-booking.entity.spec.ts | 20 +++ .../src/domain/entities/csv-booking.entity.ts | 33 ++++- .../domain/entities/subscription.entity.ts | 6 +- .../subscription-plan.vo.spec.ts | 26 +++- .../value-objects/subscription-plan.vo.ts | 50 +++++--- .../entities/csv-booking.orm-entity.ts | 14 +++ .../typeorm/mappers/csv-booking.mapper.ts | 10 +- ...740000000003-AddFreightFobToCsvBookings.ts | 32 +++++ .../infrastructure/stripe/stripe.adapter.ts | 2 +- .../app/[locale]/admin/bookings/page.tsx | 59 ++++++++- .../dashboard/booking/[id]/pay/page.tsx | 70 ++++++++--- .../booking/[id]/payment-success/page.tsx | 4 +- .../[locale]/dashboard/booking/new/page.tsx | 5 + apps/frontend/app/[locale]/page.tsx | 28 +++-- apps/frontend/app/[locale]/pricing/page.tsx | 26 ++-- apps/frontend/messages/en.json | 15 ++- apps/frontend/messages/fr.json | 15 ++- apps/frontend/src/lib/api/subscriptions.ts | 2 +- 23 files changed, 493 insertions(+), 122 deletions(-) create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index 5c8393c..b413978 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -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, }; } diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index d32f5f8..2203dbc 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -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; } /** diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts index 5302528..573c335 100644 --- a/apps/backend/src/application/dto/subscription.dto.ts +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -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', diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index f22516a..ed8eadc 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -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 { + 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 { 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 { 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, }; } diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 255c0e3..741d834 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -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], diff --git a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts index 7bcd7a8..26e6a93 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts @@ -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(); + }); + }); }); diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index ca0b7de..f75f6a2 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -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; } diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts index 3cde08c..34e49fd 100644 --- a/apps/backend/src/domain/entities/subscription.entity.ts +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -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; } /** diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts index ddbcaa2..cadca2c 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts @@ -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', () => { diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts index 5ffa990..7e90892 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -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 = { 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 = { '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 = { 'Toutes les fonctionnalités Silver', 'Intégration API', 'Assistance commerciale directe', + 'Forfait par booking : 5 €', ], }, PLATINIUM: { @@ -95,7 +108,7 @@ const PLAN_DETAILS: Record = { 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 = { '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 { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index 19ae980..083aaa8 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -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; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts index 85217ed..2605cc4 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts @@ -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, }; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts new file mode 100644 index 0000000..5469524 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts @@ -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 { + 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 { + 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" + `); + } +} diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts index 4cd3665..9cfa590 100644 --- a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -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, }, }, diff --git a/apps/frontend/app/[locale]/admin/bookings/page.tsx b/apps/frontend/app/[locale]/admin/bookings/page.tsx index e5cb135..1088e5d 100644 --- a/apps/frontend/app/[locale]/admin/bookings/page.tsx +++ b/apps/frontend/app/[locale]/admin/bookings/page.tsx @@ -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 && (
-
+

{t('modal.title')}

+

{t('modal.routeSection')} @@ -592,11 +601,56 @@ export default function AdminBookingsPage() {

- {(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && ( + {(selectedBooking.priceEUR != null || + selectedBooking.priceUSD != null || + selectedBooking.freightTotal != null || + selectedBooking.fobTotal != null) && (

{t('modal.priceSection')}

+ + {/* Transport breakdown — Fret + FOB (paid to carrier) */} + {(selectedBooking.freightTotal != null || selectedBooking.fobTotal != null) && ( +
+ {selectedBooking.freightTotal != null && ( +
+ {t('modal.freight')} + + {formatMoney( + selectedBooking.freightTotal, + selectedBooking.freightCurrency || + selectedBooking.primaryCurrency || + 'USD' + )} + +
+ )} + {selectedBooking.fobTotal != null && selectedBooking.fobTotal > 0 && ( +
+ {t('modal.fob')} + + {formatMoney( + selectedBooking.fobTotal, + selectedBooking.fobCurrency || 'EUR' + )} + +
+ )} + {selectedBooking.commissionAmountEur != null && + selectedBooking.commissionAmountEur > 0 && ( +
+ {t('modal.bookingFee')} + + {formatMoney(selectedBooking.commissionAmountEur, 'EUR')} + +
+ )} +

{t('modal.transportNote')}

+
+ )} + + {/* Per-currency totals (also covers legacy bookings without a breakdown) */}
{selectedBooking.priceEUR != null && (
@@ -641,6 +695,7 @@ export default function AdminBookingsPage() { )}
+
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx index 82edffb..9286165 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx @@ -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 (
@@ -204,9 +221,9 @@ export default function PayCommissionPage() { Retour aux bookings -

Paiement de la commission

+

Paiement du forfait booking

- Finalisez votre booking en réglant la commission de service + Finalisez votre booking en réglant le forfait par booking

{/* SIRET/SIREN non vérifié — bannière de demande */} @@ -374,7 +391,7 @@ export default function PayCommissionPage() { ) : ( <> - Payer {formatPrice(commissionAmount, 'EUR')} par carte + Payer {formatPrice(bookingFeeAmount, 'EUR')} par carte )} @@ -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() { Transit {booking.transitDays} jours
-
- Prix transport + {/* Transport cost breakdown — Fret + FOB (paid to the carrier) */} + {hasBreakdown && ( + <> + {freightTotal != null && ( +
+ Fret + + {formatPrice(freightTotal, freightCurrency)} + +
+ )} + {fobTotal != null && fobTotal > 0 && ( +
+ Frais FOB + + {formatPrice(fobTotal, fobCurrency)} + +
+ )} + + )} + {/* Single transport total — always in USD (fret + FOB) */} +
+ Total - {formatPrice(booking.priceEUR, 'EUR')} + {formatPrice(transportTotalUSD, 'USD')}
+

+ Fret et frais FOB sont réglés directement au transporteur. +

- {/* Commission box */} + {/* Booking fee box */}
-

- Commission ({commissionRate}% du prix transport) -

-

{formatPrice(commissionAmount, 'EUR')}

+

Forfait par booking — à régler maintenant

+

{formatPrice(bookingFeeAmount, 'EUR')}

- {formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}% + Montant fixe par booking (hors fret et frais FOB)

diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx index faf477c..8974f2a 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx @@ -81,8 +81,8 @@ export default function PaymentSuccessPage() {

Paiement confirme !

- 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.

diff --git a/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx index adedceb..8db6dc2 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx @@ -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); diff --git a/apps/frontend/app/[locale]/page.tsx b/apps/frontend/app/[locale]/page.tsx index 0d9bf0d..262c40c 100644 --- a/apps/frontend/app/[locale]/page.tsx +++ b/apps/frontend/app/[locale]/page.tsx @@ -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() {
- + - {t('pricing.commission', { rate: plan.commission })} + {planBookingFee}
diff --git a/apps/frontend/app/[locale]/pricing/page.tsx b/apps/frontend/app/[locale]/pricing/page.tsx index 2f4d244..6904d0e 100644 --- a/apps/frontend/app/[locale]/pricing/page.tsx +++ b/apps/frontend/app/[locale]/pricing/page.tsx @@ -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() { {t(`values.${plan.shipmentsKey}`)}
- {t('stats.commission')} - {plan.commission} + {t('stats.bookingFee')} + + {plan.bookingFee === -1 + ? t('values.bookingFeeCustom') + : t('values.bookingFeePerBooking', { price: formatPrice(plan.bookingFee) })} +
{t('stats.support')} diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 141efd3..73b00ef 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -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", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 68b8e9a..dc6fe96 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -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", diff --git a/apps/frontend/src/lib/api/subscriptions.ts b/apps/frontend/src/lib/api/subscriptions.ts index cd63d0d..de607eb 100644 --- a/apps/frontend/src/lib/api/subscriptions.ts +++ b/apps/frontend/src/lib/api/subscriptions.ts @@ -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[];