UNPKG

@unchainedshop/plugins

Version:

Official plugin collection for the Unchained Engine with payment, delivery, and pricing adapters

419 lines (418 loc) 17.9 kB
import { createLogger } from '@unchainedshop/logger'; import createDatatransAPI from "./api/index.js"; import parseRegistrationData from "./parseRegistrationData.js"; import roundedAmountFromOrder from "./roundedAmountFromOrder.js"; import { PaymentAdapter, PaymentDirector, PaymentError, PaymentPricingRowCategory, PaymentPricingSheet, OrderPricingSheet, } from '@unchainedshop/core'; const logger = createLogger('unchained:datatrans'); const { DATATRANS_SECRET, DATATRANS_SIGN_KEY, DATATRANS_API_ENDPOINT = 'https://api.sandbox.datatrans.com', DATATRANS_MERCHANT_ID, } = process.env; const newDatatransError = ({ code, message }) => { const error = new Error(message); error.name = `DATATRANS_${code}`; return error; }; const throwIfResponseError = (result) => { if (result.error) { const rawError = result.error; throw newDatatransError(rawError); } }; const getMarketplaceSplits = async ({ order, orderPayment, config, }) => { const pricingForOrderPayment = PaymentPricingSheet({ calculation: orderPayment.calculation, currencyCode: order.currencyCode, }); const pricing = OrderPricingSheet({ calculation: order.calculation, currencyCode: order.currencyCode, }); const { amount: total } = pricing.total({ useNetPrice: false }); const roundedTotal = Math.round(total); const splits = await Promise.all(config .filter((item) => item.key === 'marketplaceSplit') .map((item) => { const [subMerchantId, staticDiscountId, sharePercentage] = (item.value || '') .split(';') .map((f) => f.trim()); const { amount: discountSum } = pricingForOrderPayment.total({ category: PaymentPricingRowCategory.Discount, discountId: staticDiscountId, }); const shareFactor = sharePercentage ? parseInt(sharePercentage, 10) / 100 : 1; const amount = Math.round(total * shareFactor); const commission = Math.round(discountSum * -1 * shareFactor); return { subMerchantId, amount, commission, }; })); if (splits.length > 0) { const currentSum = splits.reduce((sum, split) => sum + split.amount, 0); const difference = roundedTotal - currentSum; if (difference !== 0) { splits[splits.length - 1].amount += difference; } } return splits; }; const Datatrans = { ...PaymentAdapter, key: 'shop.unchained.datatrans', label: 'Datatrans', version: '2.0.0', initialConfiguration: [ { key: 'merchantId', value: null, }, ], typeSupported(type) { return type === 'GENERIC'; }, actions: (config, context) => { const getMerchantId = () => { return config.find((item) => item.key === 'merchantId')?.value || DATATRANS_MERCHANT_ID; }; const api = () => { const merchantId = getMerchantId(); if (!DATATRANS_SECRET) throw new Error('Credential DATATRANS_SECRET not Set'); if (!merchantId) throw new Error('Credential Merchant ID not configured'); if (!DATATRANS_API_ENDPOINT) throw new Error('Credential DATATRANS_API_ENDPOINT not Set'); return createDatatransAPI(DATATRANS_API_ENDPOINT, merchantId, DATATRANS_SECRET); }; const shouldSettleInUnchained = () => { return config.reduce((current, item) => { if (item.key === 'settleInUnchained') return Boolean(item.value); return current; }, true); }; const authorize = async ({ paymentCredentials, ...arbitraryFields }) => { const { order, orderPayment } = context; if (!orderPayment) throw new Error('Order Payment missing in context'); if (!order) throw new Error('Order missing in context'); const refno = Buffer.from(orderPayment._id, 'hex').toString('base64'); const userId = order?.userId || context?.userId; const refno2 = userId; const { currencyCode, amount } = roundedAmountFromOrder(order); const splits = await getMarketplaceSplits({ orderPayment, order, config, }); const result = await api().authorize({ ...arbitraryFields, amount, currency: currencyCode, refno, refno2, autoSettle: false, customer: { id: userId, }, marketplace: splits.length ? { splits, } : undefined, [paymentCredentials.meta.objectKey]: JSON.parse(paymentCredentials.token), }); throwIfResponseError(result); return result.transactionId; }; const authorizeAuth = async ({ transactionId, refno, refno2, ...arbitraryFields }) => { const { order } = context; if (!order) throw new Error('Order missing in context'); const { currencyCode, amount } = roundedAmountFromOrder(order); const result = await api().authorizeAuthenticated({ ...arbitraryFields, transactionId, amount, currency: currencyCode, refno, refno2, autoSettle: false, }); throwIfResponseError(result); return result.acquirerAuthorizationCode; }; const isTransactionAmountValid = (transaction) => { const { order } = context; if (!order) throw new Error('Order missing in context'); const { currencyCode, amount } = roundedAmountFromOrder(order); if (transaction.currency !== currencyCode || transaction.detail.authorize?.amount !== amount) { logger.info(`currency: ${transaction.currency} === ${currencyCode} => ${transaction.currency === currencyCode}, amount: ${transaction.detail.authorize?.amount} === ${amount} => ${transaction.detail.authorize?.amount === amount}`); return false; } return true; }; const checkIfTransactionAmountValid = (transactionId, transaction) => { if (!isTransactionAmountValid(transaction)) { logger.error(`Transaction declined / Transaction ID ${transactionId} because of amount/currency mismatch`); throw newDatatransError({ code: `YOU_HAVE_TO_PAY_THE_FULL_AMOUNT_DUDE`, message: 'Amount / Currency Mismatch with Cart', }); } }; const settle = async ({ transactionId, refno, refno2, extensions }) => { const { order, orderPayment } = context; if (!orderPayment) throw new Error('Order Payment missing in context'); if (!order) throw new Error('Order missing in context'); const { currencyCode, amount } = roundedAmountFromOrder(order); const splits = await getMarketplaceSplits({ orderPayment, order, config, }); const result = await api().settle({ transactionId, amount, refno, refno2, currency: currencyCode, marketplace: splits.length ? { splits, } : undefined, extensions, }); throwIfResponseError(result); return result; }; const cancel = async ({ transactionId, refno }) => { const result = await api().cancel({ transactionId, refno, }); throwIfResponseError(result); return result; }; const adapterActions = { ...PaymentAdapter.actions(config, context), configurationError() { if (!getMerchantId() || !DATATRANS_SECRET || !DATATRANS_SIGN_KEY) { return PaymentError.INCOMPLETE_CONFIGURATION; } return null; }, isActive() { if (adapterActions.configurationError() === null) return true; return false; }, isPayLaterAllowed() { return false; }, async sign(transactionContext = {}) { const { useSecureFields = false, ...arbitraryFields } = transactionContext || {}; const { orderPayment, paymentProvider, order } = context; const refno = Buffer.from(orderPayment ? orderPayment._id : paymentProvider._id, 'hex').toString('base64'); const userId = order?.userId || context?.userId; const refno2 = userId; const price = order ? roundedAmountFromOrder(order) : {}; if (useSecureFields) { const result = await api().secureFields({ ...arbitraryFields, currency: price.currencyCode || 'CHF', refno, refno2, customer: { id: userId, }, amount: price.amount, }); throwIfResponseError(result); return JSON.stringify(result); } const result = await api().init({ ...arbitraryFields, currency: price.currencyCode || 'CHF', refno, refno2, customer: { id: userId, }, amount: price.amount, }); throwIfResponseError(result); return JSON.stringify(result); }, async validate(credentials) { if (!credentials.meta) return false; const { objectKey, currency } = credentials.meta; const result = await api().validate({ refno: Buffer.from(`valid-${new Date().getTime()}`, 'hex').toString('base64'), currency, [objectKey]: JSON.parse(credentials.token), }); return Boolean(result?.transactionId); }, async register(transactionResponse) { const { transactionId } = transactionResponse; const result = (await api().status({ transactionId, })); if (result.transactionId) { return parseRegistrationData(result); } return null; }, async confirm() { if (!shouldSettleInUnchained()) return false; const { orderPayment, transactionContext } = context; if (!orderPayment) throw new Error('Order Payment missing in context'); const { transactionId } = orderPayment; const { extensions } = transactionContext || {}; if (!transactionId) { return false; } const transaction = (await api().status({ transactionId, })); throwIfResponseError(transaction); const { status } = transaction; if (status === 'authorized') { await settle({ transactionId, refno: transaction.refno, refno2: transaction.refno2, extensions, }); } return true; }, async cancel() { if (!shouldSettleInUnchained()) return false; const { orderPayment } = context; if (!orderPayment) throw new Error('Order Payment missing in context'); const { transactionId } = orderPayment; if (!transactionId) { return false; } const transaction = (await api().status({ transactionId, })); throwIfResponseError(transaction); const { status } = transaction; if (status === 'authorized') { await cancel({ transactionId, refno: transaction.refno, }); } return true; }, async charge({ transactionId: rawTransactionId, paymentCredentials, authorizeAuthenticated, ...arbitraryFields }) { if (!rawTransactionId && !paymentCredentials) { logger.warn('Not trying to charge because of missing payment authorization response, return false to provide later'); return false; } const transactionId = rawTransactionId || (await authorize({ ...arbitraryFields, paymentCredentials, })); const transaction = (await api().status({ transactionId, })); throwIfResponseError(transaction); if (!transaction) { throw newDatatransError({ code: `TRANSACTION_NOT_FOUND`, message: 'Amount / Currency Mismatch with Cart', }); } let { status } = transaction; if (status === 'canceled' || status === 'failed') { logger.error(`Payment declined or canceled with Transaction ID ${transactionId} and status ${status}`); throw newDatatransError({ code: `STATUS_${status.toUpperCase()}`, message: 'Payment declined or canceled', }); } if (status === 'authenticated') { if (authorizeAuthenticated) { checkIfTransactionAmountValid(transactionId, transaction); await authorizeAuth({ ...(authorizeAuthenticated || {}), transactionId, refno: transaction.refno, refno2: transaction.refno2, }); status = 'authorized'; } else { logger.error(`Transaction declined / Transaction ID ${transactionId} not authorized yet`); throw newDatatransError({ code: `STATUS_${status.toUpperCase()}`, message: 'Payment authenticated but not authorized', }); } } if (status === 'authorized' || status === 'settled') { let settledTransaction = transaction; let credentials; try { checkIfTransactionAmountValid(transactionId, settledTransaction); credentials = await parseRegistrationData(settledTransaction); const result = await api().status({ transactionId, }); if (result?.error) { settledTransaction = transaction; } else { settledTransaction = result; } } catch { await cancel({ transactionId, refno: settledTransaction.refno }); logger.error(`Transaction declined / Transaction ID ${transactionId} authorization cancelled`); throw newDatatransError({ code: `STATUS_${status.toUpperCase()}`, message: 'Payment cancelled server-side because of amount missmatch', }); } return { transactionId, settledTransaction, arbitraryFields, credentials, }; } if (status === 'initialized' || status === 'challenge_required' || status === 'challenge_ongoing' || status === 'transmitted') { logger.error(`Transaction ID ${transactionId} in transit with status ${status}`); throw newDatatransError({ code: `STATUS_${status.toUpperCase()}`, message: 'Transaction status invalid for checkout', }); } return false; }, }; return adapterActions; }, }; PaymentDirector.registerAdapter(Datatrans);