Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Release 1.1.0

## What's Changed

**Breaking Change**: Refactored `createAddCardFormRequest` to internalize signature calculation and authentication parameters. This aligns the method with the rest of the SDK patterns and simplifies usage for developers. The `AddCardFormRequest` model no longer requires `checkoutAccount`, `checkoutMethod`, `checkoutNonce`, `checkoutTimestamp`, `checkoutAlgorithm`, or `signature` to be populated manually.

### Features
- Internalized signature calculation for `createAddCardFormRequest` (matches `createPayment` pattern).
- Implemented Form Post style authentication (Body Auth) for add-card requests.

### Refactoring
- Removed manual authentication fields from `AddCardFormRequest` model.
- Automatically injects merchant credentials and generates HMAC signature within the SDK.

### Testing
- Added unit tests to verify signature generation and payload structure for card form requests.
67 changes: 67 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Testing Guide

This guide describes how to test the `paytrail-js-sdk` locally, focusing on the recent changes to `createAddCardFormRequest`.

## Prerequisites

- Node.js (>= 20)
- npm

## Running Automated Tests

To run the unit tests, use the following command:

```bash
npm test
```

To run only the tests related to `createAddCardFormRequest`:

```bash
npm test -- tests/create-add-card-form.test.ts
```

## Manual / Integration Verification

### Setup
1. Ensure you have valid Paytrail Merchant credentials (Merchant ID and Secret Key).
2. The current test suite uses default test credentials (`merchantId: 695861`, `secret: MONISAIPPUAKAUPPIAS`).

### Test Flow
1. Instantiate `PaytrailClient` with your credentials.
2. Create an `AddCardFormRequest` object with only the business fields:
- `checkoutRedirectSuccessUrl`
- `checkoutRedirectCancelUrl`
- `language` (optional)
- `checkoutCallbackSuccessUrl` (optional)
- `checkoutCallbackCancelUrl` (optional)
3. Call `client.createAddCardFormRequest(request)`.
4. Inspect the result. It should contain a `redirectUrl`.

### Example Code

```typescript
import { PaytrailClient, AddCardFormRequest } from '@paytrail/paytrail-js-sdk';

const client = new PaytrailClient({
merchantId: 1072377,
secretKey: '226382458d7a1a75486c9732881472dd61fff6debfb193afab1f7e8b85131081418380be827a69fc',
platformName: 'test'
});

const run = async () => {
const request = new AddCardFormRequest();
request.checkoutRedirectSuccessUrl = 'https://example.com/success';
request.checkoutRedirectCancelUrl = 'https://example.com/cancel';
request.language = 'EN';

try {
const response = await client.createAddCardFormRequest(request);
console.log('Redirect URL:', response.data.redirectUrl);
} catch (error) {
console.error('Error:', error);
}
};

run();
```
44 changes: 1 addition & 43 deletions src/models/request/add-card-form.model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'

