@smartsoft001/trans-shell-app-services

The one service an application calls: it chooses the payment provider, decides who your back end is, and hands the work to the domain.


Install

npm install @smartsoft001/trans-shell-app-services @smartsoft001/trans-domain @smartsoft001/domain-core @smartsoft001/payu @smartsoft001/paypal @smartsoft001/paynow @smartsoft001/revolut

The manifest declares the six workspace packages above as peer dependencies, pinned to its own version. All four payment packages are imported unconditionally, as constructor parameter types, so all four have to be installed even in an application that enables only one provider. On top of them it needs @nestjs/common for @Injectable and @Optional, @nestjs/core for ModuleRef and @nestjs/axios for HttpService.

Two ways in

Almost every application gets this service by importing @smartsoft001/trans-shell-nestjs, which provides it and its collaborators. Constructing it by hand, as the example below does, is what a test or a non-Nest process would do, and it is the shortest way to see exactly what it depends on.

What it is

The domain services in @smartsoft001/trans-domain deliberately know nothing about who your payment provider is or where your back end lives. Both arrive as arguments to every call. Something has to supply them, and this package is that something.

Two decisions make up almost the whole service. The first is which provider handles a transaction: a private getter builds the map { payu, paypal, paynow, revolut } out of four @Optional() injections, and the domain indexes it with trans.system. A provider that was never registered is simply undefined in the map, and a transaction naming it fails when the domain reaches for it.

The second is who your back end is. The service tries to resolve a provider registered under TRANS_TOKEN_INTERNAL_SERVICE, non-strictly, so it can come from anywhere in the application. If that lookup throws, for any reason, a built-in HTTP implementation takes over: it posts a new transaction to TransConfig.internalApiUrl and puts a refreshed one to that url plus the id. When internalApiUrl is empty, that implementation short-circuits to a resolved promise and makes no request at all, which is what keeps a development setup, and the example below, entirely offline.

Usage

import { HttpService } from '@nestjs/axios';
import { ModuleRef } from '@nestjs/core';

import { IItemRepository } from '@smartsoft001/domain-core';
import { PayuService } from '@smartsoft001/payu';
import {
  CreatorService,
  RefresherService,
  RefundService,
  Trans,
  TransConfig,
} from '@smartsoft001/trans-domain';
import { TransService } from '@smartsoft001/trans-shell-app-services';

import { InMemoryTransRepository, OrderData } from './creator-service.example';

/**
 * `TransService` falls back to an HTTP internal service that posts every new
 * transaction to `TransConfig.internalApiUrl`. An empty url short-circuits
 * that fallback to a resolved promise, so nothing here ever reaches the
 * network. This stub records any call that would break that promise.
 */
export class OfflineHttpService {
  readonly calls: string[] = [];

  post(url: string): never {
    this.calls.push(`POST ${url}`);
    throw new Error('the example is offline');
  }

  put(url: string): never {
    this.calls.push(`PUT ${url}`);
    throw new Error('the example is offline');
  }
}

/**
 * Stand-in for `PayuService`. In an application this provider appears only
 * when you pass a `payuConfig` to `TransShellNestjsModule.forRoot`. The real
 * service also implements `getStatus` and `refund`, which the webhook and the
 * refund flow use; creating a transaction needs `create` alone.
 */
export class StubPayuService {
  readonly orders: string[] = [];

  async create(obj: {
    name: string;
  }): Promise<{ orderId: string; redirectUrl: string }> {
    this.orders.push(obj.name);

    return { orderId: 'ORD-2', redirectUrl: 'https://pay.example/ORD-2' };
  }
}

/**
 * `TransService` is the application-level entry point: it picks the payment
 * provider by `system`, resolves the internal service and delegates to the
 * three domain services. Nest normally injects all of this. Building it by
 * hand shows exactly what it depends on.
 *
 * `moduleRef.get` is how `TransService` looks up a custom internal service
 * registered under `TRANS_TOKEN_INTERNAL_SERVICE`. A throwing stub reproduces
 * the common case where no such provider exists, and the built-in fallback
 * takes over.
 *
 * The three payment services left `undefined` are `@Optional()` injections.
 * Only the one named by `system` is ever called.
 */
