import crypto from 'crypto'; import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; import { env } from '../config/env'; import paymentRepository from '../repositories/paymentRepository'; import subscriptionRepository from '../repositories/subscriptionRepository'; import licenseService from './licenseService'; import { logger } from '../utils/logger'; import { BadRequestError, InternalError } from '../utils/helpers'; import { PLANS } from '../utils/constants'; import { LicensePlan } from '@prisma/client'; interface DokuCheckoutRequest { order: { amount: number; invoice_number: string; currency?: string; callback_url?: string; callback_url_cancel?: string; language?: string; auto_redirect?: boolean; disable_retry_payment?: boolean; line_items?: Array<{ name: string; quantity: number; price: number; }>; }; payment: { payment_due_date?: number; }; customer?: { id?: string; name?: string; last_name?: string; email?: string; phone?: string; }; } interface DokuCheckoutResponse { message: string[]; response: { order: { amount: number; invoice_number: string; session_id: string; }; payment: { token_id: string; url: string; expired_date: string; payment_due_date: number; }; }; } export class DokuService { private clientId: string; private secretKey: string; private apiUrl: string; constructor() { this.clientId = env.DOKU_CLIENT_ID; this.secretKey = env.DOKU_SECRET_KEY; this.apiUrl = env.DOKU_API_URL; } /** * Generate HMAC-SHA256 signature for DOKU API * Signature = HMAC-SHA256(clientId + ":" + requestId + ":" + requestTimestamp + ":" + requestTarget + ":" + digest, secretKey) */ private generateSignature( requestId: string, requestTimestamp: string, requestTarget: string, body: string ): string { // Generate digest (SHA-256 of body, base64 encoded) const digest = crypto .createHash('sha256') .update(body) .digest('base64'); // Component signature const componentSignature = `Client-Id:${this.clientId}\nRequest-Id:${requestId}\nRequest-Timestamp:${requestTimestamp}\nRequest-Target:${requestTarget}\nDigest:${digest}`; // Generate HMAC-SHA256 const signature = crypto .createHmac('sha256', this.secretKey) .update(componentSignature) .digest('base64'); return `HMACSHA256=${signature}`; } /** * Create a checkout payment via DOKU */ async createCheckout( userId: string, plan: LicensePlan, customerEmail: string, customerName: string ) { if (plan === 'FREE') { throw new BadRequestError('Cannot purchase free plan'); } const planConfig = PLANS[plan as keyof typeof PLANS]; const invoiceNumber = `MS-${Date.now()}-${uuidv4().substring(0, 6).toUpperCase()}`; const requestId = uuidv4(); const requestTimestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const requestTarget = '/checkout/v1/payment'; const requestBody: DokuCheckoutRequest = { order: { amount: planConfig.price, invoice_number: invoiceNumber, currency: 'IDR', callback_url: `${env.FRONTEND_URL}/payment/success`, callback_url_cancel: `${env.FRONTEND_URL}/payment/cancel`, auto_redirect: true, line_items: [ { name: `MarketScope ${planConfig.name}`, quantity: 1, price: planConfig.price, }, ], }, payment: { payment_due_date: 60, // 60 minutes }, customer: { id: userId, name: customerName, email: customerEmail, }, }; const bodyString = JSON.stringify(requestBody); const signature = this.generateSignature(requestId, requestTimestamp, requestTarget, bodyString); try { const response = await axios.post( `${this.apiUrl}${requestTarget}`, requestBody, { headers: { 'Client-Id': this.clientId, 'Request-Id': requestId, 'Request-Timestamp': requestTimestamp, 'Signature': signature, 'Content-Type': 'application/json', }, } ); // Create subscription record const subscription = await subscriptionRepository.create({ plan, dokuInvoiceNo: invoiceNumber, user: { connect: { id: userId } }, }); // Create payment record const payment = await paymentRepository.create({ invoiceNumber, amount: planConfig.price, dokuPaymentUrl: response.data.response.payment.url, dokuTokenId: response.data.response.payment.token_id, rawResponse: response.data as any, user: { connect: { id: userId } }, subscription: { connect: { id: subscription.id } }, }); return { paymentUrl: response.data.response.payment.url, tokenId: response.data.response.payment.token_id, invoiceNumber, paymentId: payment.id, subscriptionId: subscription.id, expiredDate: response.data.response.payment.expired_date, }; } catch (error: any) { logger.error('DOKU Checkout error:', error.response?.data || error.message); throw new InternalError('Payment gateway error. Please try again later.'); } } /** * Handle DOKU notification webhook */ async handleNotification(notificationData: any) { const { order, transaction, } = notificationData; const invoiceNumber = order?.invoice_number; if (!invoiceNumber) { throw new BadRequestError('Invalid notification: missing invoice number'); } const payment = await paymentRepository.findByInvoiceNumber(invoiceNumber); if (!payment) { logger.warn(`Payment not found for invoice: ${invoiceNumber}`); throw new BadRequestError('Payment not found'); } const status = transaction?.status; if (status === 'SUCCESS') { // Update payment await paymentRepository.updateByInvoice(invoiceNumber, { status: 'PAID', paymentMethod: transaction.payment_method || 'DOKU', paidAt: new Date(), rawResponse: notificationData, }); // Update subscription if (payment.subscriptionId) { const subscription = await subscriptionRepository.findById(payment.subscriptionId); if (subscription) { const startDate = new Date(); const endDate = new Date(); if (subscription.plan === 'PRO_MONTHLY') { endDate.setDate(endDate.getDate() + 30); } else if (subscription.plan === 'PRO_YEARLY') { endDate.setDate(endDate.getDate() + 365); } await subscriptionRepository.update(subscription.id, { status: 'ACTIVE', startDate, endDate, }); // Generate/upgrade license await licenseService.generateLicense( subscription.userId, subscription.plan, subscription.plan === 'PRO_MONTHLY' ? 30 : 365 ); } } logger.info(`Payment successful for invoice: ${invoiceNumber}`); } else if (status === 'FAILED' || status === 'EXPIRED') { await paymentRepository.updateByInvoice(invoiceNumber, { status: status === 'FAILED' ? 'FAILED' : 'EXPIRED', rawResponse: notificationData, }); if (payment.subscriptionId) { await subscriptionRepository.update(payment.subscriptionId, { status: 'CANCELLED', }); } logger.info(`Payment ${status.toLowerCase()} for invoice: ${invoiceNumber}`); } return { message: 'Notification processed' }; } /** * Check payment status */ async checkPaymentStatus(invoiceNumber: string) { const payment = await paymentRepository.findByInvoiceNumber(invoiceNumber); if (!payment) { throw new BadRequestError('Payment not found'); } return { invoiceNumber: payment.invoiceNumber, status: payment.status, amount: payment.amount, paymentMethod: payment.paymentMethod, paidAt: payment.paidAt, }; } } export default new DokuService();