payments/src/lib/actions/approve.ts

import {
  Payment,
  PaymentStatus,
  PaymentProcessingState,
  PaymentBatchApprove,
} from '../types';
import { tracer } from '@capchase/tracer';
import { create } from './create';
import { requestPayment } from './request';
import { getLastPayment } from './getters';
import { callLog } from '../call_log';
import { PaymentRequestResponse } from '@capchase/messaging-system';

const acceptedStatuses = [
  PaymentStatus.NEEDS_APPROVAL,
  PaymentStatus.REQUEST_FAILED,
];

const internalBatchApprovePayments = async (
  paymentKeys: string[],
  actionBy: string
): Promise<PaymentBatchApprove> => {
  await callLog({
    callName: 'approvePayment',
    actionBy,
    args: [paymentKeys, actionBy],
  });

  const span = tracer?.scope()?.active();

  span?.addTags({
    payment_keys: paymentKeys,
    action_by: actionBy,
  });

  const response: PaymentBatchApprove = {
    approved: [],
    errors: [],
  };

  for (const key of paymentKeys) {
    try {
      const approvedPayment = await internalApprovePayment(key, actionBy);

      response.approved.push(approvedPayment);
    } catch (err) {
      response.errors.push({
        key,
        error: err.toString(),
      });
    }
  }

  return response;
};

/**
 * Given a list of payment keys it tries to approve them in a sync way.
 * If one fails it logs the
 *
 * @param {string[]} paymentKeys Payment keys
 * @param {string} actionBy Action executor
 * @return {Promise<PaymentBatchApprove>} Batch approval response
 *
 * @async
 * @function
 * @memberOf module:payments/actions
 * @access public
 */
const batchApprovePayments =
  tracer?.wrap('payments.batchApprovePayments', internalBatchApprovePayments) ||
  internalBatchApprovePayments;

const internalApprovePayment = async (
  paymentKey: string,
  actionBy: string
): Promise<Payment> => {
  await callLog({
    callName: 'approvePayment',
    actionBy,
    args: [paymentKey, actionBy],
  });

  const span = tracer?.scope()?.active();

  span?.addTags({
    payment_key: paymentKey,
    action_by: actionBy,
  });

  // TODO(SHI-1454): Validate actual user permissions
  if (actionBy.length === 0) {
    throw new Error('Action by should be set');
  }

  const payment = await getLastPayment(paymentKey, actionBy);

  // Compare with a reduce as the the `acceptedStatuses` is an array of enum types
  // we can not use the `include` method of the array
  if (
    !acceptedStatuses.reduce(
      (flag, status) => (status === payment.status ? true : flag),
      false
    )
  ) {
    throw new Error(
      `Last payment for key ${paymentKey} is not ${acceptedStatuses} but ${payment.status}`
    );
  }

  const approvedPayment = await create({
    ...payment,
    actionBy: actionBy,
    status: PaymentStatus.APPROVED,
    processingState: PaymentProcessingState.UNPROCESSED,
  });

  let response: PaymentRequestResponse;

  try {
    response = await requestPayment(approvedPayment, actionBy);
  } catch (err) {
    // In case it fails, we create a new payment with request failed state
    await create({
      ...payment,
      actionBy: actionBy,
      status: PaymentStatus.REQUEST_FAILED,
      processingState: PaymentProcessingState.UNPROCESSED,
      metadata: {
        ...(payment.metadata || {}),
        errors: [
          ...(payment.metadata?.errors || []),
          {
            ts: new Date().toISOString(),
            error: `${err}`,
          },
        ],
      },
    });

    // Still we propagate and throw the wrapped error
    throw new Error(`Payment request failed: ${err}`);
  }

  span?.addTags({
    request_success: response.ok,
  });

  if (!response.ok) {
    span?.addTags({
      error: response.error,
    });

    await create({
      ...payment,
      actionBy: actionBy,
      status: PaymentStatus.REQUEST_FAILED,
      processingState: PaymentProcessingState.UNPROCESSED,
      metadata: {
        ...(payment.metadata || {}),
        errors: [
          ...(payment.metadata?.errors || []),
          {
            ts: new Date(),
            error: response.error,
          },
        ],
      },
    });

    throw new Error(
      `Payment request failed with error: ${response.error || 'unknown'}`
    );
  }

  return create({
    ...payment,
    actionBy: actionBy,
    status: PaymentStatus.REQUESTED,
    processingState: PaymentProcessingState.UNPROCESSED,
    // Propagate the external service data
    externalKey: response?.data?.key,
    externalStatus: response?.data?.status,
    externalService: response?.data?.service,
    eventData: {
      id: '',
      url: response?.data?.external_url || '',
      metadata: {},
      createdAt: new Date().toISOString(),
    },
  });
};

/**
 * Given a `paymentKey` it does the following:
 *
 *  1. Gets the last payment for the key and checks tha its pending
 *  2. Add a new row with `approved` state with the same info
 *  3. Request payment to external payment system/s
 *  4. Add a new roww with `requested` state
 *
 * If any of the steps fails it throws an error.
 *
 * @param {string} paymentKey Payment key
 * @param {string} actionBy Action executor
 * @return {Promise<Payment>} The approved payment
 *
 * @async
 * @function
 * @memberOf module:payments/actions
 * @access public
 */
const approvePayment =
  tracer?.wrap('payments.approvePayment', internalApprovePayment) ||
  internalApprovePayment;

export { approvePayment, batchApprovePayments };