Compare commits

..

No commits in common. "62698de9521c804928c4bfbb0175801d1ae68774" and "711aca5f40c23d279c0cc330a5f72a99e48ab71c" have entirely different histories.

8 changed files with 103 additions and 117 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 ENTERPRISE plan with no expiration // ADMIN users always get PLATINIUM plan with no expiration
let plan = 'FREE'; let plan = 'BRONZE';
let planFeatures: string[] = []; let planFeatures: string[] = [];
if (user.role === UserRole.ADMIN) { if (user.role === UserRole.ADMIN) {
plan = 'ENTERPRISE'; plan = 'PLATINIUM';
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 {
FREE = 'FREE', BRONZE = 'BRONZE',
STARTER = 'STARTER', SILVER = 'SILVER',
PRO = 'PRO', GOLD = 'GOLD',
ENTERPRISE = 'ENTERPRISE', PLATINIUM = 'PLATINIUM',
} }
/** /**
@ -44,7 +44,7 @@ export enum BillingIntervalDto {
*/ */
export class CreateCheckoutSessionDto { export class CreateCheckoutSessionDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
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.STARTER, example: SubscriptionPlanDto.SILVER,
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.STARTER, example: SubscriptionPlanDto.SILVER,
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.FREE) { if (dto.plan === SubscriptionPlanDto.BRONZE) {
throw new BadRequestException('Cannot create checkout session for Free plan'); throw new BadRequestException('Cannot create checkout session for Bronze 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 = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = { export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
FREE: [], BRONZE: [],
STARTER: ['dashboard', 'wiki', 'user_management', 'csv_export'], SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'],
PRO: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'],
ENTERPRISE: [ PLATINIUM: [
'dashboard', 'dashboard',
'wiki', 'wiki',
'user_management', 'user_management',

View File

@ -5,25 +5,24 @@
* 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: FREE (0EUR/mo), STARTER (49EUR/mo), PRO (249EUR/mo), ENTERPRISE (custom) * Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
*/ */
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
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 with DB values. * Legacy plan name mapping for backward compatibility during migration.
* 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> = {
BRONZE: 'FREE', FREE: 'BRONZE',
SILVER: 'STARTER', STARTER: 'SILVER',
GOLD: 'PRO', PRO: 'GOLD',
PLATINIUM: 'ENTERPRISE', ENTERPRISE: 'PLATINIUM',
}; };
interface PlanDetails { interface PlanDetails {
@ -40,58 +39,58 @@ interface PlanDetails {
} }
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = { const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
FREE: { BRONZE: {
name: 'Free', name: 'Bronze',
maxLicenses: 2, maxLicenses: 1,
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.FREE, planFeatures: PLAN_FEATURES.BRONZE,
features: ['Up to 2 users', '12 shipments per year', 'Basic rate search'], features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
}, },
STARTER: { SILVER: {
name: 'Starter', name: 'Silver',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 49, monthlyPriceEur: 249,
yearlyPriceEur: 470, yearlyPriceEur: 2739, // 249 * 11 months
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 3, commissionRatePercent: 3,
statusBadge: 'silver', statusBadge: 'silver',
supportLevel: 'email', supportLevel: 'email',
planFeatures: PLAN_FEATURES.STARTER, planFeatures: PLAN_FEATURES.SILVER,
features: [ features: [
'Up to 5 users', "Jusqu'à 5 utilisateurs",
'Unlimited shipments', 'Expéditions illimitées',
'Dashboard', 'Tableau de bord',
'Maritime Wiki', 'Wiki Maritime',
'User management', 'Gestion des utilisateurs',
'CSV import', 'Import CSV',
'Email support', 'Support par email',
], ],
}, },
PRO: { GOLD: {
name: 'Pro', name: 'Gold',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 249, monthlyPriceEur: 899,
yearlyPriceEur: 2739, yearlyPriceEur: 9889, // 899 * 11 months
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 2, commissionRatePercent: 2,
statusBadge: 'gold', statusBadge: 'gold',
supportLevel: 'direct', supportLevel: 'direct',
planFeatures: PLAN_FEATURES.PRO, planFeatures: PLAN_FEATURES.GOLD,
features: [ features: [
'Up to 20 users', "Jusqu'à 20 utilisateurs",
'Unlimited shipments', 'Expéditions illimitées',
'All Starter features', 'Toutes les fonctionnalités Silver',
'API access', 'Intégration API',
'Direct commercial support', 'Assistance commerciale directe',
], ],
}, },
ENTERPRISE: { PLATINIUM: {
name: 'Enterprise', name: 'Platinium',
maxLicenses: -1, // unlimited maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing
@ -99,13 +98,13 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
commissionRatePercent: 1, commissionRatePercent: 1,
statusBadge: 'platinium', statusBadge: 'platinium',
supportLevel: 'dedicated_kam', supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.ENTERPRISE, planFeatures: PLAN_FEATURES.PLATINIUM,
features: [ features: [
'Unlimited users', 'Utilisateurs illimités',
'All Pro features', 'Toutes les fonctionnalités Gold',
'Dedicated Key Account Manager', 'Key Account Manager dédié',
'Custom interface', 'Interface personnalisable',
'Framework rate contracts', 'Contrats tarifaires cadre',
], ],
}, },
}; };
@ -122,18 +121,18 @@ export class SubscriptionPlan {
/** /**
* Create from string with legacy name support. * Create from string with legacy name support.
* Accepts both old DB names (BRONZE/SILVER/GOLD/PLATINIUM) and canonical names (FREE/STARTER/PRO/ENTERPRISE). * Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names.
*/ */
static fromString(value: string): SubscriptionPlan { static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase(); const upperValue = value.toUpperCase();
// Check legacy mapping first (DB values BRONZE/SILVER/GOLD/PLATINIUM) // Check legacy mapping first
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 (canonical names) // Try direct match
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) { if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
return new SubscriptionPlan(upperValue as SubscriptionPlanType); return new SubscriptionPlan(upperValue as SubscriptionPlanType);
} }
@ -142,41 +141,41 @@ export class SubscriptionPlan {
} }
// Named factories // Named factories
static free(): SubscriptionPlan {
return new SubscriptionPlan('FREE');
}
static starter(): SubscriptionPlan {
return new SubscriptionPlan('STARTER');
}
static pro(): SubscriptionPlan {
return new SubscriptionPlan('PRO');
}
static enterprise(): SubscriptionPlan {
return new SubscriptionPlan('ENTERPRISE');
}
// Legacy aliases (kept for backward compatibility)
static bronze(): SubscriptionPlan { static bronze(): SubscriptionPlan {
return SubscriptionPlan.free(); return new SubscriptionPlan('BRONZE');
} }
static silver(): SubscriptionPlan { static silver(): SubscriptionPlan {
return SubscriptionPlan.starter(); return new SubscriptionPlan('SILVER');
} }
static gold(): SubscriptionPlan { static gold(): SubscriptionPlan {
return SubscriptionPlan.pro(); return new SubscriptionPlan('GOLD');
} }
static platinium(): SubscriptionPlan { static platinium(): SubscriptionPlan {
return SubscriptionPlan.enterprise(); return new SubscriptionPlan('PLATINIUM');
}
// Legacy aliases
static free(): SubscriptionPlan {
return SubscriptionPlan.bronze();
}
static starter(): SubscriptionPlan {
return SubscriptionPlan.silver();
}
static pro(): SubscriptionPlan {
return SubscriptionPlan.gold();
}
static enterprise(): SubscriptionPlan {
return SubscriptionPlan.platinium();
} }
static getAllPlans(): SubscriptionPlan[] { static getAllPlans(): SubscriptionPlan[] {
return (['FREE', 'STARTER', 'PRO', 'ENTERPRISE'] as SubscriptionPlanType[]).map( return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map(
p => new SubscriptionPlan(p) p => new SubscriptionPlan(p)
); );
} }
@ -251,21 +250,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 !== 'FREE'; return this.plan !== 'BRONZE';
} }
/** /**
* Returns true if this is the free plan * Returns true if this is the free (Bronze) plan
*/ */
isFree(): boolean { isFree(): boolean {
return this.plan === 'FREE'; return this.plan === 'BRONZE';
} }
/** /**
* Returns true if this plan has custom pricing (Enterprise) * Returns true if this plan has custom pricing (Platinium)
*/ */
isCustomPricing(): boolean { isCustomPricing(): boolean {
return this.plan === 'ENTERPRISE'; return this.plan === 'PLATINIUM';
} }
/** /**
@ -280,7 +279,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[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
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;
@ -290,7 +289,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[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
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,20 +5,7 @@
*/ */
import { Subscription } from '@domain/entities/subscription.entity'; import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity'; import { SubscriptionOrmEntity } 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 {
/** /**
@ -30,7 +17,7 @@ export class SubscriptionOrmMapper {
orm.id = props.id; orm.id = props.id;
orm.organizationId = props.organizationId; orm.organizationId = props.organizationId;
orm.plan = DOMAIN_TO_ORM_PLAN[props.plan] ?? 'BRONZE'; orm.plan = props.plan;
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, 'STARTER'); if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER');
if (silverYearly) this.priceIdMap.set(silverYearly, 'STARTER'); if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER');
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'PRO'); if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD');
if (goldYearly) this.priceIdMap.set(goldYearly, 'PRO'); if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD');
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'ENTERPRISE'); if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM');
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'ENTERPRISE'); if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM');
this.planPriceMap.set('STARTER', { this.planPriceMap.set('SILVER', {
monthly: silverMonthly || '', monthly: silverMonthly || '',
yearly: silverYearly || '', yearly: silverYearly || '',
}); });
this.planPriceMap.set('PRO', { this.planPriceMap.set('GOLD', {
monthly: goldMonthly || '', monthly: goldMonthly || '',
yearly: goldYearly || '', yearly: goldYearly || '',
}); });
this.planPriceMap.set('ENTERPRISE', { this.planPriceMap.set('PLATINIUM', {
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().locator('button:has-text("Book")').click(); await page.locator('.rate-card').first().click('button:has-text("Book")');
// Should navigate to booking form // Should navigate to booking form
await expect(page).toHaveURL(/.*bookings\/create/); await expect(page).toHaveURL(/.*bookings\/create/);