This commit is contained in:
David 2026-04-04 14:18:41 +02:00
parent 1fcf5d0032
commit 1d248b3cc9
8 changed files with 117 additions and 103 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,20 @@
*/
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 {
/**
@ -17,7 +30,7 @@ export class SubscriptionOrmMapper {
orm.id = props.id;
orm.organizationId = props.organizationId;
orm.plan = props.plan;
orm.plan = DOMAIN_TO_ORM_PLAN[props.plan] ?? 'BRONZE';
orm.status = props.status;
orm.stripeCustomerId = props.stripeCustomerId;
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 platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID');
if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER');
if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER');
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD');
if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD');
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM');
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM');
if (silverMonthly) this.priceIdMap.set(silverMonthly, 'STARTER');
if (silverYearly) this.priceIdMap.set(silverYearly, 'STARTER');
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'PRO');
if (goldYearly) this.priceIdMap.set(goldYearly, 'PRO');
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'ENTERPRISE');
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'ENTERPRISE');
this.planPriceMap.set('SILVER', {
this.planPriceMap.set('STARTER', {
monthly: silverMonthly || '',
yearly: silverYearly || '',
});
this.planPriceMap.set('GOLD', {
this.planPriceMap.set('PRO', {
monthly: goldMonthly || '',
yearly: goldYearly || '',
});
this.planPriceMap.set('PLATINIUM', {
this.planPriceMap.set('ENTERPRISE', {
monthly: platiniumMonthly || '',
yearly: platiniumYearly || '',
});

View File

@ -77,7 +77,7 @@ test.describe('Complete Booking Workflow', () => {
// Step 4: Select a Rate and Create Booking
await test.step('Select Rate and Create Booking', async () => {
// 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
await expect(page).toHaveURL(/.*bookings\/create/);