xpeditis2.0/apps/backend/src/infrastructure/stripe/stripe.adapter.ts
2026-03-18 15:11:09 +01:00

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;
}
}