@smartsoft001/revolut

The Revolut end of a transaction: two calls against the Merchant orders API, pinned to one API version, with refunds deliberately unsupported.


Install

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

The manifest declares @smartsoft001/trans-domain as a peer dependency, pinned to its own version, and nothing else. Both requests go through the HttpService of @nestjs/axios, @nestjs/common provides @Injectable, @Optional 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 revolut as its system, but it differs from the other three in three visible ways: create answers with the whole Revolut body as responseData alongside the redirectUrl, refund always rejects, and the config is injected with @Optional().

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

Authentication is a single merchant secret key sent as a bearer token, with no token exchange to perform, which makes this the smallest configuration of the four: one string and an optional flag.

The core module exports it without providing it

TransShellNestjsCoreModule.forRoot lists RevolutConfig and RevolutService in its exports but has no branch that provides them, unlike its payuConfig, paypalConfig and paynowConfig branches. Passing a revolutConfig to the core module therefore does not give an application a working Revolut provider. TransShellNestjsModule.forRoot both provides and exports them.

Usage

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

import { RevolutConfig, RevolutService } from '@smartsoft001/revolut';

/**
 * `RevolutService` implements `ITransPaymentSingleService` from
 * `@smartsoft001/trans-domain`, so an application normally gets it from
 * `TransShellNestjsModule.forRoot({ revolutConfig })`, which registers the
 * service only when that key is present. Register the two providers by hand
 * when you want the Revolut calls without the rest of the transaction shell.
 *
 * `RevolutConfig` is a plain class used as its own injection token. Its
 * `token` field has no initialiser, so pass an object literal to `useValue`
 * instead of calling `new RevolutConfig()`. Unlike the other three payment
 * services, `RevolutService` injects the config with `@Optional()`: it
 * resolves without this provider and only fails once a method needs the
 * merchant token.
 */
export const revolutProviders: Provider[] = [
  RevolutService,
  {
    provide: RevolutConfig,
    useValue: {
      // The merchant secret API key, sent as a bearer token alongside the
      // `Revolut-Api-Version` header, pinned to the exported
      // `REVOLUT_API_VERSION` ('2024-09-01').
      token: 'revolut-secret-api-key',
      // `test: true` sends every request to
      // https://sandbox-merchant.revolut.com, `false` or absent sends it to
      // https://merchant.revolut.com.
      test: true,
    } satisfies RevolutConfig,
  },
];

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

The region registers the two providers and wraps them in a module that imports HttpModule. RevolutConfig declares token without an initialiser, so the config is supplied as an object literal to useValue rather than through new RevolutConfig(), 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, and checks four things. RevolutService resolves. The RevolutConfig read back out of the injector equals the literal the example wrote, including test: true. The stub recorded nothing after the service had been resolved, so the merchant key never left the process. And a second testing module, built from the service and the HTTP stub alone, still resolves the service, which is the @Optional() config in action: the missing provider is not an error until a method needs the token.

API

RevolutConfig

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

FieldTypeWhat it does
tokenstringThe merchant secret API key, sent as Authorization: Bearer {token}.
testbooleantrue sends both requests to https://sandbox-merchant.revolut.com, false or absent to https://merchant.revolut.com.

REVOLUT_API_VERSION is exported alongside it as the string 2024-09-01 and is sent as the Revolut-Api-Version header on both requests. It is a constant, not a setting: changing the version means changing the package.

RevolutService

MethodReturnsWhat it does
create(obj)Promise<{ orderId: string; redirectUrl: string; responseData: any }>Creates an order and returns its checkout_url as the redirect, its token as the order id, and the whole answer as responseData.
getStatus<T>(trans)Promise<{ status: TransStatus; data: any }>Reads the order by the id stored on the started history entry and maps the state of the answer.
refund(trans, comment)Promise<any>, always rejectedRejects with the string Revolut does not support refund. It sends no request and does not read the config.

create takes { id, name, amount, firstName?, lastName?, email?, contactPhone?, clientIp, data, options? }, which is the shared ITransPaymentSingleService shape with options made optional. Nothing here reads options, so a value passed by a caller is accepted and ignored, while PayU and Paynow read it. The order it builds sends id as merchant_order_ext_ref and name as the description, hard-codes the currency to PLN and capture_mode to automatic, so amount is read as minor units and sent unchanged, and adds a customer object only when at least one of the email, phone, first name and last name is present, joining the names into full_name.

The two methods use different identifiers

The shared contract allows both a redirectUrl and a responseData, and this implementation returns both: the redirect comes from response.data.checkout_url, and the Revolut answer is handed back whole beside it. That whole answer matters for the follow-up call, because the two methods use different identifiers. create reports response.data.token as the orderId, which is what the transaction is stored under, while getStatus reads the order id from historyItem.data.responseData.id instead. A history entry that kept only the order id, and not the response body, cannot be refreshed.

Choosing the credentials

PathHow it is registeredWhen it wins
Static configA RevolutConfig value provider, as in the example. Optional at construction.Whenever no config provider resolves.
Per transactionA class implementing IRevolutConfigProvider under REVOLUT_CONFIG_PROVIDER.Whenever the lookup succeeds. Its get(data) receives the transaction's data.

REVOLUT_CONFIG_PROVIDER is a string constant and IRevolutConfigProvider is an abstract class with a single get(data: any): Promise<RevolutConfig>. Both public methods resolve the config first, through moduleRef.get(REVOLUT_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 Revolut config provider not found at warning level, and the injected config is used instead. Because that injection is @Optional(), an application with neither path registered constructs the service successfully and fails only inside the first call, when the base url is read off undefined.

External calls

Base url is https://sandbox-merchant.revolut.com when test is true and https://merchant.revolut.com otherwise. Both requests carry Authorization: Bearer {token}, Revolut-Api-Version: 2024-09-01, Content-Type: application/json and Accept: application/json.

MethodRequestNotes
createPOST {base}/api/ordersmaxRedirects: 0.
getStatusGET {base}/api/orders/{id}{id} is responseData.id from the started history entry, not orderId.
refundnoneRejects before doing anything.

Status mapping

getStatus matches the state of the order, which Revolut reports in lower case.

Revolut stateTransStatus
completedcompleted
authorisedcompleted
pendingpending
processingpending
cancelledcanceled
failedcanceled
anything elsethe Revolut state, returned unchanged

Note the two spellings. Revolut reports cancelled, the domain stores canceled, and the mapping is the only place the difference is handled.