xpeditis2.0/apps/backend/src/domain/value-objects/subscription-status.vo.ts
2026-01-20 11:28:54 +01:00

216 lines
5.5 KiB
TypeScript

/**
* Subscription Status Value Object
*
* Represents the different statuses a subscription can have.
* Follows Stripe subscription lifecycle states.
*/
export type SubscriptionStatusType =
| 'ACTIVE'
| 'PAST_DUE'
| 'CANCELED'
| 'INCOMPLETE'
| 'INCOMPLETE_EXPIRED'
| 'TRIALING'
| 'UNPAID'
| 'PAUSED';
interface StatusDetails {
readonly label: string;
readonly description: string;
readonly allowsAccess: boolean;
readonly requiresAction: boolean;
}
const STATUS_DETAILS: Record<SubscriptionStatusType, StatusDetails> = {
ACTIVE: {
label: 'Active',
description: 'Subscription is active and fully paid',
allowsAccess: true,
requiresAction: false,
},
PAST_DUE: {
label: 'Past Due',
description: 'Payment failed but subscription still active. Action required.',
allowsAccess: true, // Grace period
requiresAction: true,
},
CANCELED: {
label: 'Canceled',
description: 'Subscription has been canceled',
allowsAccess: false,
requiresAction: false,
},
INCOMPLETE: {
label: 'Incomplete',
description: 'Initial payment failed during subscription creation',
allowsAccess: false,
requiresAction: true,
},
INCOMPLETE_EXPIRED: {
label: 'Incomplete Expired',
description: 'Subscription creation payment window expired',
allowsAccess: false,
requiresAction: false,
},
TRIALING: {
label: 'Trialing',
description: 'Subscription is in trial period',
allowsAccess: true,
requiresAction: false,
},
UNPAID: {
label: 'Unpaid',
description: 'All payment retry attempts have failed',
allowsAccess: false,
requiresAction: true,
},
PAUSED: {
label: 'Paused',
description: 'Subscription has been paused',
allowsAccess: false,
requiresAction: false,
},
};
// Status transitions that are valid
const VALID_TRANSITIONS: Record<SubscriptionStatusType, SubscriptionStatusType[]> = {
ACTIVE: ['PAST_DUE', 'CANCELED', 'PAUSED'],
PAST_DUE: ['ACTIVE', 'CANCELED', 'UNPAID'],
CANCELED: [], // Terminal state
INCOMPLETE: ['ACTIVE', 'INCOMPLETE_EXPIRED'],
INCOMPLETE_EXPIRED: [], // Terminal state
TRIALING: ['ACTIVE', 'PAST_DUE', 'CANCELED'],
UNPAID: ['ACTIVE', 'CANCELED'],
PAUSED: ['ACTIVE', 'CANCELED'],
};
export class SubscriptionStatus {
private constructor(private readonly status: SubscriptionStatusType) {}
static create(status: SubscriptionStatusType): SubscriptionStatus {
if (!STATUS_DETAILS[status]) {
throw new Error(`Invalid subscription status: ${status}`);
}
return new SubscriptionStatus(status);
}
static fromString(value: string): SubscriptionStatus {
const upperValue = value.toUpperCase().replace(/-/g, '_') as SubscriptionStatusType;
if (!STATUS_DETAILS[upperValue]) {
throw new Error(`Invalid subscription status: ${value}`);
}
return new SubscriptionStatus(upperValue);
}
static fromStripeStatus(stripeStatus: string): SubscriptionStatus {
// Map Stripe status to our internal status
const mapping: Record<string, SubscriptionStatusType> = {
active: 'ACTIVE',
past_due: 'PAST_DUE',
canceled: 'CANCELED',
incomplete: 'INCOMPLETE',
incomplete_expired: 'INCOMPLETE_EXPIRED',
trialing: 'TRIALING',
unpaid: 'UNPAID',
paused: 'PAUSED',
};
const mappedStatus = mapping[stripeStatus.toLowerCase()];
if (!mappedStatus) {
throw new Error(`Unknown Stripe subscription status: ${stripeStatus}`);
}
return new SubscriptionStatus(mappedStatus);
}
static active(): SubscriptionStatus {
return new SubscriptionStatus('ACTIVE');
}
static canceled(): SubscriptionStatus {
return new SubscriptionStatus('CANCELED');
}
static pastDue(): SubscriptionStatus {
return new SubscriptionStatus('PAST_DUE');
}
static trialing(): SubscriptionStatus {
return new SubscriptionStatus('TRIALING');
}
get value(): SubscriptionStatusType {
return this.status;
}
get label(): string {
return STATUS_DETAILS[this.status].label;
}
get description(): string {
return STATUS_DETAILS[this.status].description;
}
/**
* Returns true if this status allows access to the platform
*/
allowsAccess(): boolean {
return STATUS_DETAILS[this.status].allowsAccess;
}
/**
* Returns true if this status requires user action (e.g., update payment method)
*/
requiresAction(): boolean {
return STATUS_DETAILS[this.status].requiresAction;
}
/**
* Returns true if this is a terminal state (cannot transition out)
*/
isTerminal(): boolean {
return VALID_TRANSITIONS[this.status].length === 0;
}
/**
* Returns true if the subscription is in good standing
*/
isInGoodStanding(): boolean {
return this.status === 'ACTIVE' || this.status === 'TRIALING';
}
/**
* Check if transition to new status is valid
*/
canTransitionTo(newStatus: SubscriptionStatus): boolean {
return VALID_TRANSITIONS[this.status].includes(newStatus.value);
}
/**
* Transition to new status if valid
*/
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
if (!this.canTransitionTo(newStatus)) {
throw new Error(
`Invalid status transition from ${this.status} to ${newStatus.value}`,
);
}
return newStatus;
}
equals(other: SubscriptionStatus): boolean {
return this.status === other.status;
}
toString(): string {
return this.status;
}
/**
* Convert to Stripe-compatible status string
*/
toStripeStatus(): string {
return this.status.toLowerCase().replace(/_/g, '-');
}
}