@payloops/processor-core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/Dockerfile +41 -0
- package/README.md +248 -0
- package/dist/activities/index.d.ts +2 -0
- package/dist/activities/index.js +19 -0
- package/dist/chunk-AV4OEJXY.js +132 -0
- package/dist/chunk-CZTZBCNV.js +133 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-X2Y2ZUQA.js +297 -0
- package/dist/index-CXmmtGo_.d.ts +25 -0
- package/dist/index-CsnpS83V.d.ts +72 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +21 -0
- package/dist/workflows/index.d.ts +27 -0
- package/dist/workflows/index.js +13 -0
- package/package.json +54 -0
- package/src/activities/index.ts +224 -0
- package/src/index.ts +22 -0
- package/src/lib/crypto.ts +24 -0
- package/src/lib/db.ts +91 -0
- package/src/lib/observability/index.ts +2 -0
- package/src/lib/observability/logger.ts +44 -0
- package/src/lib/observability/otel.ts +53 -0
- package/src/lib/registry.ts +28 -0
- package/src/types/index.ts +95 -0
- package/src/worker.ts +105 -0
- package/src/workflows/index.ts +5 -0
- package/src/workflows/payment.ts +111 -0
- package/src/workflows/webhook.ts +64 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { eq, and } from 'drizzle-orm';
|
|
2
|
+
import { getDb, orders, transactions, processorConfigs, webhookEvents, merchants } from '../lib/db';
|
|
3
|
+
import { decrypt } from '../lib/crypto';
|
|
4
|
+
import { getProcessor } from '../lib/registry';
|
|
5
|
+
import type { PaymentInput, PaymentResult, PaymentConfig, RefundResult, WebhookDeliveryResult } from '../types';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
// Get processor config for a merchant
|
|
9
|
+
export async function getProcessorConfig(
|
|
10
|
+
merchantId: string,
|
|
11
|
+
processor: string
|
|
12
|
+
): Promise<PaymentConfig | null> {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
|
|
15
|
+
const config = await db
|
|
16
|
+
.select()
|
|
17
|
+
.from(processorConfigs)
|
|
18
|
+
.where(and(eq(processorConfigs.merchantId, merchantId), eq(processorConfigs.processor, processor)))
|
|
19
|
+
.limit(1);
|
|
20
|
+
|
|
21
|
+
if (config.length === 0) return null;
|
|
22
|
+
|
|
23
|
+
const credentials = JSON.parse(decrypt(config[0].credentialsEncrypted));
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
merchantId,
|
|
27
|
+
processor,
|
|
28
|
+
testMode: config[0].testMode,
|
|
29
|
+
credentials
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Update order status
|
|
34
|
+
export async function updateOrderStatus(
|
|
35
|
+
orderId: string,
|
|
36
|
+
status: string,
|
|
37
|
+
processorOrderId?: string,
|
|
38
|
+
processorTransactionId?: string
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const db = getDb();
|
|
41
|
+
|
|
42
|
+
await db
|
|
43
|
+
.update(orders)
|
|
44
|
+
.set({
|
|
45
|
+
status,
|
|
46
|
+
processorOrderId: processorOrderId || undefined,
|
|
47
|
+
updatedAt: new Date()
|
|
48
|
+
})
|
|
49
|
+
.where(eq(orders.id, orderId));
|
|
50
|
+
|
|
51
|
+
// Create transaction record if we have a transaction id
|
|
52
|
+
if (processorTransactionId) {
|
|
53
|
+
const order = await db.select().from(orders).where(eq(orders.id, orderId)).limit(1);
|
|
54
|
+
|
|
55
|
+
if (order.length > 0) {
|
|
56
|
+
await db.insert(transactions).values({
|
|
57
|
+
orderId,
|
|
58
|
+
type: status === 'captured' ? 'capture' : status === 'authorized' ? 'authorization' : 'authorization',
|
|
59
|
+
amount: order[0].amount,
|
|
60
|
+
status: status === 'failed' ? 'failed' : 'success',
|
|
61
|
+
processorTransactionId
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Process payment using the appropriate processor
|
|
68
|
+
export async function processPayment(input: PaymentInput): Promise<PaymentResult> {
|
|
69
|
+
const config = await getProcessorConfig(input.merchantId, input.processor);
|
|
70
|
+
|
|
71
|
+
if (!config) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
status: 'failed',
|
|
75
|
+
errorCode: 'no_processor_config',
|
|
76
|
+
errorMessage: `Processor ${input.processor} not configured for merchant`
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const processor = getProcessor(input.processor);
|
|
81
|
+
return processor.createPayment(input, config);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Capture a payment
|
|
85
|
+
export async function capturePayment(
|
|
86
|
+
processorOrderId: string,
|
|
87
|
+
amount: number,
|
|
88
|
+
merchantId: string,
|
|
89
|
+
processor: string
|
|
90
|
+
): Promise<PaymentResult> {
|
|
91
|
+
const config = await getProcessorConfig(merchantId, processor);
|
|
92
|
+
|
|
93
|
+
if (!config) {
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
status: 'failed',
|
|
97
|
+
errorCode: 'no_processor_config',
|
|
98
|
+
errorMessage: `Processor ${processor} not configured`
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const processorInstance = getProcessor(processor);
|
|
103
|
+
return processorInstance.capturePayment(processorOrderId, amount, config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Refund a payment
|
|
107
|
+
export async function refundPayment(
|
|
108
|
+
processorTransactionId: string,
|
|
109
|
+
amount: number,
|
|
110
|
+
merchantId: string,
|
|
111
|
+
processor: string
|
|
112
|
+
): Promise<RefundResult> {
|
|
113
|
+
const config = await getProcessorConfig(merchantId, processor);
|
|
114
|
+
|
|
115
|
+
if (!config) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
status: 'failed',
|
|
119
|
+
errorCode: 'no_processor_config',
|
|
120
|
+
errorMessage: `Processor ${processor} not configured`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const processorInstance = getProcessor(processor);
|
|
125
|
+
return processorInstance.refundPayment(processorTransactionId, amount, config);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Deliver webhook to merchant
|
|
129
|
+
export async function deliverWebhook(
|
|
130
|
+
webhookEventId: string,
|
|
131
|
+
webhookUrl: string,
|
|
132
|
+
webhookSecret: string | undefined,
|
|
133
|
+
payload: Record<string, unknown>
|
|
134
|
+
): Promise<WebhookDeliveryResult> {
|
|
135
|
+
const db = getDb();
|
|
136
|
+
|
|
137
|
+
// Get current attempt count
|
|
138
|
+
const event = await db.select().from(webhookEvents).where(eq(webhookEvents.id, webhookEventId)).limit(1);
|
|
139
|
+
|
|
140
|
+
const attempts = (event[0]?.attempts || 0) + 1;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const body = JSON.stringify(payload);
|
|
144
|
+
const headers: Record<string, string> = {
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
'X-Loop-Event-Id': webhookEventId,
|
|
147
|
+
'X-Loop-Timestamp': String(Date.now())
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Sign the webhook if secret is provided
|
|
151
|
+
if (webhookSecret) {
|
|
152
|
+
const timestamp = headers['X-Loop-Timestamp'];
|
|
153
|
+
const signaturePayload = `${timestamp}.${body}`;
|
|
154
|
+
const signature = crypto.createHmac('sha256', webhookSecret).update(signaturePayload).digest('hex');
|
|
155
|
+
headers['X-Loop-Signature'] = `v1=${signature}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const response = await fetch(webhookUrl, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers,
|
|
161
|
+
body,
|
|
162
|
+
signal: AbortSignal.timeout(30000) // 30 second timeout
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const success = response.ok;
|
|
166
|
+
|
|
167
|
+
await db
|
|
168
|
+
.update(webhookEvents)
|
|
169
|
+
.set({
|
|
170
|
+
status: success ? 'delivered' : 'pending',
|
|
171
|
+
attempts,
|
|
172
|
+
lastAttemptAt: new Date(),
|
|
173
|
+
deliveredAt: success ? new Date() : undefined,
|
|
174
|
+
nextRetryAt: success ? undefined : new Date(Date.now() + getRetryDelay(attempts))
|
|
175
|
+
})
|
|
176
|
+
.where(eq(webhookEvents.id, webhookEventId));
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
success,
|
|
180
|
+
statusCode: response.status,
|
|
181
|
+
attempts,
|
|
182
|
+
deliveredAt: success ? new Date() : undefined,
|
|
183
|
+
errorMessage: success ? undefined : `HTTP ${response.status}`
|
|
184
|
+
};
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
187
|
+
|
|
188
|
+
await db
|
|
189
|
+
.update(webhookEvents)
|
|
190
|
+
.set({
|
|
191
|
+
status: attempts >= 5 ? 'failed' : 'pending',
|
|
192
|
+
attempts,
|
|
193
|
+
lastAttemptAt: new Date(),
|
|
194
|
+
nextRetryAt: attempts >= 5 ? undefined : new Date(Date.now() + getRetryDelay(attempts))
|
|
195
|
+
})
|
|
196
|
+
.where(eq(webhookEvents.id, webhookEventId));
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
attempts,
|
|
201
|
+
errorMessage
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Get merchant webhook URL
|
|
207
|
+
export async function getMerchantWebhookUrl(merchantId: string): Promise<{ url: string | null; secret: string | null }> {
|
|
208
|
+
const db = getDb();
|
|
209
|
+
|
|
210
|
+
const merchant = await db.select().from(merchants).where(eq(merchants.id, merchantId)).limit(1);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
url: merchant[0]?.webhookUrl || null,
|
|
214
|
+
secret: merchant[0]?.webhookSecret || null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Exponential backoff for retries
|
|
219
|
+
function getRetryDelay(attempt: number): number {
|
|
220
|
+
const baseDelay = 60 * 1000; // 1 minute
|
|
221
|
+
const maxDelay = 24 * 60 * 60 * 1000; // 24 hours
|
|
222
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
223
|
+
return Math.min(delay, maxDelay);
|
|
224
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Core types
|
|
2
|
+
export type {
|
|
3
|
+
PaymentConfig,
|
|
4
|
+
PaymentInput,
|
|
5
|
+
PaymentResult,
|
|
6
|
+
RefundInput,
|
|
7
|
+
RefundResult,
|
|
8
|
+
WebhookDeliveryInput,
|
|
9
|
+
WebhookDeliveryResult,
|
|
10
|
+
PaymentProcessor,
|
|
11
|
+
ProcessorRegistry
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
// Processor registry
|
|
15
|
+
export { registerProcessor, getProcessor, getRegisteredProcessors, hasProcessor } from './lib/registry';
|
|
16
|
+
|
|
17
|
+
// Activities (for worker registration)
|
|
18
|
+
export * as activities from './activities';
|
|
19
|
+
|
|
20
|
+
// Workflows
|
|
21
|
+
export { PaymentWorkflow, WebhookDeliveryWorkflow } from './workflows';
|
|
22
|
+
export type { PaymentWorkflowInput, WebhookDeliveryWorkflowInput } from './workflows';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
4
|
+
|
|
5
|
+
function getKey(): Buffer {
|
|
6
|
+
const key = process.env.ENCRYPTION_KEY;
|
|
7
|
+
if (!key) throw new Error('ENCRYPTION_KEY not set');
|
|
8
|
+
return createHash('sha256').update(key).digest();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function decrypt(encryptedText: string): string {
|
|
12
|
+
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
|
|
13
|
+
|
|
14
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
15
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
16
|
+
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
|
17
|
+
|
|
18
|
+
decipher.setAuthTag(authTag);
|
|
19
|
+
|
|
20
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
21
|
+
decrypted += decipher.final('utf8');
|
|
22
|
+
|
|
23
|
+
return decrypted;
|
|
24
|
+
}
|
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
import {
|
|
4
|
+
pgTable,
|
|
5
|
+
text,
|
|
6
|
+
timestamp,
|
|
7
|
+
integer,
|
|
8
|
+
boolean,
|
|
9
|
+
jsonb,
|
|
10
|
+
varchar,
|
|
11
|
+
uuid
|
|
12
|
+
} from 'drizzle-orm/pg-core';
|
|
13
|
+
|
|
14
|
+
// Schema definitions (shared with backend)
|
|
15
|
+
export const orders = pgTable('orders', {
|
|
16
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
17
|
+
merchantId: uuid('merchant_id').notNull(),
|
|
18
|
+
externalId: varchar('external_id', { length: 255 }).notNull(),
|
|
19
|
+
amount: integer('amount').notNull(),
|
|
20
|
+
currency: varchar('currency', { length: 3 }).notNull().default('USD'),
|
|
21
|
+
status: varchar('status', { length: 50 }).notNull().default('pending'),
|
|
22
|
+
processor: varchar('processor', { length: 50 }),
|
|
23
|
+
processorOrderId: varchar('processor_order_id', { length: 255 }),
|
|
24
|
+
metadata: jsonb('metadata').default({}),
|
|
25
|
+
workflowId: varchar('workflow_id', { length: 255 }),
|
|
26
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
27
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const transactions = pgTable('transactions', {
|
|
31
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
32
|
+
orderId: uuid('order_id').notNull(),
|
|
33
|
+
type: varchar('type', { length: 50 }).notNull(),
|
|
34
|
+
amount: integer('amount').notNull(),
|
|
35
|
+
status: varchar('status', { length: 50 }).notNull().default('pending'),
|
|
36
|
+
processorTransactionId: varchar('processor_transaction_id', { length: 255 }),
|
|
37
|
+
processorResponse: jsonb('processor_response'),
|
|
38
|
+
errorCode: varchar('error_code', { length: 100 }),
|
|
39
|
+
errorMessage: text('error_message'),
|
|
40
|
+
createdAt: timestamp('created_at').defaultNow().notNull()
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const processorConfigs = pgTable('processor_configs', {
|
|
44
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
45
|
+
merchantId: uuid('merchant_id').notNull(),
|
|
46
|
+
processor: varchar('processor', { length: 50 }).notNull(),
|
|
47
|
+
credentialsEncrypted: text('credentials_encrypted').notNull(),
|
|
48
|
+
priority: integer('priority').notNull().default(1),
|
|
49
|
+
enabled: boolean('enabled').notNull().default(true),
|
|
50
|
+
testMode: boolean('test_mode').notNull().default(true),
|
|
51
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
52
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const webhookEvents = pgTable('webhook_events', {
|
|
56
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
57
|
+
merchantId: uuid('merchant_id').notNull(),
|
|
58
|
+
orderId: uuid('order_id'),
|
|
59
|
+
eventType: varchar('event_type', { length: 100 }).notNull(),
|
|
60
|
+
payload: jsonb('payload').notNull(),
|
|
61
|
+
status: varchar('status', { length: 50 }).notNull().default('pending'),
|
|
62
|
+
attempts: integer('attempts').notNull().default(0),
|
|
63
|
+
lastAttemptAt: timestamp('last_attempt_at'),
|
|
64
|
+
nextRetryAt: timestamp('next_retry_at'),
|
|
65
|
+
deliveredAt: timestamp('delivered_at'),
|
|
66
|
+
workflowId: varchar('workflow_id', { length: 255 }),
|
|
67
|
+
createdAt: timestamp('created_at').defaultNow().notNull()
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const merchants = pgTable('merchants', {
|
|
71
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
72
|
+
name: varchar('name', { length: 255 }).notNull(),
|
|
73
|
+
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
74
|
+
webhookUrl: text('webhook_url'),
|
|
75
|
+
webhookSecret: text('webhook_secret')
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Database connection
|
|
79
|
+
let dbInstance: ReturnType<typeof drizzle> | null = null;
|
|
80
|
+
|
|
81
|
+
export function getDb() {
|
|
82
|
+
if (!dbInstance) {
|
|
83
|
+
const pool = new Pool({
|
|
84
|
+
connectionString: process.env.DATABASE_URL
|
|
85
|
+
});
|
|
86
|
+
dbInstance = drizzle(pool);
|
|
87
|
+
}
|
|
88
|
+
return dbInstance;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type Database = ReturnType<typeof getDb>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import { trace } from '@opentelemetry/api';
|
|
3
|
+
|
|
4
|
+
const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
5
|
+
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || 'loop-processor-core';
|
|
6
|
+
|
|
7
|
+
const traceMixin = () => {
|
|
8
|
+
const span = trace.getActiveSpan();
|
|
9
|
+
if (!span) return {};
|
|
10
|
+
|
|
11
|
+
const spanContext = span.spanContext();
|
|
12
|
+
return {
|
|
13
|
+
trace_id: spanContext.traceId,
|
|
14
|
+
span_id: spanContext.spanId
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const logger = pino({
|
|
19
|
+
level: NODE_ENV === 'production' ? 'info' : 'debug',
|
|
20
|
+
mixin: traceMixin,
|
|
21
|
+
base: {
|
|
22
|
+
service: SERVICE_NAME,
|
|
23
|
+
env: NODE_ENV
|
|
24
|
+
},
|
|
25
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
26
|
+
transport:
|
|
27
|
+
NODE_ENV !== 'production'
|
|
28
|
+
? { target: 'pino-pretty', options: { colorize: true } }
|
|
29
|
+
: undefined
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function createActivityLogger(activityName: string, correlationId?: string) {
|
|
33
|
+
return logger.child({
|
|
34
|
+
activity: activityName,
|
|
35
|
+
correlationId
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWorkflowLogger(workflowId: string, correlationId?: string) {
|
|
40
|
+
return logger.child({
|
|
41
|
+
workflowId,
|
|
42
|
+
correlationId
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
2
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
3
|
+
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
|
4
|
+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
|
5
|
+
import { Resource } from '@opentelemetry/resources';
|
|
6
|
+
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
|
7
|
+
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
|
8
|
+
|
|
9
|
+
let sdk: NodeSDK | null = null;
|
|
10
|
+
|
|
11
|
+
export function initTelemetry(serviceName: string, serviceVersion = '0.0.1'): NodeSDK {
|
|
12
|
+
if (sdk) return sdk;
|
|
13
|
+
|
|
14
|
+
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
|
|
15
|
+
|
|
16
|
+
sdk = new NodeSDK({
|
|
17
|
+
resource: new Resource({
|
|
18
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
19
|
+
[ATTR_SERVICE_VERSION]: serviceVersion,
|
|
20
|
+
'deployment.environment': process.env.NODE_ENV || 'development'
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
traceExporter: new OTLPTraceExporter({
|
|
24
|
+
url: `${otlpEndpoint}/v1/traces`
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
metricReader: new PeriodicExportingMetricReader({
|
|
28
|
+
exporter: new OTLPMetricExporter({
|
|
29
|
+
url: `${otlpEndpoint}/v1/metrics`
|
|
30
|
+
}),
|
|
31
|
+
exportIntervalMillis: 30000
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
instrumentations: [
|
|
35
|
+
getNodeAutoInstrumentations({
|
|
36
|
+
'@opentelemetry/instrumentation-fs': { enabled: false },
|
|
37
|
+
'@opentelemetry/instrumentation-http': { enabled: true },
|
|
38
|
+
'@opentelemetry/instrumentation-pg': { enabled: true }
|
|
39
|
+
})
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
sdk.start();
|
|
44
|
+
|
|
45
|
+
process.on('SIGTERM', () => {
|
|
46
|
+
sdk
|
|
47
|
+
?.shutdown()
|
|
48
|
+
.then(() => console.log('Telemetry shut down'))
|
|
49
|
+
.catch((err) => console.error('Telemetry shutdown error', err));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return sdk;
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PaymentProcessor, ProcessorRegistry } from '../types';
|
|
2
|
+
|
|
3
|
+
// Global processor registry
|
|
4
|
+
const processors: ProcessorRegistry = new Map();
|
|
5
|
+
|
|
6
|
+
export function registerProcessor(processor: PaymentProcessor): void {
|
|
7
|
+
if (processors.has(processor.name)) {
|
|
8
|
+
throw new Error(`Processor ${processor.name} is already registered`);
|
|
9
|
+
}
|
|
10
|
+
processors.set(processor.name, processor);
|
|
11
|
+
console.log(`Registered processor: ${processor.name}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getProcessor(name: string): PaymentProcessor {
|
|
15
|
+
const processor = processors.get(name);
|
|
16
|
+
if (!processor) {
|
|
17
|
+
throw new Error(`Processor ${name} not found. Available: ${Array.from(processors.keys()).join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
return processor;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getRegisteredProcessors(): string[] {
|
|
23
|
+
return Array.from(processors.keys());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hasProcessor(name: string): boolean {
|
|
27
|
+
return processors.has(name);
|
|
28
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface PaymentConfig {
|
|
2
|
+
merchantId: string;
|
|
3
|
+
processor: string;
|
|
4
|
+
testMode: boolean;
|
|
5
|
+
credentials: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PaymentInput {
|
|
9
|
+
orderId: string;
|
|
10
|
+
merchantId: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
currency: string;
|
|
13
|
+
processor: string;
|
|
14
|
+
returnUrl?: string;
|
|
15
|
+
cancelUrl?: string;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
customer?: {
|
|
18
|
+
id?: string;
|
|
19
|
+
email?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
};
|
|
22
|
+
paymentMethod?: {
|
|
23
|
+
type: 'card' | 'upi' | 'netbanking' | 'wallet';
|
|
24
|
+
token?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PaymentResult {
|
|
29
|
+
success: boolean;
|
|
30
|
+
status: 'authorized' | 'captured' | 'failed' | 'pending' | 'requires_action';
|
|
31
|
+
processorOrderId?: string;
|
|
32
|
+
processorTransactionId?: string;
|
|
33
|
+
redirectUrl?: string;
|
|
34
|
+
errorCode?: string;
|
|
35
|
+
errorMessage?: string;
|
|
36
|
+
metadata?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RefundInput {
|
|
40
|
+
orderId: string;
|
|
41
|
+
transactionId: string;
|
|
42
|
+
amount: number;
|
|
43
|
+
reason?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RefundResult {
|
|
47
|
+
success: boolean;
|
|
48
|
+
refundId?: string;
|
|
49
|
+
status: 'pending' | 'success' | 'failed';
|
|
50
|
+
errorCode?: string;
|
|
51
|
+
errorMessage?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WebhookDeliveryInput {
|
|
55
|
+
webhookEventId: string;
|
|
56
|
+
merchantId: string;
|
|
57
|
+
webhookUrl: string;
|
|
58
|
+
webhookSecret?: string;
|
|
59
|
+
payload: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WebhookDeliveryResult {
|
|
63
|
+
success: boolean;
|
|
64
|
+
statusCode?: number;
|
|
65
|
+
attempts: number;
|
|
66
|
+
deliveredAt?: Date;
|
|
67
|
+
errorMessage?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Processor interface that each processor must implement
|
|
71
|
+
export interface PaymentProcessor {
|
|
72
|
+
name: string;
|
|
73
|
+
|
|
74
|
+
createPayment(input: PaymentInput, config: PaymentConfig): Promise<PaymentResult>;
|
|
75
|
+
|
|
76
|
+
capturePayment(
|
|
77
|
+
processorOrderId: string,
|
|
78
|
+
amount: number,
|
|
79
|
+
config: PaymentConfig
|
|
80
|
+
): Promise<PaymentResult>;
|
|
81
|
+
|
|
82
|
+
refundPayment(
|
|
83
|
+
processorTransactionId: string,
|
|
84
|
+
amount: number,
|
|
85
|
+
config: PaymentConfig
|
|
86
|
+
): Promise<RefundResult>;
|
|
87
|
+
|
|
88
|
+
getPaymentStatus(
|
|
89
|
+
processorOrderId: string,
|
|
90
|
+
config: PaymentConfig
|
|
91
|
+
): Promise<PaymentResult>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Processor registry type
|
|
95
|
+
export type ProcessorRegistry = Map<string, PaymentProcessor>;
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Initialize OpenTelemetry FIRST, before any other imports
|
|
2
|
+
import { initTelemetry } from './lib/observability/otel';
|
|
3
|
+
initTelemetry(process.env.OTEL_SERVICE_NAME || 'loop-processor-core', '0.0.1');
|
|
4
|
+
|
|
5
|
+
import { Worker, NativeConnection } from '@temporalio/worker';
|
|
6
|
+
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
|
7
|
+
import * as activities from './activities';
|
|
8
|
+
import { logger } from './lib/observability/logger';
|
|
9
|
+
|
|
10
|
+
const tracer = trace.getTracer('loop-processor-core');
|
|
11
|
+
|
|
12
|
+
async function run() {
|
|
13
|
+
const connection = await NativeConnection.connect({
|
|
14
|
+
address: process.env.TEMPORAL_ADDRESS || 'localhost:7233'
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const worker = await Worker.create({
|
|
18
|
+
connection,
|
|
19
|
+
namespace: process.env.TEMPORAL_NAMESPACE || 'loop',
|
|
20
|
+
taskQueue: 'payment-queue',
|
|
21
|
+
workflowsPath: new URL('./workflows/index.js', import.meta.url).pathname,
|
|
22
|
+
activities,
|
|
23
|
+
interceptors: {
|
|
24
|
+
activityInbound: [
|
|
25
|
+
(ctx) => ({
|
|
26
|
+
async execute(input, next) {
|
|
27
|
+
const activityType = ctx.info.activityType;
|
|
28
|
+
const workflowId = ctx.info.workflowExecution.workflowId;
|
|
29
|
+
|
|
30
|
+
const span = tracer.startSpan(`activity.${activityType}`, {
|
|
31
|
+
attributes: {
|
|
32
|
+
'temporal.activity.type': activityType,
|
|
33
|
+
'temporal.workflow.id': workflowId,
|
|
34
|
+
'temporal.task_queue': ctx.info.taskQueue
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
logger.info(
|
|
39
|
+
{
|
|
40
|
+
activity: activityType,
|
|
41
|
+
workflowId
|
|
42
|
+
},
|
|
43
|
+
'Activity started'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const startTime = Date.now();
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const result = await next(input);
|
|
50
|
+
|
|
51
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
52
|
+
|
|
53
|
+
logger.info(
|
|
54
|
+
{
|
|
55
|
+
activity: activityType,
|
|
56
|
+
workflowId,
|
|
57
|
+
duration: Date.now() - startTime
|
|
58
|
+
},
|
|
59
|
+
'Activity completed'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
span.setStatus({
|
|
65
|
+
code: SpanStatusCode.ERROR,
|
|
66
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
67
|
+
});
|
|
68
|
+
span.recordException(error as Error);
|
|
69
|
+
|
|
70
|
+
logger.error(
|
|
71
|
+
{
|
|
72
|
+
activity: activityType,
|
|
73
|
+
workflowId,
|
|
74
|
+
error,
|
|
75
|
+
duration: Date.now() - startTime
|
|
76
|
+
},
|
|
77
|
+
'Activity failed'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
throw error;
|
|
81
|
+
} finally {
|
|
82
|
+
span.end();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
{
|
|
92
|
+
taskQueue: 'payment-queue',
|
|
93
|
+
namespace: process.env.TEMPORAL_NAMESPACE || 'loop',
|
|
94
|
+
temporalAddress: process.env.TEMPORAL_ADDRESS || 'localhost:7233'
|
|
95
|
+
},
|
|
96
|
+
'Starting Temporal worker'
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await worker.run();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
run().catch((err) => {
|
|
103
|
+
logger.error({ error: err }, 'Worker failed');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|