216 lines
5.5 KiB
TypeScript
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, '-');
|
|
}
|
|
}
|