Merge branch 'cicd' into dev
Some checks failed
Dev CI / Backend — Lint (push) Successful in 10m24s
Dev CI / Frontend — Lint & Type-check (push) Successful in 10m52s
Dev CI / Frontend — Unit Tests (push) Failing after 5m50s
Dev CI / Backend — Unit Tests (push) Successful in 10m12s
Dev CI / Notify Failure (push) Has been skipped

# Conflicts:
#	apps/backend/src/application/auth/auth.service.ts
#	apps/backend/src/application/dto/subscription.dto.ts
#	apps/backend/src/application/services/subscription.service.ts
#	apps/backend/src/domain/value-objects/plan-feature.vo.ts
#	apps/backend/src/domain/value-objects/subscription-plan.vo.ts
#	apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts
#	apps/backend/src/infrastructure/stripe/stripe.adapter.ts
#	apps/frontend/e2e/booking-workflow.spec.ts
This commit is contained in:
David 2026-04-04 14:21:15 +02:00
commit 62698de952
8 changed files with 117 additions and 103 deletions

View File

@ -311,12 +311,12 @@ export class AuthService {
* Generate access and refresh tokens * Generate access and refresh tokens
*/ */
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get PLATINIUM plan with no expiration // ADMIN users always get ENTERPRISE plan with no expiration
let plan = 'BRONZE'; let plan = 'FREE';
let planFeatures: string[] = []; let planFeatures: string[] = [];
if (user.role === UserRole.ADMIN) { if (user.role === UserRole.ADMIN) {
plan = 'PLATINIUM'; plan = 'ENTERPRISE';
planFeatures = [ planFeatures = [
'dashboard', 'dashboard',
'wiki', 'wiki',

View File

@ -11,10 +11,10 @@ import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator';
* Subscription plan types * Subscription plan types
*/ */
export enum SubscriptionPlanDto { export enum SubscriptionPlanDto {
BRONZE = 'BRONZE', FREE = 'FREE',
SILVER = 'SILVER', STARTER = 'STARTER',
GOLD = 'GOLD', PRO = 'PRO',
PLATINIUM = 'PLATINIUM', ENTERPRISE = 'ENTERPRISE',
} }
/** /**
@ -44,7 +44,7 @@ export enum BillingIntervalDto {
*/ */
export class CreateCheckoutSessionDto { export class CreateCheckoutSessionDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.SILVER, example: SubscriptionPlanDto.STARTER,
description: 'The subscription plan to purchase', description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
@ -188,7 +188,7 @@ export class LicenseResponseDto {
*/ */
export class PlanDetailsDto { export class PlanDetailsDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.SILVER, example: SubscriptionPlanDto.STARTER,
description: 'Plan identifier', description: 'Plan identifier',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
@ -274,7 +274,7 @@ export class SubscriptionResponseDto {
organizationId: string; organizationId: string;
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.SILVER, example: SubscriptionPlanDto.STARTER,
description: 'Current subscription plan', description: 'Current subscription plan',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })

View File

@ -182,8 +182,8 @@ export class SubscriptionService {
} }
// Cannot checkout for FREE plan // Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.BRONZE) { if (dto.plan === SubscriptionPlanDto.FREE) {
throw new BadRequestException('Cannot create checkout session for Bronze plan'); throw new BadRequestException('Cannot create checkout session for Free plan');
} }
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);

View File

@ -24,13 +24,13 @@ export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [
'dedicated_kam', 'dedicated_kam',
]; ];
export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SubscriptionPlanTypeForFeatures = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = { export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
BRONZE: [], FREE: [],
SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'], STARTER: ['dashboard', 'wiki', 'user_management', 'csv_export'],
GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], PRO: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'],
PLATINIUM: [ ENTERPRISE: [
'dashboard', 'dashboard',
'wiki', 'wiki',
'user_management', 'user_management',

View File

@ -5,24 +5,25 @@
* Each plan has a maximum number of licenses, shipment limits, commission rates, * Each plan has a maximum number of licenses, shipment limits, commission rates,
* feature flags, and support levels. * feature flags, and support levels.
* *
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom) * Plans: FREE (0EUR/mo), STARTER (49EUR/mo), PRO (249EUR/mo), ENTERPRISE (custom)
*/ */
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam'; export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam';
export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium'; export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium';
/** /**
* Legacy plan name mapping for backward compatibility during migration. * Legacy plan name mapping for backward compatibility with DB values.
* DB stores BRONZE/SILVER/GOLD/PLATINIUM (from migration); map them to canonical names.
*/ */
const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = { const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = {
FREE: 'BRONZE', BRONZE: 'FREE',
STARTER: 'SILVER', SILVER: 'STARTER',
PRO: 'GOLD', GOLD: 'PRO',
ENTERPRISE: 'PLATINIUM', PLATINIUM: 'ENTERPRISE',
}; };
interface PlanDetails { interface PlanDetails {
@ -39,58 +40,58 @@ interface PlanDetails {
} }
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = { const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
BRONZE: { FREE: {
name: 'Bronze', name: 'Free',
maxLicenses: 1, maxLicenses: 2,
monthlyPriceEur: 0, monthlyPriceEur: 0,
yearlyPriceEur: 0, yearlyPriceEur: 0,
maxShipmentsPerYear: 12, maxShipmentsPerYear: 12,
commissionRatePercent: 5, commissionRatePercent: 5,
statusBadge: 'none', statusBadge: 'none',
supportLevel: 'none', supportLevel: 'none',
planFeatures: PLAN_FEATURES.BRONZE, planFeatures: PLAN_FEATURES.FREE,
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'], features: ['Up to 2 users', '12 shipments per year', 'Basic rate search'],
}, },
SILVER: { STARTER: {
name: 'Silver', name: 'Starter',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 249, monthlyPriceEur: 49,
yearlyPriceEur: 2739, // 249 * 11 months yearlyPriceEur: 470,
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 3, commissionRatePercent: 3,
statusBadge: 'silver', statusBadge: 'silver',
supportLevel: 'email', supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER, planFeatures: PLAN_FEATURES.STARTER,
features: [ features: [
"Jusqu'à 5 utilisateurs", 'Up to 5 users',
'Expéditions illimitées', 'Unlimited shipments',
'Tableau de bord', 'Dashboard',
'Wiki Maritime', 'Maritime Wiki',
'Gestion des utilisateurs', 'User management',
'Import CSV', 'CSV import',
'Support par email', 'Email support',
], ],
}, },
GOLD: { PRO: {
name: 'Gold', name: 'Pro',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 899, monthlyPriceEur: 249,
yearlyPriceEur: 9889, // 899 * 11 months yearlyPriceEur: 2739,
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 2, commissionRatePercent: 2,
statusBadge: 'gold', statusBadge: 'gold',
supportLevel: 'direct', supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD, planFeatures: PLAN_FEATURES.PRO,
features: [ features: [
"Jusqu'à 20 utilisateurs", 'Up to 20 users',
'Expéditions illimitées', 'Unlimited shipments',
'Toutes les fonctionnalités Silver', 'All Starter features',
'Intégration API', 'API access',
'Assistance commerciale directe', 'Direct commercial support',
], ],
}, },
PLATINIUM: { ENTERPRISE: {
name: 'Platinium', name: 'Enterprise',
maxLicenses: -1, // unlimited maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing
@ -98,13 +99,13 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
commissionRatePercent: 1, commissionRatePercent: 1,
statusBadge: 'platinium', statusBadge: 'platinium',
supportLevel: 'dedicated_kam', supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM, planFeatures: PLAN_FEATURES.ENTERPRISE,
features: [ features: [
'Utilisateurs illimités', 'Unlimited users',
'Toutes les fonctionnalités Gold', 'All Pro features',
'Key Account Manager dédié', 'Dedicated Key Account Manager',
'Interface personnalisable', 'Custom interface',
'Contrats tarifaires cadre', 'Framework rate contracts',
], ],
}, },
}; };
@ -121,18 +122,18 @@ export class SubscriptionPlan {
/** /**
* Create from string with legacy name support. * Create from string with legacy name support.
* Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names. * Accepts both old DB names (BRONZE/SILVER/GOLD/PLATINIUM) and canonical names (FREE/STARTER/PRO/ENTERPRISE).
*/ */
static fromString(value: string): SubscriptionPlan { static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase(); const upperValue = value.toUpperCase();
// Check legacy mapping first // Check legacy mapping first (DB values BRONZE/SILVER/GOLD/PLATINIUM)
const mapped = LEGACY_PLAN_MAPPING[upperValue]; const mapped = LEGACY_PLAN_MAPPING[upperValue];
if (mapped) { if (mapped) {
return new SubscriptionPlan(mapped); return new SubscriptionPlan(mapped);
} }
// Try direct match // Try direct match (canonical names)
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) { if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
return new SubscriptionPlan(upperValue as SubscriptionPlanType); return new SubscriptionPlan(upperValue as SubscriptionPlanType);
} }
@ -141,41 +142,41 @@ export class SubscriptionPlan {
} }
// Named factories // Named factories
static bronze(): SubscriptionPlan {
return new SubscriptionPlan('BRONZE');
}
static silver(): SubscriptionPlan {
return new SubscriptionPlan('SILVER');
}
static gold(): SubscriptionPlan {
return new SubscriptionPlan('GOLD');
}
static platinium(): SubscriptionPlan {
return new SubscriptionPlan('PLATINIUM');
}
// Legacy aliases
static free(): SubscriptionPlan { static free(): SubscriptionPlan {
return SubscriptionPlan.bronze(); return new SubscriptionPlan('FREE');
} }
static starter(): SubscriptionPlan { static starter(): SubscriptionPlan {
return SubscriptionPlan.silver(); return new SubscriptionPlan('STARTER');
} }
static pro(): SubscriptionPlan { static pro(): SubscriptionPlan {
return SubscriptionPlan.gold(); return new SubscriptionPlan('PRO');
} }
static enterprise(): SubscriptionPlan { static enterprise(): SubscriptionPlan {
return SubscriptionPlan.platinium(); return new SubscriptionPlan('ENTERPRISE');
}
// Legacy aliases (kept for backward compatibility)
static bronze(): SubscriptionPlan {
return SubscriptionPlan.free();
}
static silver(): SubscriptionPlan {
return SubscriptionPlan.starter();
}
static gold(): SubscriptionPlan {
return SubscriptionPlan.pro();
}
static platinium(): SubscriptionPlan {
return SubscriptionPlan.enterprise();
} }
static getAllPlans(): SubscriptionPlan[] { static getAllPlans(): SubscriptionPlan[] {
return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map( return (['FREE', 'STARTER', 'PRO', 'ENTERPRISE'] as SubscriptionPlanType[]).map(
p => new SubscriptionPlan(p) p => new SubscriptionPlan(p)
); );
} }
@ -250,21 +251,21 @@ export class SubscriptionPlan {
* Returns true if this is a paid plan * Returns true if this is a paid plan
*/ */
isPaid(): boolean { isPaid(): boolean {
return this.plan !== 'BRONZE'; return this.plan !== 'FREE';
} }
/** /**
* Returns true if this is the free (Bronze) plan * Returns true if this is the free plan
*/ */
isFree(): boolean { isFree(): boolean {
return this.plan === 'BRONZE'; return this.plan === 'FREE';
} }
/** /**
* Returns true if this plan has custom pricing (Platinium) * Returns true if this plan has custom pricing (Enterprise)
*/ */
isCustomPricing(): boolean { isCustomPricing(): boolean {
return this.plan === 'PLATINIUM'; return this.plan === 'ENTERPRISE';
} }
/** /**
@ -279,7 +280,7 @@ export class SubscriptionPlan {
* Check if upgrade to target plan is allowed * Check if upgrade to target plan is allowed
*/ */
canUpgradeTo(targetPlan: SubscriptionPlan): boolean { canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlanType[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex; return targetIndex > currentIndex;
@ -289,7 +290,7 @@ export class SubscriptionPlan {
* Check if downgrade to target plan is allowed given current user count * Check if downgrade to target plan is allowed given current user count
*/ */
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlanType[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);

View File

@ -5,7 +5,20 @@
*/ */
import { Subscription } from '@domain/entities/subscription.entity'; import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity'; import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity';
/** Maps canonical domain plan names back to the values stored in the DB. */
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
// Pass-through for any value already in ORM format
BRONZE: 'BRONZE',
SILVER: 'SILVER',
GOLD: 'GOLD',
PLATINIUM: 'PLATINIUM',
};
export class SubscriptionOrmMapper { export class SubscriptionOrmMapper {
/** /**
@ -17,7 +30,7 @@ export class SubscriptionOrmMapper {
orm.id = props.id; orm.id = props.id;
orm.organizationId = props.organizationId; orm.organizationId = props.organizationId;
orm.plan = props.plan; orm.plan = DOMAIN_TO_ORM_PLAN[props.plan] ?? 'BRONZE';
orm.status = props.status; orm.status = props.status;
orm.stripeCustomerId = props.stripeCustomerId; orm.stripeCustomerId = props.stripeCustomerId;
orm.stripeSubscriptionId = props.stripeSubscriptionId; orm.stripeSubscriptionId = props.stripeSubscriptionId;

View File

@ -51,22 +51,22 @@ export class StripeAdapter implements StripePort {
const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID'); const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID');
const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID'); const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID');
if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER'); if (silverMonthly) this.priceIdMap.set(silverMonthly, 'STARTER');
if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER'); if (silverYearly) this.priceIdMap.set(silverYearly, 'STARTER');
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD'); if (goldMonthly) this.priceIdMap.set(goldMonthly, 'PRO');
if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD'); if (goldYearly) this.priceIdMap.set(goldYearly, 'PRO');
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM'); if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'ENTERPRISE');
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM'); if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'ENTERPRISE');
this.planPriceMap.set('SILVER', { this.planPriceMap.set('STARTER', {
monthly: silverMonthly || '', monthly: silverMonthly || '',
yearly: silverYearly || '', yearly: silverYearly || '',
}); });
this.planPriceMap.set('GOLD', { this.planPriceMap.set('PRO', {
monthly: goldMonthly || '', monthly: goldMonthly || '',
yearly: goldYearly || '', yearly: goldYearly || '',
}); });
this.planPriceMap.set('PLATINIUM', { this.planPriceMap.set('ENTERPRISE', {
monthly: platiniumMonthly || '', monthly: platiniumMonthly || '',
yearly: platiniumYearly || '', yearly: platiniumYearly || '',
}); });

View File

@ -77,7 +77,7 @@ test.describe('Complete Booking Workflow', () => {
// Step 4: Select a Rate and Create Booking // Step 4: Select a Rate and Create Booking
await test.step('Select Rate and Create Booking', async () => { await test.step('Select Rate and Create Booking', async () => {
// Select first available rate // Select first available rate
await page.locator('.rate-card').first().click('button:has-text("Book")'); await page.locator('.rate-card').first().locator('button:has-text("Book")').click();
// Should navigate to booking form // Should navigate to booking form
await expect(page).toHaveURL(/.*bookings\/create/); await expect(page).toHaveURL(/.*bookings\/create/);