export function createTransService(
  repository: InMemoryTransRepository,
  httpService: OfflineHttpService,
  payuService: StubPayuService,
): TransService {
  const itemRepository = repository as unknown as IItemRepository<
    Trans<OrderData>
  >;

  const config = new TransConfig('', {
    secretOrPrivateKey: 'change-me',
    expiredIn: 3600,
  });

  const moduleRef = {
    get: () => {
      throw new Error('no internal service is registered');
    },
  } as unknown as ModuleRef;

  return new TransService(
    moduleRef,
    new CreatorService(itemRepository),
    new RefresherService(itemRepository),
    new RefundService(itemRepository),
    httpService as unknown as HttpService,
    config,
    itemRepository,
    payuService as unknown as PayuService,
    undefined as never,
    undefined as never,
    undefined as never,
  );
}

The region constructs the service by hand and, in doing so, names every collaborator. The repository is the array-backed fake from the creator example. The three domain services are real, built over that same repository. The TransConfig carries an empty internalApiUrl, which is what turns the internal calls off. The ModuleRef stub throws from get, reproducing the ordinary case where no custom internal service is registered. OfflineHttpService throws from post and put and records the call, so an unexpected request would fail loudly instead of silently reaching the network. StubPayuService stands in for the one provider this example enables; the other three are left undefined, exactly as the @Optional() injections would be.

Its spec runs one create and checks five things. The call returns ORD-2, the order id the stub issued, so the request reached the provider through the map. The stub recorded the order name, so the request was routed by system rather than by position. The HTTP stub recorded nothing, which is the empty internalApiUrl short-circuit working. The stored transaction ends in started. And reading it back through getById returns the record carrying ORD-2 as its externalId.

API

Constructor

new TransService(moduleRef, creatorService, refresherService, refundService, httpService, config, repository, payuService?, paynowService?, paypalService?, revolutService?). In an application every argument comes from the Nest injector.

PositionParameterTypeNotes
1moduleRefModuleRefUsed once, to look for a custom internal service.
2creatorServiceCreatorService<any>Backs create.
3refresherServiceRefresherService<any>Backs refresh.
4refundServiceRefundService<any>Backs refund.
5httpServiceHttpServiceOnly used by the built-in internal service, and only with a non-empty url.
6configTransConfigRead for internalApiUrl.
7repositoryIItemRepository<Trans<any>>Used directly by getById.
8payuServicePayuService, @Optional()Present only when the module was given a payuConfig.
9paynowServicePaynowService, @Optional()Note the order: Paynow comes before Paypal.
10paypalServicePaypalService, @Optional()
11revolutServiceRevolutService, @Optional()

The four optional positions are easy to get wrong when constructing the service by hand, because the order is payu, paynow, paypal, revolut, while the map the domain sees is keyed by name and unaffected by it.

Methods

MethodReturnsWhat it does
create<T>(ops: ITransCreate<T>)Promise<{ orderId: string; redirectUrl?: string; responseData?: any }>Delegates to CreatorService.create with the resolved internal service and the provider map.
refresh(transId, data = {})Promise<void>Delegates to RefresherService.refresh. transId is the provider's order id, and data is stored on the history entry as customData.
refund(transId, comment = 'Refund')Promise<void>Delegates to RefundService.refund. Here transId is the local id.
getById(id)Promise<Trans<any>>Reads straight from the repository, with no permission check of any kind.

None of the four validates anything itself. Every rule, including the six validation messages and the completed-only refund, belongs to the domain and is documented with it.

The internal service

TRANS_TOKEN_INTERNAL_SERVICE is a string constant exported by the package. Register a provider under it, anywhere in the application, and every call made by this service uses your implementation of ITransInternalService instead of the built-in one. The lookup is moduleRef.get(TRANS_TOKEN_INTERNAL_SERVICE, { strict: false }) inside a try, so a missing token falls back silently, and so does a provider that exists but throws while being resolved.

The built-in implementation behaves as follows.

CallWith an empty internalApiUrlWith a url
create(trans)Resolves { date, req: trans }, no request.POST to the url with the transaction as the body, resolving res.data.
refresh(trans)Resolves { date, req: trans, id: trans.id }, no request.PUT to the url plus / and the id, resolving res.data.

The offline answers matter beyond being empty. The domain overwrites amount when the internal answer carries one, and neither of these does, so the amount stays as requested. And the refresh path only persists a status change when the internal answer is truthy, which both of these are.

SERVICES

SERVICES is [TransService], the provider array a Nest module spreads into its providers and exports. Both module variants in @smartsoft001/trans-shell-nestjs do exactly that, so an application normally registers the service by importing a module rather than by naming the class.