265 lines
8.4 KiB
TypeScript
265 lines
8.4 KiB
TypeScript
/**
|
|
* Stripe Adapter
|
|
*
|
|
* Implementation of the StripePort interface using the Stripe SDK.
|
|
*/
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import Stripe from 'stripe';
|
|
import {
|
|
StripePort,
|
|
CreateCheckoutSessionInput,
|
|
CreateCheckoutSessionOutput,
|
|
CreateCommissionCheckoutInput,
|
|
CreateCommissionCheckoutOutput,
|
|
CreatePortalSessionInput,
|
|
CreatePortalSessionOutput,
|
|
StripeSubscriptionData,
|
|
StripeCheckoutSessionData,
|
|
StripeWebhookEvent,
|
|
} from '@domain/ports/out/stripe.port';
|
|
import { SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
|
|
|
|
@Injectable()
|
|
export class StripeAdapter implements StripePort {
|
|
private readonly logger = new Logger(StripeAdapter.name);
|
|
private readonly stripe: Stripe;
|
|
private readonly webhookSecret: string;
|
|
private readonly priceIdMap: Map<string, SubscriptionPlanType>;
|
|
private readonly planPriceMap: Map<string, { monthly: string; yearly: string }>;
|
|
|
|
constructor(private readonly configService: ConfigService) {
|
|
const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
|
if (!apiKey) {
|
|
this.logger.warn('STRIPE_SECRET_KEY not configured - Stripe features will be disabled');
|
|
}
|
|
|
|
this.stripe = new Stripe(apiKey || 'sk_test_placeholder');
|
|
|
|
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
|
|
|
|
// Map Stripe price IDs to plans
|
|
this.priceIdMap = new Map();
|
|
this.planPriceMap = new Map();
|
|
|
|
// Configure plan price IDs from environment
|
|
const silverMonthly = this.configService.get<string>('STRIPE_SILVER_MONTHLY_PRICE_ID');
|
|
const silverYearly = this.configService.get<string>('STRIPE_SILVER_YEARLY_PRICE_ID');
|
|
const goldMonthly = this.configService.get<string>('STRIPE_GOLD_MONTHLY_PRICE_ID');
|
|
const goldYearly = this.configService.get<string>('STRIPE_GOLD_YEARLY_PRICE_ID');
|
|
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');
|
|
|
|
this.planPriceMap.set('SILVER', {
|
|
monthly: silverMonthly || '',
|
|
yearly: silverYearly || '',
|
|
});
|
|
this.planPriceMap.set('GOLD', {
|
|
monthly: goldMonthly || '',
|
|
yearly: goldYearly || '',
|
|
});
|
|
this.planPriceMap.set('PLATINIUM', {
|
|
monthly: platiniumMonthly || '',
|
|
yearly: platiniumYearly || '',
|
|
});
|
|
}
|
|
|
|
async createCheckoutSession(
|
|
input: CreateCheckoutSessionInput
|
|
): Promise<CreateCheckoutSessionOutput> {
|
|
const planPrices = this.planPriceMap.get(input.plan);
|
|
if (!planPrices) {
|
|
throw new Error(`No price configuration for plan: ${input.plan}`);
|
|
}
|
|
|
|
const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly;
|
|
|
|
if (!priceId) {
|
|
throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`);
|
|
}
|
|
|
|
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
|
mode: 'subscription',
|
|
payment_method_types: ['card'],
|
|
line_items: [
|
|
{
|
|
price: priceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
success_url: input.successUrl,
|
|
cancel_url: input.cancelUrl,
|
|
customer_email: input.customerId ? undefined : input.email,
|
|
customer: input.customerId || undefined,
|
|
metadata: {
|
|
organizationId: input.organizationId,
|
|
organizationName: input.organizationName,
|
|
plan: input.plan,
|
|
},
|
|
subscription_data: {
|
|
metadata: {
|
|
organizationId: input.organizationId,
|
|
plan: input.plan,
|
|
},
|
|
},
|
|
allow_promotion_codes: true,
|
|
billing_address_collection: 'required',
|
|
};
|
|
|
|
const session = await this.stripe.checkout.sessions.create(sessionParams);
|
|
|
|
this.logger.log(
|
|
`Created checkout session ${session.id} for organization ${input.organizationId}`
|
|
);
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
sessionUrl: session.url || '',
|
|
};
|
|
}
|
|
|
|
async createCommissionCheckout(
|
|
input: CreateCommissionCheckoutInput
|
|
): Promise<CreateCommissionCheckoutOutput> {
|
|
const session = await this.stripe.checkout.sessions.create({
|
|
mode: 'payment',
|
|
payment_method_types: ['card'],
|
|
line_items: [
|
|
{
|
|
price_data: {
|
|
currency: input.currency,
|
|
unit_amount: input.amountCents,
|
|
product_data: {
|
|
name: 'Commission Xpeditis',
|
|
description: input.bookingDescription,
|
|
},
|
|
},
|
|
quantity: 1,
|
|
},
|
|
],
|
|
customer_email: input.customerEmail,
|
|
success_url: input.successUrl,
|
|
cancel_url: input.cancelUrl,
|
|
metadata: {
|
|
type: 'commission',
|
|
bookingId: input.bookingId,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
this.logger.log(
|
|
`Created commission checkout session ${session.id} for booking ${input.bookingId}`
|
|
);
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
sessionUrl: session.url || '',
|
|
};
|
|
}
|
|
|
|
async createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput> {
|
|
const session = await this.stripe.billingPortal.sessions.create({
|
|
customer: input.customerId,
|
|
return_url: input.returnUrl,
|
|
});
|
|
|
|
this.logger.log(`Created portal session for customer ${input.customerId}`);
|
|
|
|
return {
|
|
sessionUrl: session.url,
|
|
};
|
|
}
|
|
|
|
async getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null> {
|
|
try {
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
// Get the price ID from the first item
|
|
const priceId = subscription.items.data[0]?.price.id || '';
|
|
|
|
return {
|
|
subscriptionId: subscription.id,
|
|
customerId: subscription.customer as string,
|
|
status: subscription.status,
|
|
planId: priceId,
|
|
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
|
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
};
|
|
} catch (error) {
|
|
if ((error as any).code === 'resource_missing') {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null> {
|
|
try {
|
|
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
customerId: session.customer as string | null,
|
|
subscriptionId: session.subscription as string | null,
|
|
status: session.status || 'unknown',
|
|
metadata: (session.metadata || {}) as Record<string, string>,
|
|
};
|
|
} catch (error) {
|
|
if ((error as any).code === 'resource_missing') {
|
|
return null;
|
|
}
|
|
this.logger.error(`Failed to retrieve checkout session ${sessionId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void> {
|
|
await this.stripe.subscriptions.update(subscriptionId, {
|
|
cancel_at_period_end: true,
|
|
});
|
|
|
|
this.logger.log(`Scheduled subscription ${subscriptionId} for cancellation at period end`);
|
|
}
|
|
|
|
async cancelSubscriptionImmediately(subscriptionId: string): Promise<void> {
|
|
await this.stripe.subscriptions.cancel(subscriptionId);
|
|
|
|
this.logger.log(`Cancelled subscription ${subscriptionId} immediately`);
|
|
}
|
|
|
|
async resumeSubscription(subscriptionId: string): Promise<void> {
|
|
await this.stripe.subscriptions.update(subscriptionId, {
|
|
cancel_at_period_end: false,
|
|
});
|
|
|
|
this.logger.log(`Resumed subscription ${subscriptionId}`);
|
|
}
|
|
|
|
async constructWebhookEvent(
|
|
payload: string | Buffer,
|
|
signature: string
|
|
): Promise<StripeWebhookEvent> {
|
|
const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret);
|
|
|
|
return {
|
|
type: event.type,
|
|
data: {
|
|
object: event.data.object as Record<string, unknown>,
|
|
},
|
|
};
|
|
}
|
|
|
|
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null {
|
|
return this.priceIdMap.get(priceId) || null;
|
|
}
|
|
}
|