payments/src/lib/actions/create.ts

import {
  Payment,
  PaymentParams,
  PaymentStatus,
  PaymentProcessingState,
  SUPPORTED_CURRENCIES,
} from '../types';
import { convertToPrisma, convertFromPrisma } from '../converters';
import { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
import { publishPaymentAdded } from './publish';
import { v4 as uuid, validate as uuidValidate } from 'uuid';
import { tracer } from '@capchase/tracer';
import { callLog } from '../call_log';
import { incrementMetric } from '@capchase/metrics';
import { sendNotification } from '@capchase/notifications';
import { formatMoney } from '@capchase/money';
import { Currency } from 'dinero.js';
import { format } from 'date-fns';
import { getJarvisUrl } from '../utils';
import { PAYMENT_SYSTEM_EVENTS_CHANNEL } from '../consts';

const internalHandlePostCreateNotification = async (payment: Payment) => {
  if (
    payment.status === PaymentStatus.NEEDS_APPROVAL &&
    payment.processingState === PaymentProcessingState.UNPROCESSED
  ) {
    const formattedMoney = formatMoney(
      payment.amount,
      payment.currency as Currency
    );
    const formattedDate = format(payment.effectiveDate, 'yyyy-MM-dd');
    const jarvisPath = `#/payments?paymentId=${payment.key}`;

    const text = `🤑 New payment ${
      payment.direction
    } ${formattedMoney} at ${formattedDate} needs approval 🔗 ${getJarvisUrl(
      jarvisPath
    )}`;

    await sendNotification(text, PAYMENT_SYSTEM_EVENTS_CHANNEL);
  }
};

/**
 * Send a notification once a payment has been created.
 *
 * @async
 * @function
 * @memberOf module:payments/actions
 * @access private
 */
const handlePostCreateNotification =
  tracer?.wrap(
    'payments.handlePostCreateNotification',
    internalHandlePostCreateNotification
  ) || internalHandlePostCreateNotification;

const internalCreatePayment = async (
  payment: PaymentParams,
  actionBy: string
): Promise<Payment> => {
  await callLog({
    callName: 'createPayment',
    actionBy,
    args: [payment, actionBy],
  });

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

  span?.addTags({
    params: payment,
    action_by: actionBy,
  });

  if (!payment.companyId && !payment.receivingAccount) {
    throw new Error('No counterparty information given');
  }

  if (payment.key && !uuidValidate(payment.key)) {
    throw new Error(`Payment key ${payment.key} is not a valid UUID`);
  }

  if (payment.amount < 0) {
    throw new Error('Amount is negative');
  }

  if (!SUPPORTED_CURRENCIES.includes(payment.currency)) {
    throw new Error(`Currency ${payment.currency} is not supported`);
  }

  if (payment.effectiveDate && payment.effectiveDate < new Date()) {
    throw new Error('Effective date is in the past');
  }

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

  // The contract name should only contain `a-zA-Z0-9_\-` characters and max of 24
  if (
    payment.contractName.length === 0 ||
    !payment.contractName.match('^[a-zA-Z0-9_-]{1,24}$')
  ) {
    throw new Error('Contract name is not valid');
  }

  const key = payment.key || uuid();

  // Check if there already exists a payment with the same
  // key, so that we avoid duplicates
  const count = await prisma.payment.count({ where: { key } });

  if (count > 0) {
    throw new Error(`Payment with key ${key} already exists in the system`);
  }

  return create({
    ...payment,
    key,
    actionBy,
  } as Payment);
};

/**
 * Creates a new payment with status `NEEDS_APPROVAL`.
 *
 * @param {PaymentParams} params Payment params
 * @param {string} actionBy Action executor
 * @return {Promise<Payment>} The created payment
 *
 * @async
 * @function
 * @memberOf module:payments/actions
 * @access public
 */
const createPayment =
  tracer?.wrap('payments.createPayment', internalCreatePayment) ||
  internalCreatePayment;

const internalCreate = async (payment: Payment): Promise<Payment> => {
  let processingKey = uuid();

  // If the payment has not yet been processed, keep the
  // same processing key
  if (
    payment.processingKey &&
    payment.processingState !== PaymentProcessingState.UNPROCESSED
  ) {
    processingKey = payment.processingKey;
  }

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

  span?.addTags({
    processing_key: processingKey,
  });

  const data = convertToPrisma({
    ...payment,
    id: undefined,
    createdAt: undefined,
    key: payment.key || uuid(),
    effectiveDate: payment.effectiveDate || new Date(),
    status: payment.status || PaymentStatus.NEEDS_APPROVAL,
    processingState:
      payment.processingState || PaymentProcessingState.UNPROCESSED,
    processingKey,
  });

  const createdPayment = convertFromPrisma(
    await prisma.payment.create({
      data: {
        ...data,
        companyId: undefined,
        company: payment.companyId
          ? {
              connect: {
                id: payment.companyId,
              },
            }
          : undefined,
      } as Prisma.PaymentCreateInput,
    })
  );

  // Broadcast that the payment has been added to the DB
  // for those entity listeners that may need to handle it
  await publishPaymentAdded(createdPayment, createdPayment.actionBy);

  incrementMetric('payment_system.payment.create', {
    status: createdPayment.status,
    processing_state: createdPayment.processingState,
    contract_name: createdPayment.contractName,
    direction: createdPayment.direction,
    currency: createdPayment.currency,
    action_by: createdPayment.actionBy,
  });

  // Do not await this call
  handlePostCreateNotification(createdPayment);

  return createdPayment;
};

/**
 * Internal function that creates a payment, basically a wrapper over
 * the Prisma interface to append a new payment to the PS's table.
 *
 * @async
 * @function
 * @memberOf module:payments/actions
 * @access private
 */
const create =
  tracer?.wrap('payments.create', internalCreate) || internalCreate;

export { createPayment, create };