UNPKG

@unchainedshop/plugins

Version:

Because of a Typescript issue with upstream "postfinancecheckout", the Postfinance plugin has been disabled from transpilation, import the source ts files from src and enable node_module tsc or copy over the src/payment/postfinance-checkout to your projec

491 lines (443 loc) 15.4 kB
import { createLogger } from '@unchainedshop/logger'; import createDatatransAPI from './api/index.js'; import { AuthorizeAuthenticatedResponseSuccess, AuthorizeResponseSuccess, InitResponseSuccess, ResponseError, StatusResponseSuccess, ValidateResponseSuccess, } from './api/types.js'; import parseRegistrationData from './parseRegistrationData.js'; import roundedAmountFromOrder from './roundedAmountFromOrder.js'; import { IPaymentAdapter, PaymentAdapter, PaymentDirector, PaymentError, PaymentPricingRowCategory, PaymentPricingSheet, OrderPricingSheet, } from '@unchainedshop/core'; const logger = createLogger('unchained:core-payment:datatrans'); // v2 const { DATATRANS_SECRET, DATATRANS_SIGN_KEY, DATATRANS_API_ENDPOINT = 'https://api.sandbox.datatrans.com', DATATRANS_MERCHANT_ID, } = process.env; const newDatatransError = ({ code, message }: { code: string; message: string }) => { const error = new Error(message); error.name = `DATATRANS_${code}`; return error; }; const throwIfResponseError = (result) => { if ((result as ResponseError).error) { const rawError = (result as ResponseError).error; throw newDatatransError(rawError); } }; const Datatrans: IPaymentAdapter = { ...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 = (): string | undefined => { return config.find((item) => item.key === 'merchantId')?.value || DATATRANS_MERCHANT_ID; }; const api = () => { if (!DATATRANS_SECRET) throw new Error('Credentials not Set'); return createDatatransAPI(DATATRANS_API_ENDPOINT, getMerchantId(), DATATRANS_SECRET); }; const shouldSettleInUnchained = () => { return config.reduce((current, item) => { if (item.key === 'settleInUnchained') return Boolean(item.value); return current; }, true); }; const getMarketplaceSplits = async (): Promise< { subMerchantId: string; amount: number; commission: number; }[] > => { const { order, orderPayment } = context; const pricingForOrderPayment = PaymentPricingSheet({ calculation: orderPayment.calculation, currency: order.currency, }); const pricing = OrderPricingSheet({ calculation: order.calculation, currency: order.currency, }); const { amount: total } = pricing.total({ useNetPrice: false }); return 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, }; }), ); }; const authorize = async ({ paymentCredentials, ...arbitraryFields }): Promise<string> => { const { order, orderPayment } = context; const refno = Buffer.from(orderPayment._id, 'hex').toString('base64'); const userId = order?.userId || context?.userId; const refno2 = userId; const { currency, amount } = roundedAmountFromOrder(order); const splits = await getMarketplaceSplits(); const result = await api().authorize({ ...arbitraryFields, amount, currency, refno, refno2, autoSettle: false, customer: { id: userId, }, marketplace: splits.length ? { splits, } : undefined, [paymentCredentials.meta.objectKey]: JSON.parse(paymentCredentials.token), }); throwIfResponseError(result); return (result as AuthorizeResponseSuccess).transactionId; }; const authorizeAuth = async ({ transactionId, refno, refno2, ...arbitraryFields }): Promise<string> => { const { order } = context; const { currency, amount } = roundedAmountFromOrder(order); const result = await api().authorizeAuthenticated({ ...arbitraryFields, transactionId, amount, currency, refno, refno2, autoSettle: false, }); throwIfResponseError(result); return (result as AuthorizeAuthenticatedResponseSuccess).acquirerAuthorizationCode; }; const isTransactionAmountValid = (transaction: StatusResponseSuccess): boolean => { const { order } = context; const { currency, amount } = roundedAmountFromOrder(order); if ( transaction.currency !== currency || (transaction.detail.authorize as any)?.amount !== amount ) { logger.info( `currency: ${transaction.currency} === ${currency} => ${ transaction.currency === currency }, amount: ${(transaction.detail.authorize as any)?.amount} === ${amount} => ${ (transaction.detail.authorize as any)?.amount === amount }`, ); return false; } return true; }; const checkIfTransactionAmountValid = ( transactionId: string, transaction: StatusResponseSuccess, ): void => { 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 }): Promise<boolean> => { const { order } = context; const { currency, amount } = roundedAmountFromOrder(order); const splits = await getMarketplaceSplits(); const result = await api().settle({ transactionId, amount, refno, refno2, currency, marketplace: splits.length ? { splits, } : undefined, extensions, }); throwIfResponseError(result); return result as boolean; }; const cancel = async ({ transactionId, refno }): Promise<boolean> => { const result = await api().cancel({ transactionId, refno, }); throwIfResponseError(result); return result as boolean; }; 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: any = {}) { const { useSecureFields = false, ...arbitraryFields } = transactionContext || {}; const { orderPayment, paymentProviderId, order } = context; const refno = Buffer.from(orderPayment ? orderPayment._id : paymentProviderId, 'hex').toString( 'base64', ); const userId = order?.userId || context?.userId; const refno2 = userId; const price: { amount?: number; currency?: string } = order ? roundedAmountFromOrder(order) : {}; if (useSecureFields) { const result = await api().secureFields({ ...arbitraryFields, currency: price.currency || 'CHF', refno, refno2, customer: { id: userId, }, amount: price.amount, }); throwIfResponseError(result); return JSON.stringify(result as InitResponseSuccess); } const result = await api().init({ ...arbitraryFields, currency: price.currency || 'CHF', refno, refno2, customer: { id: userId, }, amount: price.amount, }); throwIfResponseError(result); return JSON.stringify(result as InitResponseSuccess); }, 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 as ValidateResponseSuccess)?.transactionId); }, async register(transactionResponse) { const { transactionId } = transactionResponse; const result = (await api().status({ transactionId, })) as StatusResponseSuccess; if (result.transactionId) { return parseRegistrationData(result); } return null; }, async confirm() { if (!shouldSettleInUnchained()) return false; const { orderPayment, transactionContext } = context; const { transactionId } = orderPayment; const { extensions } = transactionContext || {}; if (!transactionId) { return false; } const transaction: StatusResponseSuccess = (await api().status({ transactionId, })) as StatusResponseSuccess; throwIfResponseError(transaction); const { status } = transaction; if (status === 'authorized') { // either settle or cancel // if further deferred settlement is active, don't settle in unchained and hand off // settlement to other systems await settle({ transactionId, refno: transaction.refno, refno2: transaction.refno2, extensions, }); } return true; }, async cancel() { if (!shouldSettleInUnchained()) return false; const { orderPayment } = context; const { transactionId } = orderPayment; if (!transactionId) { return false; } const transaction: StatusResponseSuccess = (await api().status({ transactionId, })) as StatusResponseSuccess; throwIfResponseError(transaction); const { status } = transaction; if (status === 'authorized') { // either settle or cancel // if further deferred settlement is active, don't settle in unchained and hand off // settlement to other systems 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: StatusResponseSuccess = (await api().status({ transactionId, })) as StatusResponseSuccess; 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; // here we try to guard us against a potential // network hicup or registration data parsing error // at the dumbest possible moment try { checkIfTransactionAmountValid(transactionId, settledTransaction); credentials = await parseRegistrationData(settledTransaction); const result = await api().status({ transactionId, }); if ((result as ResponseError)?.error) { settledTransaction = transaction; } else { settledTransaction = result as StatusResponseSuccess; } } 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);