CognxSafeTrack commited on
Commit
c37b68c
·
1 Parent(s): b035578

feat: implement organization-specific API keys and tenant-aware webhooks (Phase 2)

Browse files
apps/api/src/routes/payments.ts CHANGED
@@ -103,7 +103,12 @@ export async function stripeWebhookRoute(fastify: FastifyInstance) {
103
  }
104
  });
105
 
106
- fastify.post('/webhook', async (request, reply) => {
 
 
 
 
 
107
  const sig = request.headers['stripe-signature'];
108
 
109
  if (!sig || typeof sig !== 'string') {
@@ -115,10 +120,10 @@ export async function stripeWebhookRoute(fastify: FastifyInstance) {
115
  try {
116
  const rawBody = request.rawBody;
117
  if (!rawBody) throw new Error('Missing raw body');
118
- event = stripeService.verifyWebhookSignature(rawBody, sig);
119
  } catch (err: unknown) {
120
  const errorMsg = err instanceof Error ? err.message : String(err);
121
- fastify.log.warn(`[Stripe Webhook] Signature verification failed: ${errorMsg}`);
122
  return reply.status(400).send(`Webhook Error: ${errorMsg}`);
123
  }
124
 
 
103
  }
104
  });
105
 
106
+ // ── POST /webhook or /webhook/:organizationId ───────────────────────────
107
+ fastify.post('/webhook', async (request, reply) => handleWebhook(request, reply));
108
+ fastify.post('/webhook/:organizationId', async (request, reply) => handleWebhook(request, reply));
109
+
110
+ async function handleWebhook(request: any, reply: any) {
111
+ const { organizationId } = request.params as { organizationId?: string };
112
  const sig = request.headers['stripe-signature'];
113
 
114
  if (!sig || typeof sig !== 'string') {
 
120
  try {
121
  const rawBody = request.rawBody;
122
  if (!rawBody) throw new Error('Missing raw body');
123
+ event = await stripeService.verifyWebhookSignature(rawBody, sig, organizationId);
124
  } catch (err: unknown) {
125
  const errorMsg = err instanceof Error ? err.message : String(err);
126
+ fastify.log.warn(`[Stripe Webhook] Signature verification failed for Org ${organizationId || 'global'}: ${errorMsg}`);
127
  return reply.status(400).send(`Webhook Error: ${errorMsg}`);
128
  }
129
 
apps/api/src/routes/whatsapp.ts CHANGED
@@ -3,6 +3,7 @@ import { FastifyInstance } from 'fastify';
3
  import crypto from 'crypto';
4
  import { z } from 'zod';
5
  import { getOrganizationByPhoneNumberId } from '../services/organization';
 
6
 
7
  // ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
8
  const WhatsAppMessageSchema = z.object({
@@ -65,11 +66,7 @@ function verifyWebhookSignature(rawBody: Buffer, signature: string | undefined,
65
 
66
  // ─── Route Plugin ─────────────────────────────────────────────────────────────
67
  export async function whatsappRoutes(fastify: FastifyInstance) {
68
- // ── Raw body capture for HMAC verification ──────────────────────────────
69
- // We need the raw buffer BEFORE JSON.parse, so we override the content type parser
70
- // for this specific route scope only.
71
  fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
72
- // Store raw body on request for signature verification
73
  req.rawBody = body as Buffer;
74
  try {
75
  done(null, JSON.parse(body.toString('utf8')));
@@ -78,159 +75,82 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
78
  }
79
  });
80
 
81
- // ── GET /webhook — Meta verification handshake ──────────────────────────
82
  fastify.get('/webhook', async (request, reply) => {
83
  const query = request.query as Record<string, string>;
84
  const mode = query['hub.mode'];
85
  const token = query['hub.verify_token'];
86
  const challenge = query['hub.challenge'];
87
 
88
- // Parameterless ping from HF proxy or Meta health probe
89
- if (!mode && !token && !challenge) {
90
- return reply.code(200).type('text/plain').send('ok');
91
- }
92
 
93
- // Meta verification
94
  if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
95
- request.log.info('[WEBHOOK] Meta verification successful');
96
  return reply.code(200).type('text/plain').send(challenge);
97
  }
98
-
99
- request.log.warn('[WEBHOOK] Meta verification failed — token mismatch or wrong mode');
100
- return reply.code(403).type('text/plain').send('Forbidden');
101
  });
102
 
103
- // ── POST /webhook Incoming Meta events ────────────────────────────────
104
- fastify.post('/webhook', async (request, reply) => {
105
- // ── 1. HMAC Signature Verification ──────────────────────────────────
106
- logger.info("[RAW-WHATSAPP-PAYLOAD]", JSON.stringify(request.body, null, 2));
107
- const appSecret = process.env.WHATSAPP_APP_SECRET;
108
 
 
 
 
 
 
109
  if (appSecret) {
110
  const signature = request.headers['x-hub-signature-256'] as string;
111
- const rawBody = request.rawBody;
112
-
113
- // Optional: verify webhook signature. Important for production on both HF and Railway.
114
- if (!rawBody || !verifyWebhookSignature(rawBody, signature, appSecret)) {
115
- request.log.warn('[WEBHOOK] Invalid HMAC signature — request rejected');
116
- // Meta won't retry if we return 401/403 directly for invalid signatures.
117
  return reply.code(403).send({ error: 'Invalid signature' });
118
  }
119
- } else {
120
- // Log a warning but allow in development (no secret set)
121
- request.log.warn('[WEBHOOK] WHATSAPP_APP_SECRET not set — skipping signature verification');
122
  }
123
 
124
- // ── 2. Forward to Railway Internal Worker (if configured as Gateway) ──
125
  const railwayInternalUrl = process.env.RAILWAY_INTERNAL_URL;
126
-
127
- // Hardened Gateway Detection:
128
- // We are a gateway if:
129
- // 1. IS_GATEWAY is explicitly true
130
- // 2. We are running on Hugging Face (HF_SPACE_ID exists)
131
- // 3. We have a RAILWAY_INTERNAL_URL but NOT a RAILWAY_STATIC_URL (not on Railway)
132
- const isGateway =
133
- process.env.IS_GATEWAY === 'true' ||
134
- process.env.HF_SPACE_ID !== undefined ||
135
- (!!railwayInternalUrl && !process.env.RAILWAY_STATIC_URL);
136
 
137
  if (railwayInternalUrl && isGateway) {
138
- // Loop protection: don't forward to yourself
139
- const myUrl = process.env.RAILWAY_PUBLIC_URL || '';
140
- if (railwayInternalUrl.includes(myUrl) && myUrl !== '') {
141
- request.log.warn('[WEBHOOK] Loop detected: RAILWAY_INTERNAL_URL matches self. Skipping forward.');
142
- } else {
143
- const targetUrl = `${railwayInternalUrl.replace(/\/$/, '')}/v1/internal/whatsapp/inbound`;
144
- request.log.info(`[WEBHOOK] GATEWAY MODE: Forwarding payload to ${targetUrl}`);
145
-
146
- try {
147
- // Fire and forget (don't await) to ensure fast 200 response to Meta
148
- fetch(targetUrl, {
149
- method: 'POST',
150
- headers: {
151
- 'Content-Type': 'application/json',
152
- 'Authorization': `Bearer ${process.env.ADMIN_API_KEY || ''}`,
153
- 'X-Organization-Id': request.headers['x-organization-id'] as string || 'default-org-id'
154
- },
155
- body: request.body ? JSON.stringify(request.body) : ''
156
- }).then(res => {
157
- request.log.info(`[WEBHOOK] Gateway forward result: ${res.status} ${res.statusText}`);
158
- }).catch(err => {
159
- request.log.error(`[WEBHOOK] Forward to Railway failed: ${err instanceof Error ? err.message : String(err)}`);
160
- });
161
-
162
- // 🚨 CRITICAL: CRUCIAL EXIT POINT FOR GATEWAY
163
- return reply.code(200).send('EVENT_RECEIVED');
164
- } catch (error: unknown) {
165
- request.log.error(`[WEBHOOK] Forward throwing error: ${error instanceof Error ? error.message : String(error)}`);
166
- // Still return 200 to Meta to avoid retries, even if gateway forward failed internally
167
- return reply.code(200).send('EVENT_RECEIVED_FW_ERR');
168
- }
169
- }
170
- }
171
-
172
- // ── 3. DETACH IMMEDIATELY ──
173
- // Respond 200 OK right now to release the HF Gateway connection.
174
- // We do this BEFORE any parsing or work.
175
- if (!reply.sent) {
176
- reply.code(200).send('EVENT_RECEIVED');
177
  }
178
 
179
- // ── 4. Background Processing (enqueue to Worker) ──
180
- const body = request.body;
181
- setImmediate(async () => {
182
- try {
183
- const parsed = WebhookPayloadSchema.safeParse(body);
184
- if (!parsed.success) {
185
- fastify.log.warn(`[WEBHOOK] Failed to parse webhook payload: ${parsed.error.message}`);
186
- return;
187
- }
188
-
189
- const { scheduleInboundMessage } = await import('../services/queue');
190
- const payload = parsed.data;
191
-
192
- // 🏢 Multi-Tenant Routing (metadata is at change level)
193
- const phoneNumberId = payload.entry?.[0]?.changes?.[0]?.value?.metadata?.phone_number_id || 'unknown';
194
- const organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
195
-
196
- // Use Shared Utility
197
- const { extractWhatsAppPayload } = await import('@repo/shared-types');
198
- const extractedMessages = extractWhatsAppPayload(body);
199
-
200
- for (const msg of extractedMessages) {
201
- logger.info(`[WEBHOOK-TRACE] Processing message from ${msg.phone} (Org: ${organizationId})`);
202
-
203
- if (msg.text !== undefined) {
204
- await scheduleInboundMessage({
205
- phone: msg.phone,
206
- text: msg.text,
207
- messageId: msg.messageId,
208
- organizationId
209
- });
210
- } else if (msg.mediaId && msg.mediaType) {
211
- const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || undefined;
212
- const { whatsappQueue: q } = await import('../services/queue');
213
-
214
- await q.add('download-media', {
215
- mediaId: msg.mediaId,
216
- mimeType: msg.mediaType === 'image' ? 'image/jpeg' : 'audio/ogg',
217
- phone: msg.phone,
218
- organizationId,
219
- caption: msg.caption,
220
- ...(accessToken ? { accessToken } : {})
221
- }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
222
-
223
- await q.add('send-message-direct', {
224
- phone: msg.phone,
225
- text: msg.mediaType === 'image' ? "⏳ J'analyse ton image..." : "⏳ J'analyse ton audio...",
226
- organizationId
227
- });
228
- }
229
  }
230
- } catch (error) {
231
- fastify.log.error(`[WEBHOOK] Detached processing error: ${String(error)}`);
232
  }
233
- });
234
 
235
- });
 
236
  }
 
3
  import crypto from 'crypto';
4
  import { z } from 'zod';
5
  import { getOrganizationByPhoneNumberId } from '../services/organization';
6
+ import { whatsappQueue } from '../services/queue';
7
 
8
  // ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
9
  const WhatsAppMessageSchema = z.object({
 
66
 
67
  // ─── Route Plugin ─────────────────────────────────────────────────────────────
68
  export async function whatsappRoutes(fastify: FastifyInstance) {
 
 
 
69
  fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
 
70
  req.rawBody = body as Buffer;
71
  try {
72
  done(null, JSON.parse(body.toString('utf8')));
 
75
  }
76
  });
77
 
 
78
  fastify.get('/webhook', async (request, reply) => {
79
  const query = request.query as Record<string, string>;
80
  const mode = query['hub.mode'];
81
  const token = query['hub.verify_token'];
82
  const challenge = query['hub.challenge'];
83
 
84
+ if (!mode && !token && !challenge) return reply.code(200).type('text/plain').send('ok');
 
 
 
85
 
 
86
  if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
 
87
  return reply.code(200).type('text/plain').send(challenge);
88
  }
89
+ return reply.code(403).send('Forbidden');
 
 
90
  });
91
 
92
+ fastify.post('/webhook', async (request, reply) => handleIncoming(request, reply));
93
+ fastify.post('/webhook/:organizationId', async (request, reply) => handleIncoming(request, reply));
 
 
 
94
 
95
+ async function handleIncoming(request: any, reply: any) {
96
+ const { organizationId: urlOrgId } = request.params as { organizationId?: string };
97
+
98
+ // 1. HMAC Verification
99
+ const appSecret = process.env.WHATSAPP_APP_SECRET;
100
  if (appSecret) {
101
  const signature = request.headers['x-hub-signature-256'] as string;
102
+ if (!request.rawBody || !verifyWebhookSignature(request.rawBody, signature, appSecret)) {
103
+ request.log.warn(`[WEBHOOK] Invalid HMAC for Org ${urlOrgId || 'global'}`);
 
 
 
 
104
  return reply.code(403).send({ error: 'Invalid signature' });
105
  }
 
 
 
106
  }
107
 
108
+ // 2. Gateway Forwarding
109
  const railwayInternalUrl = process.env.RAILWAY_INTERNAL_URL;
110
+ const isGateway = process.env.IS_GATEWAY === 'true' || !!process.env.HF_SPACE_ID;
 
 
 
 
 
 
 
 
 
111
 
112
  if (railwayInternalUrl && isGateway) {
113
+ const targetUrl = `${railwayInternalUrl.replace(/\/$/, '')}/v1/internal/whatsapp/inbound`;
114
+ fetch(targetUrl, {
115
+ method: 'POST',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ 'Authorization': `Bearer ${process.env.ADMIN_API_KEY}`,
119
+ 'x-organization-id': urlOrgId || ''
120
+ },
121
+ body: JSON.stringify(request.body)
122
+ }).catch(err => logger.error('[WEBHOOK] Forwarding failed:', err));
123
+
124
+ return reply.code(200).send({ status: 'forwarded' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
 
127
+ // 3. Queue for Processing
128
+ const parsed = WebhookPayloadSchema.safeParse(request.body);
129
+ if (!parsed.success) return reply.code(200).send({ status: 'ignored' });
130
+
131
+ for (const entry of parsed.data.entry) {
132
+ for (const change of entry.changes) {
133
+ const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
134
+
135
+ // Use URL OrgId if present, otherwise resolve from phone number
136
+ const organizationId = urlOrgId || await getOrganizationByPhoneNumberId(phoneNumberId);
137
+
138
+ for (const message of change.value.messages || []) {
139
+ await whatsappQueue.add('process-message', {
140
+ message,
141
+ organizationId,
142
+ metadata: {
143
+ phoneNumberId,
144
+ displayPhoneNumber: change.value.metadata?.display_phone_number
145
+ }
146
+ }, {
147
+ attempts: 3,
148
+ backoff: { type: 'exponential', delay: 1000 }
149
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  }
 
 
151
  }
152
+ }
153
 
154
+ return reply.code(200).send({ status: 'received' });
155
+ }
156
  }
apps/api/src/services/ai/index.ts CHANGED
@@ -15,6 +15,7 @@ import { getOrganizationId } from '@repo/database';
15
  import { prisma } from '../prisma';
16
  import { redis } from '../queue';
17
  import { ProviderRegistry, ProviderCapability } from './ProviderRegistry';
 
18
 
19
  class AIService {
20
  private registry: ProviderRegistry;
@@ -75,22 +76,61 @@ class AIService {
75
  }
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  private async callWithFailover<T>(
79
  prompt: string,
80
  schema: z.ZodSchema<T>,
81
  temperature?: number,
82
  imageUrl?: string
83
  ): Promise<{ data: T, source: string }> {
 
84
  const capability = imageUrl ? ProviderCapability.VISION : ProviderCapability.TEXT;
85
- const providers = this.registry.getProvidersFor(capability);
86
 
87
  for (const provider of providers) {
88
  try {
89
  const data = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl);
90
- logger.info(`[AI_INFO] ${provider.name} used successfully. (Capability: ${capability})`);
91
  return { data, source: provider.name };
92
  } catch (err) {
93
- logger.warn(`[AI_WARNING] ${provider.name} failed: ${(err as Error).message}. Trying next provider...`);
94
  }
95
  }
96
 
 
15
  import { prisma } from '../prisma';
16
  import { redis } from '../queue';
17
  import { ProviderRegistry, ProviderCapability } from './ProviderRegistry';
18
+ import { getTenantSecrets } from '../organization';
19
 
20
  class AIService {
21
  private registry: ProviderRegistry;
 
76
  }
77
  }
78
 
79
+ private async getProvidersForTenant(capability: ProviderCapability, organizationId?: string) {
80
+ if (!organizationId) return this.registry.getProvidersFor(capability);
81
+
82
+ // Check if tenant has custom keys
83
+ const secrets = await getTenantSecrets(organizationId);
84
+ if (!secrets || (!secrets.openAiApiKey && !secrets.googleAiApiKey)) {
85
+ return this.registry.getProvidersFor(capability);
86
+ }
87
+
88
+ // Create temporary registry for this request with tenant keys
89
+ const tenantRegistry = new ProviderRegistry();
90
+
91
+ if (secrets.googleAiApiKey) {
92
+ tenantRegistry.register('GEMINI_TENANT', new GeminiProvider(secrets.googleAiApiKey), 1000, [
93
+ ProviderCapability.TEXT,
94
+ ProviderCapability.VISION
95
+ ]);
96
+ }
97
+
98
+ if (secrets.openAiApiKey) {
99
+ tenantRegistry.register('OPENAI_TENANT', new OpenAIProvider(secrets.openAiApiKey), 500, [
100
+ ProviderCapability.TEXT,
101
+ ProviderCapability.VISION,
102
+ ProviderCapability.AUDIO_TRANSCRIPTION,
103
+ ProviderCapability.SPEECH_GENERATION,
104
+ ProviderCapability.IMAGE_GENERATION
105
+ ]);
106
+ }
107
+
108
+ // Add global providers as fallback
109
+ const globalProviders = this.registry.getProvidersFor(capability);
110
+ for (const p of globalProviders) {
111
+ tenantRegistry.register(p.name, p.instance, p.priority, p.capabilities);
112
+ }
113
+
114
+ return tenantRegistry.getProvidersFor(capability);
115
+ }
116
+
117
  private async callWithFailover<T>(
118
  prompt: string,
119
  schema: z.ZodSchema<T>,
120
  temperature?: number,
121
  imageUrl?: string
122
  ): Promise<{ data: T, source: string }> {
123
+ const organizationId = getOrganizationId();
124
  const capability = imageUrl ? ProviderCapability.VISION : ProviderCapability.TEXT;
125
+ const providers = await this.getProvidersForTenant(capability, organizationId);
126
 
127
  for (const provider of providers) {
128
  try {
129
  const data = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl);
130
+ logger.info(`[AI_INFO] ${provider.name} used successfully for Org: ${organizationId || 'global'}. (Capability: ${capability})`);
131
  return { data, source: provider.name };
132
  } catch (err) {
133
+ logger.warn(`[AI_WARNING] ${provider.name} failed for Org ${organizationId || 'global'}: ${(err as Error).message}. Trying next provider...`);
134
  }
135
  }
136
 
apps/api/src/services/organization.ts CHANGED
@@ -73,5 +73,28 @@ export function encryptSecrets(data: any) {
73
  export function decryptSecrets(org: any) {
74
  if (org.systemUserToken) org.systemUserToken = decrypt(org.systemUserToken, ENCRYPTION_SECRET);
75
  if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
 
 
 
76
  return org;
77
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  export function decryptSecrets(org: any) {
74
  if (org.systemUserToken) org.systemUserToken = decrypt(org.systemUserToken, ENCRYPTION_SECRET);
75
  if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
76
+ if (org.openAiApiKey) org.openAiApiKey = decrypt(org.openAiApiKey, ENCRYPTION_SECRET);
77
+ if (org.googleAiApiKey) org.googleAiApiKey = decrypt(org.googleAiApiKey, ENCRYPTION_SECRET);
78
+ if (org.stripeSecretKey) org.stripeSecretKey = decrypt(org.stripeSecretKey, ENCRYPTION_SECRET);
79
  return org;
80
  }
81
+
82
+ /**
83
+ * Retrieves all secrets for a tenant, decrypted.
84
+ */
85
+ export async function getTenantSecrets(organizationId: string) {
86
+ const org = await prisma.organization.findUnique({
87
+ where: { id: organizationId },
88
+ select: {
89
+ systemUserToken: true,
90
+ webhookSecret: true,
91
+ openAiApiKey: true,
92
+ googleAiApiKey: true,
93
+ stripeSecretKey: true,
94
+ stripeWebhookSecret: true
95
+ }
96
+ });
97
+
98
+ if (!org) return null;
99
+ return decryptSecrets(org);
100
+ }
apps/api/src/services/stripe.ts CHANGED
@@ -1,12 +1,11 @@
1
  import { logger } from '../logger';
2
  import Stripe from 'stripe';
3
  import { PaymentProvider, CheckoutSessionParams } from './payments/types';
 
4
 
5
  export class StripeService implements PaymentProvider {
6
  public name = 'stripe';
7
- private stripe: Stripe | null = null;
8
- private webhookSecret: string | null = null;
9
- private clientUrl: string;
10
 
11
  constructor() {
12
  const secretKey = process.env.STRIPE_SECRET_KEY;
@@ -22,11 +21,37 @@ export class StripeService implements PaymentProvider {
22
  }
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  /**
26
  * Unified checkout session creator for the interface
27
  */
28
  async createCheckoutSession(params: CheckoutSessionParams): Promise<string> {
29
- if (!this.stripe) throw new Error('[StripeService] STRIPE_SECRET_KEY is not configured');
 
30
 
31
  // Decide mode based on params
32
  const isSubscription = !!params.organizationId && !params.trackId;
@@ -36,7 +61,7 @@ export class StripeService implements PaymentProvider {
36
  if (!priceId) throw new Error('[StripeService] Missing Price ID for checkout');
37
 
38
  try {
39
- const session = await this.stripe.checkout.sessions.create({
40
  payment_method_types: ['card'],
41
  line_items: [{ price: priceId, quantity: 1 }],
42
  mode: mode as any,
@@ -75,19 +100,21 @@ export class StripeService implements PaymentProvider {
75
  /**
76
  * Verifies the signature of an incoming Stripe webhook.
77
  */
78
- verifyWebhookSignature(payload: Buffer, signature: string | undefined): Stripe.Event {
79
- if (!this.stripe || !this.webhookSecret) {
80
- throw new Error('[StripeService] Stripe is not configured (missing keys)');
 
 
81
  }
82
  if (!signature) {
83
  throw new Error('Missing stripe-signature header');
84
  }
85
 
86
  try {
87
- return this.stripe.webhooks.constructEvent(
88
  payload,
89
  signature,
90
- this.webhookSecret
91
  );
92
  } catch (err: unknown) {
93
  throw new Error(`Webhook Error: ${(err instanceof Error ? err.message : String(err))}`);
@@ -97,11 +124,12 @@ export class StripeService implements PaymentProvider {
97
  /**
98
  * Creates a link to the Stripe Customer Portal for subscription management.
99
  */
100
- async createCustomerPortalSession(customerId: string) {
101
- if (!this.stripe) throw new Error('[StripeService] Stripe not configured');
 
102
 
103
  try {
104
- const session = await this.stripe.billingPortal.sessions.create({
105
  customer: customerId,
106
  return_url: `${this.clientUrl}/settings`,
107
  });
 
1
  import { logger } from '../logger';
2
  import Stripe from 'stripe';
3
  import { PaymentProvider, CheckoutSessionParams } from './payments/types';
4
+ import { getTenantSecrets } from './organization';
5
 
6
  export class StripeService implements PaymentProvider {
7
  public name = 'stripe';
8
+ private instances: Map<string, { stripe: Stripe, webhookSecret: string | null }> = new Map();
 
 
9
 
10
  constructor() {
11
  const secretKey = process.env.STRIPE_SECRET_KEY;
 
21
  }
22
  }
23
 
24
+ private async getStripeInstance(organizationId?: string): Promise<{ stripe: Stripe | null, webhookSecret: string | null }> {
25
+ if (!organizationId) return { stripe: this.stripe, webhookSecret: this.webhookSecret };
26
+
27
+ // Check cache
28
+ if (this.instances.has(organizationId)) {
29
+ return this.instances.get(organizationId)!;
30
+ }
31
+
32
+ // Check DB for tenant secrets
33
+ const secrets = await getTenantSecrets(organizationId);
34
+ if (secrets?.stripeSecretKey) {
35
+ const instance = {
36
+ stripe: new Stripe(secrets.stripeSecretKey, {
37
+ apiVersion: '2025-01-27.acacia' as any,
38
+ }),
39
+ webhookSecret: secrets.stripeWebhookSecret || null
40
+ };
41
+ this.instances.set(organizationId, instance);
42
+ return instance;
43
+ }
44
+
45
+ // Fallback to global
46
+ return { stripe: this.stripe, webhookSecret: this.webhookSecret };
47
+ }
48
+
49
  /**
50
  * Unified checkout session creator for the interface
51
  */
52
  async createCheckoutSession(params: CheckoutSessionParams): Promise<string> {
53
+ const { stripe } = await this.getStripeInstance(params.organizationId);
54
+ if (!stripe) throw new Error('[StripeService] Stripe is not configured for this organization');
55
 
56
  // Decide mode based on params
57
  const isSubscription = !!params.organizationId && !params.trackId;
 
61
  if (!priceId) throw new Error('[StripeService] Missing Price ID for checkout');
62
 
63
  try {
64
+ const session = await stripe.checkout.sessions.create({
65
  payment_method_types: ['card'],
66
  line_items: [{ price: priceId, quantity: 1 }],
67
  mode: mode as any,
 
100
  /**
101
  * Verifies the signature of an incoming Stripe webhook.
102
  */
103
+ async verifyWebhookSignature(payload: Buffer, signature: string | undefined, organizationId?: string): Promise<Stripe.Event> {
104
+ const { stripe, webhookSecret } = await this.getStripeInstance(organizationId);
105
+
106
+ if (!stripe || !webhookSecret) {
107
+ throw new Error('[StripeService] Stripe is not configured for this organization');
108
  }
109
  if (!signature) {
110
  throw new Error('Missing stripe-signature header');
111
  }
112
 
113
  try {
114
+ return stripe.webhooks.constructEvent(
115
  payload,
116
  signature,
117
+ webhookSecret
118
  );
119
  } catch (err: unknown) {
120
  throw new Error(`Webhook Error: ${(err instanceof Error ? err.message : String(err))}`);
 
124
  /**
125
  * Creates a link to the Stripe Customer Portal for subscription management.
126
  */
127
+ async createCustomerPortalSession(customerId: string, organizationId?: string) {
128
+ const { stripe } = await this.getStripeInstance(organizationId);
129
+ if (!stripe) throw new Error('[StripeService] Stripe not configured for this organization');
130
 
131
  try {
132
+ const session = await stripe.billingPortal.sessions.create({
133
  customer: customerId,
134
  return_url: `${this.clientUrl}/settings`,
135
  });