@smartsoft001/payu

The PayU end of a transaction: three calls against the REST API v2_1, each one preceded by its own OAuth token request.


Install

npm install @smartsoft001/payu @smartsoft001/trans-domain @nestjs/axios

The manifest declares @smartsoft001/trans-domain as a peer dependency, pinned to its own version, and nothing else. Every request goes through the HttpService of @nestjs/axios, @nestjs/common provides @Injectable and Logger, and @nestjs/core provides ModuleRef. The imports from @smartsoft001/trans-domain are used only as types, so they are a build-time requirement rather than a runtime one.

What it is

One of the four payment providers behind @smartsoft001/trans-shell-app-services. The service implements ITransPaymentSingleService, the contract the transaction domain calls whenever a transaction names payu as its system, and it implements it in full: create, getStatus and refund, with no extra methods.

The package ships no NestJS module and no forRoot. An application normally gets the service from TransShellNestjsModule.forRoot({ payuConfig }), which registers PayuConfig as a value provider and PayuService as a class provider, and only when that key is present. Register the two providers by hand, as the example does, to use the PayU calls without the rest of the transaction shell.

Authentication is per call rather than per process. Each of the three methods first posts the client credentials to the OAuth endpoint and uses the access token it gets back exactly once. Nothing is cached, so a config provider can change merchants between two transactions without any state to invalidate.

Usage

import { HttpModule } from '@nestjs/axios';
import { Module, Provider } from '@nestjs/common';

import { PayuConfig, PayuService } from '@smartsoft001/payu';

/**
 * `PayuService` implements `ITransPaymentSingleService` from
 * `@smartsoft001/trans-domain`, so an application normally gets it from
 * `TransShellNestjsModule.forRoot({ payuConfig })`, which registers the
 * service only when that key is present. Register the two providers by hand
 * when you want the PayU calls without the rest of the transaction shell.
 *
 * `PayuConfig` is a plain class used as its own injection token. Its required
 * fields have no initialisers, so pass an object literal to `useValue` instead
 * of calling `new PayuConfig()`.
 */
export const payuProviders: Provider[] = [
  PayuService,
  {
    provide: PayuConfig,
    useValue: {
      clientId: 'payu-client-id',
      clientSecret: 'payu-client-secret',
      posId: 'payu-pos-id',
      notifyUrl: 'https://api.example.com/trans/payu/notify',
      continueUrl: 'https://app.example.com/orders/thank-you',
      // `test: true` sends every request to https://secure.snd.payu.com,
      // `false` or absent sends it to https://secure.payu.com.
      test: true,
    } satisfies PayuConfig,
  },
];

/**
 * `HttpModule` supplies the `HttpService` the service injects. Its third
 * dependency, `ModuleRef`, comes from Nest itself: `PayuService` uses it to
 * look up an optional `IPayuConfigProvider` under the `PAYU_CONFIG_PROVIDER`
 * token and falls back to the config above when there is none.
 */
@Module({
  imports: [HttpModule],
  providers: payuProviders,
  exports: [PayuService],
})
export class PayuPaymentsModule {}

The region registers the two providers the service needs and wraps them in a module that imports HttpModule. PayuConfig declares its required fields without initialisers, so the config is supplied as an object literal to useValue rather than through new PayuConfig(), with satisfies keeping the literal checked against the class.

Its spec compiles that module with a stubbed HttpService that records a call and then throws. PayuService resolves, so the three constructor dependencies are satisfiable from these providers alone. The PayuConfig read back out of the injector equals the literal the example wrote, including test: true. And the stub recorded nothing after the service had been resolved, which proves the sandbox credentials never left the process: no OAuth token is fetched until a method is called.

API

PayuConfig

A plain class with no decorators, used as both the injection token and the type.

FieldTypeWhat it does
clientIdstringThe OAuth client id, sent in the grant_type=client_credentials body.
clientSecretstringThe OAuth client secret, sent in the same body.
posIdstringSent as merchantPosId on every created order.
notifyUrlstringWhere PayU posts its notification. Point it at the POST /payu webhook of the transaction shell.
continueUrlstringWhere PayU sends the buyer after payment.
testbooleantrue sends every request to https://secure.snd.payu.com, false or absent to https://secure.payu.com.

PayuService

MethodReturnsWhat it does
create(obj)Promise<{ orderId: string; redirectUrl: string }>Creates an order with maxRedirects: 0 and reads the redirect out of the answer, whether it arrives as a 302 or as a 2xx.
getStatus<T>(trans)Promise<{ status: TransStatus; data: any }>Reads the order by the id stored on the started history entry, maps its status, and returns null when the answer carries no orders.
refund(trans, comment)Promise<any>Posts a refund for the whole order with comment as its description, and resolves the PayU response body.

create takes { id, name, amount, firstName?, lastName?, email?, contactPhone?, clientIp, data, options? }, which is the shared ITransPaymentSingleService shape with options made optional. Three things about the order it builds are worth knowing. The currency is hard-coded to PLN, so amount is read as grosze and sent unchanged as totalAmount. A buyer block is added only when at least one of the email, phone, first name and last name is present. And options.payMethod, when present, becomes payMethods.payMethod, which is how a single payment method is preselected for the buyer.

Both answers carry the same two fields

The order request is sent with maxRedirects: 0, and PayU answers a successful creation with a 302 to the payment page. Axios treats that as an error, so the usual path runs in the catch branch and reads e.response.data.redirectUri and e.response.data.orderId. PayU answers with a 2xx instead when the order preselects a payment method, and the try branch reads redirectUri and orderId out of that body, so both answers resolve to the same pair of fields.

Choosing the credentials

PathHow it is registeredWhen it wins
Static configA PayuConfig value provider, as in the example.Whenever no config provider resolves.
Per transactionA class implementing IPayuConfigProvider under PAYU_CONFIG_PROVIDER.Whenever the lookup succeeds. Its get(data) receives the transaction's data.

PAYU_CONFIG_PROVIDER is a string constant and IPayuConfigProvider is an abstract class with a single get(data: any): Promise<PayuConfig>. Every public method resolves the config first, through moduleRef.get(PAYU_CONFIG_PROVIDER, { strict: false }) inside a try, so the provider can live in any module of the application. A missing token throws, the catch logs PayU config provider not found at warning level under the PayuService context, and the statically injected config is used instead, so an application on the static path logs that warning on every call.

External calls

Base url is https://secure.snd.payu.com when test is true and https://secure.payu.com otherwise.

MethodRequestNotes
all threePOST {base}/pl/standard/user/oauth/authorizegrant_type=client_credentials with the client id and secret in the body.
createPOST {base}/api/v2_1/ordersmaxRedirects: 0, bearer token from the call above.
getStatusGET {base}/api/v2_1/orders/{orderId}orderId comes from the started history entry.
refundPOST {base}/api/v2_1/orders/{orderId}Body is { refund: { description: comment } }.

Status mapping

PayU statusTransStatus
COMPLETEDcompleted
CANCELEDcanceled
PENDINGpending
anything elsethe PayU status, returned unchanged

The match is case-sensitive and there is no default of pending, so a status such as WAITING_FOR_CONFIRMATION reaches the domain as itself.