@smartsoft001/paynow

The Paynow end of a transaction: three calls against the payments API, each mutating one signed locally and sent with its own idempotency key.


Install

npm install @smartsoft001/paynow @smartsoft001/trans-domain @smartsoft001/utils @nestjs/axios crypto-js

The manifest declares @smartsoft001/trans-domain and @smartsoft001/utils as peer dependencies, pinned to its own version. Every request goes through the HttpService of @nestjs/axios, crypto-js computes the signature, GuidService from @smartsoft001/utils produces the idempotency key, @nestjs/common provides @Injectable and Logger, and @nestjs/core provides ModuleRef. The imports from @smartsoft001/trans-domain are used only as types.

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 paynow 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({ paynowConfig }), which registers PaynowConfig as a value provider and PaynowService as a class provider, and only when that key is present. Register the two providers by hand, as the example does, to use the Paynow calls without the rest of the transaction shell.

The non-core module provides it without exporting it

TransShellNestjsModule.forRoot lists PaynowConfig and PaynowService among its providers but not among its exports, unlike PayU, PayPal and Revolut. The transaction service inside the module injects Paynow normally, so payments work; a module that imports the transaction shell and tries to inject PaynowService itself does not resolve it. TransShellNestjsCoreModule.forRoot exports both.

What sets this provider apart from the other three is that authentication is split in two. The api key travels with the request as a header, while the signature key never leaves the process: the service computes an HMAC-SHA256 digest of the serialised body locally and sends only the digest. Every mutating request also carries a freshly generated idempotency key, so a retried create or refund is recognised by Paynow as the same operation rather than as a second one.

Usage

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

import { PaynowConfig, PaynowService } from '@smartsoft001/paynow';

/**
 * `PaynowService` implements `ITransPaymentSingleService` from
 * `@smartsoft001/trans-domain`, so an application normally gets it from
 * `TransShellNestjsModule.forRoot({ paynowConfig })`, which registers the
 * service only when that key is present. Register the two providers by hand
 * when you want the Paynow calls without the rest of the transaction shell.
 *
 * `PaynowConfig` 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 PaynowConfig()`.
 */
export const paynowProviders: Provider[] = [
  PaynowService,
  {
    provide: PaynowConfig,
    useValue: {
      // `apiKey` travels as the `Api-Key` header. `apiSignatureKey` never
      // leaves the process: the service signs each body with it locally
      // (HMAC-SHA256) and sends the digest as the `Signature` header.
      apiKey: 'paynow-api-key',
      apiSignatureKey: 'paynow-api-signature-key',
      continueUrl: 'https://app.example.com/orders/thank-you',
      // `test: true` sends every request to https://api.sandbox.paynow.pl,
      // `false` or absent sends it to https://api.paynow.pl.
      test: true,
    } satisfies PaynowConfig,
  },
];

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

The region registers the two providers the service needs and wraps them in a module that imports HttpModule. PaynowConfig declares its required fields without initialisers, so the config is supplied as an object literal to useValue rather than through new PaynowConfig(), 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. PaynowService resolves, so the three constructor dependencies are satisfiable from these providers alone. The PaynowConfig read back out of the injector equals the literal the example wrote, including test: true and both keys. And the stub recorded nothing after the service had been resolved, so registering the provider signs nothing and sends nothing.

API

PaynowConfig

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

FieldTypeWhat it does
apiKeystringSent as the Api-Key header on all three requests.
apiSignatureKeystringThe HMAC key. Never sent; only the digest it produces is.
continueUrlstringWhere Paynow sends the buyer after payment.
testbooleantrue sends every request to https://api.sandbox.paynow.pl, false or absent to https://api.paynow.pl.

PaynowService

MethodReturnsWhat it does
create(obj)Promise<{ orderId: string; redirectUrl: string }>Creates a payment and returns redirectUrl from the answer with paymentId as the order id.
getStatus<T>(trans)Promise<{ status: TransStatus; data: any }>Reads the status of the payment whose id is stored on the started history entry, and returns the whole answer body as data.
refund(trans, comment)Promise<any>Refunds trans.amount in full and resolves the Paynow response body. comment is accepted but not sent: the body carries the amount only.

create takes { id, name, amount, firstName?, lastName?, email?, contactPhone?, clientIp, data, options? }, which is the shared ITransPaymentSingleService shape with options made optional. The payment it builds sends id as externalId and name as the description, hard-codes the currency to PLN, so amount is read as grosze and sent unchanged, and always includes a buyer object: the email alone when nothing else is known, and the phone, first name and last name alongside it when any of them is present.

Request signing

HeaderValueOn which requests
Api-Keyconfig.apiKeyall three
Idempotency-KeyA fresh GuidService.create()create and refund
SignatureBase64 of HmacSHA256(JSON.stringify(body), config.apiSignatureKey), computed locallycreate and refund

The digest is taken over exactly the string that is sent, so the body must not be reserialised or reordered between signing and sending. getStatus sends neither header, because it has no body to sign.

Choosing the credentials

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

PAYNOW_CONFIG_PROVIDER is a string constant and IPaynowConfigProvider is an abstract class with a single get(data: any): Promise<PaynowConfig>. Every public method resolves the config first, through moduleRef.get(PAYNOW_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 Paynow config provider not found at warning level, and the statically injected config is used instead.

External calls

Base url is https://api.sandbox.paynow.pl when test is true and https://api.paynow.pl otherwise.

MethodRequestNotes
createPOST {base}/v1/paymentsmaxRedirects: 0, signed, with an idempotency key.
getStatusGET {base}/v1/payments/{orderId}/statusorderId comes from the started history entry.
refundPOST {base}/v1/payments/{orderId}/refundsBody is { amount: trans.amount }, signed and idempotent.

Status mapping

Paynow statusTransStatus
CONFIRMEDcompleted
REJECTEDcanceled
PENDINGpending
anything elsethe Paynow status, returned unchanged

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