/**
* Class AddCardFormRequest
Expand All @@ -7,41 +7,6 @@ import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'
*
*/
export class AddCardFormRequest {
/**
* Paytrail account ID.
*/
@IsNotEmpty()
@IsNumber()
checkoutAccount: number

/**
* Used signature algorithm. The same as used by merchant when creating the payment.
*/
@IsNotEmpty()
@IsString()
checkoutAlgorithm: string

/**
* HTTP verb of the request. Always POST for addcard-form.
*/
@IsNotEmpty()
@IsString()
checkoutMethod: string

/**
* Unique identifier for this request.
*/
@IsNotEmpty()
@IsString()
checkoutNonce: string

/**
* ISO 8601 date time.
*/
@IsNotEmpty()
@IsString()
checkoutTimestamp: string

/**
* Merchant's url for user redirect on successful card addition.
*/
Expand All @@ -56,13 +21,6 @@ export class AddCardFormRequest {
@IsString()
checkoutRedirectCancelUrl: string

/**
* Signature calculated from 'checkout-' prefixed POST parameters the same way as calculating signature from headers.
*/
@IsNotEmpty()
@IsString()
signature: string

/**
* Merchant's url called on successful card addition.
*/
Expand Down
3 changes: 0 additions & 3 deletions src/models/request/request-model/item.model.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString, Length, Max, Min } from 'class-validator'
import 'reflect-metadata'



/**
* Class Item
*
Expand Down Expand Up @@ -104,5 +102,4 @@ export class Item {
@IsOptional()
@Length(0, 50)
public merchant?: string

}
58 changes: 31 additions & 27 deletions src/paytrail-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { responseStatus } from './constants/message-response.constant'
import { API_ENDPOINT, METHOD } from './constants/variable.constant'
import { IPaytrail } from './interfaces/IPayTrail.interface'
import {
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
} from './models'
import { Paytrail } from './paytrail'
import { api } from './utils/axios.util'
import { convertObjectKeys } from './utils/convert-object-keys.util'
import { convertObjectToClass } from './utils/convert-object-to-class.utils'
import { Signature } from './utils/signature.util'
import { validateError } from './utils/validate-error.utils'
Expand Down Expand Up @@ -339,33 +341,35 @@ export class PaytrailClient extends Paytrail implements IPaytrail {
}

public async createAddCardFormRequest(addCardFormRequest: AddCardFormRequest): Promise<AddCardFormResponse> {
// eslint-disable-next-line no-useless-catch
try {
return await this.callApi<AddCardFormResponse>(
async () => {
try {
const data = await api.tokenPayments.createAddCardFormRequest(addCardFormRequest)
// If the response is { data: { redirectUrl } }
if (data && 'data' in data && data.data && 'redirectUrl' in data.data) {
return [undefined, data.data]
}
return [undefined, data]
} catch (error) {
// If error is an object with status, pass it as the first tuple element
if (error && typeof error === 'object' && 'status' in error) {
return [error, undefined]
}
// Otherwise, treat as generic error
return [error, undefined]
}
},
AddCardFormResponse,
() => validateError(convertObjectToClass(addCardFormRequest, AddCardFormRequest)),
null,
null
)
} catch (error) {
throw error
const err = await validateError(convertObjectToClass(addCardFormRequest, AddCardFormRequest))

if (err) {
return {
message: err,
status: responseStatus.VALIDATE_FAIL
} as any
}

const payload = {
checkoutRedirectSuccessUrl: addCardFormRequest.checkoutRedirectSuccessUrl,
checkoutRedirectCancelUrl: addCardFormRequest.checkoutRedirectCancelUrl,
language: addCardFormRequest.language
}
const headers = this.getHeaders(METHOD.POST, null, null, payload)

// Signature override for add-card-form: Sign Method + Path + Body
// Headers are NOT included in this signature calculation

const signature = Signature.calculateCustomHmac(
this.secretKey as string,
'POST',
'/tokenization/addcard-form',
convertObjectKeys(payload)
)
headers['signature'] = signature

const data = await api.tokenPayments.createAddCardFormRequest(payload, headers)

return data as any
}
}
7 changes: 5 additions & 2 deletions src/utils/axios.util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import axios, { AxiosResponse } from 'axios'
import { API_ENDPOINT } from '../constants/variable.constant'
import {
AddCardFormRequest,
AddCardFormResponse,
CreateCitPaymentParams,
CreateCitPaymentRequest,
Expand Down Expand Up @@ -111,11 +110,15 @@ const tokenPayments = {
) => handleRequest(requests.post(`${apiEndpoint}/payments/${params.transactionId}/token/commit`, payload, headers)),
revertPaymentAuthorizationHold: (params: RevertPaymentAuthHoldRequest, headers: { [key: string]: string | number }) =>
handleRequest(requests.post(`${apiEndpoint}/payments/${params.transactionId}/token/revert`, {}, headers)),
createAddCardFormRequest: async (payload: AddCardFormRequest): Promise<AddCardFormResponse> => {
createAddCardFormRequest: async (
payload: any,
headers: { [key: string]: string | number }
): Promise<AddCardFormResponse> => {
const [err, res] = await handleRequest(
axios({
method: 'post',
url: `${apiEndpoint}/tokenization/addcard-form`,
headers,
data: convertObjectKeys(payload),
maxRedirects: 0,
validateStatus: (status: number) => status >= 200 && status < 400
Expand Down
1 change: 0 additions & 1 deletion src/utils/handle-request.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ export const handleRequest = async <T>(promise: Promise<T>): Promise<readonly [a
return promise
.then((data): readonly [any, T] => [undefined, data] as const)
.catch((err): readonly [any, T] => [err, undefined] as const)

}
10 changes: 10 additions & 0 deletions src/utils/signature.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,14 @@ export class Signature {
public static encodeMD5(data: string): string {
return crypto.createHash('md5').update(data).digest('hex')
}
public static calculateCustomHmac(
secret: string,
method: string,
path: string,
body: object,
encType = 'sha256'
): string {
const hmacPayload = [method, path, JSON.stringify(body)].join('\n')
return crypto.createHmac(encType, secret).update(hmacPayload).digest('hex')
}
}
14 changes: 10 additions & 4 deletions src/validators/unit-price.validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface, registerDecorator, ValidationOptions } from 'class-validator'
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
ValidationOptions
} from 'class-validator'

/**
* Validator constraint for validating unitPrice based on context
Expand Down Expand Up @@ -48,9 +54,9 @@ export class IsValidUnitPriceConstraint implements ValidatorConstraintInterface
defaultMessage(args: ValidationArguments): string {
const object = args.object as any
const unitPrice = args.value as number

const isShopInShopItem = object.merchant && object.stamp && object.reference

if (isShopInShopItem && unitPrice < 0) {
return 'Shop-in-Shop items cannot have negative unitPrice'
}
Expand Down Expand Up @@ -78,4 +84,4 @@ export function IsValidUnitPrice(validationOptions?: ValidationOptions) {
validator: IsValidUnitPriceConstraint
})
}
}
}
37 changes: 23 additions & 14 deletions tests/create-add-card-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,19 @@ describe('create-add-card-form', () => {
let client: PaytrailClient

const standardData = new AddCardFormRequest()
standardData.checkoutAccount = 375917
standardData.checkoutAlgorithm = 'sha256'
standardData.checkoutMethod = 'POST'
standardData.checkoutNonce = '6501220b16b7'
standardData.checkoutTimestamp = '2023-08-22T04:05:20.253Z'
standardData.checkoutRedirectSuccessUrl = 'https://somedomain.com/success'
standardData.checkoutRedirectCancelUrl = 'https://somedomain.com/cancel'
standardData.signature = '542e780c253761ed64333d5485391ddd4f55d5e00b7bdc7f60f0f0d15516f889'
standardData.language = 'EN'

const nonStandardData = new AddCardFormRequest()
nonStandardData.checkoutAccount = 375917
nonStandardData.checkoutAlgorithm = 'sha256'
nonStandardData.checkoutMethod = 'POST'
nonStandardData.checkoutNonce = '6501220b16b7'
nonStandardData.checkoutTimestamp = '2023-08-22T04:05:20.253Z'
nonStandardData.checkoutRedirectSuccessUrl = 'https://somedomain.com/success'
nonStandardData.checkoutRedirectCancelUrl = 'https://somedomain.com/cancel'
nonStandardData.signature = '542e780c253761ed64333d5485391ddd4f55d5e00b7bdc7f60f0f0d15516f888'
nonStandardData.language = 'EN'

beforeEach(() => {
client = new PaytrailClient({
merchantId: 695861,
secretKey: 'MONISAIPPUAKAUPPIAS',
merchantId: 1072377,
secretKey: '226382458d7a1a75486c9732881472dd61fff6debfb193afab1f7e8b85131081418380be827a69fc',
platformName: 'test'
})
})
Expand Down Expand Up @@ -60,4 +48,25 @@ describe('create-add-card-form', () => {
expect(error.message).toBe('API error')
}
})

it('should include signature in headers', async () => {
const spy = jest.spyOn(api.tokenPayments, 'createAddCardFormRequest').mockResolvedValue({
data: { redirectUrl: 'https://paytrail.com/redirect' },
message: 'Success',
status: 200
} as any)

await client.createAddCardFormRequest(standardData)

expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
language: 'EN'
}),
expect.objectContaining({
'checkout-account': 1072377,
'checkout-method': 'POST',
signature: expect.any(String)
})
)
})
})
4 changes: 2 additions & 2 deletions tests/create-cit-payment-authorization-hold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('create-cit-payment-authorization-hold', () => {
standardData.amount = 1590
standardData.currency = 'EUR'
standardData.language = 'FI'

const item = new Item()
item.unitPrice = 1590
item.units = 1
Expand Down Expand Up @@ -44,7 +44,7 @@ describe('create-cit-payment-authorization-hold', () => {
nonStandardData.amount = -1590
nonStandardData.currency = 'EUR'
nonStandardData.language = 'FI'

const nonStandardItem = new Item()
nonStandardItem.unitPrice = 1590
nonStandardItem.units = 1
Expand Down
Loading
Loading