Skip to content
Merged
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 examples/payments/release.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @docs https://docs.mollie.com/reference/release-authorization
*/
const { createMollieClient } = require('@mollie/api-client');

const mollieClient = createMollieClient({ apiKey: 'test_dHar4XY7LxsDOtmnkVtjNVWXLSlXsM' });

(async () => {
try {
const payment = await mollieClient.payments.releaseAuthorization('tr_Eq8xzWUPA4');

console.log(payment);
} catch (error) {
console.warn(error);
}
})();
16 changes: 16 additions & 0 deletions examples/payments/release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @docs https://docs.mollie.com/reference/release-authorization
*/
import createMollieClient, { Payment } from '@mollie/api-client';

const mollieClient = createMollieClient({ apiKey: 'test_dHar4XY7LxsDOtmnkVtjNVWXLSlXsM' });

(async () => {
try {
const payment: Payment = await mollieClient.payments.releaseAuthorization('tr_Eq8xzWUPA4');

console.log(payment);
} catch (error) {
console.warn(error);
}
})();
29 changes: 25 additions & 4 deletions src/binders/payments/PaymentsBinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import assertWellFormedId from '../../plumbing/assertWellFormedId';
import renege from '../../plumbing/renege';
import type Callback from '../../types/Callback';
import Binder from '../Binder';
import { type CancelParameters, type CreateParameters, type GetParameters, type IterateParameters, type PageParameters, type UpdateParameters } from './parameters';
import { type CancelParameters, type CreateParameters, type GetParameters, type IterateParameters, type PageParameters, type ReleaseParameters, type UpdateParameters } from './parameters';

const pathSegment = 'payments';

const assertPaymentResource = (id: string) => assertWellFormedId(id, 'payment');

export default class PaymentsBinder extends Binder<PaymentData, Payment> {
constructor(protected readonly networkClient: TransformingNetworkClient) {
super();
Expand Down Expand Up @@ -47,7 +49,7 @@ export default class PaymentsBinder extends Binder<PaymentData, Payment> {
public get(id: string, parameters: GetParameters, callback: Callback<Payment>): void;
public get(id: string, parameters?: GetParameters) {
if (renege(this, this.get, ...arguments)) return;
assertWellFormedId(id, 'payment');
assertPaymentResource(id);
return this.networkClient.get<PaymentData, Payment>(`${pathSegment}/${id}`, parameters);
}

Expand Down Expand Up @@ -89,7 +91,7 @@ export default class PaymentsBinder extends Binder<PaymentData, Payment> {
public update(id: string, parameters: UpdateParameters, callback: Callback<Payment>): void;
public update(id: string, parameters: UpdateParameters) {
if (renege(this, this.update, ...arguments)) return;
assertWellFormedId(id, 'payment');
assertPaymentResource(id);
return this.networkClient.patch<PaymentData, Payment>(`${pathSegment}/${id}`, parameters);
}

Expand All @@ -106,7 +108,26 @@ export default class PaymentsBinder extends Binder<PaymentData, Payment> {
public cancel(id: string, parameters: CancelParameters, callback: Callback<Page<Payment>>): void;
public cancel(id: string, parameters?: CancelParameters) {
if (renege(this, this.cancel, ...arguments)) return;
assertWellFormedId(id, 'payment');
assertPaymentResource(id);
return this.networkClient.delete<PaymentData, Payment>(`${pathSegment}/${id}`, parameters);
}

/**
* Releases the full remaining authorized amount. Call this endpoint when you will not be making any additional captures. Payment authorizations may also be released manually from the
* Mollie Dashboard.
*
* Mollie will do its best to process release requests, but it is not guaranteed that it will succeed. It is up to the issuing bank if and when the hold will be released.
*
* If the request does succeed, the payment status will change to `canceled` for payments without captures. If there is a successful capture, the payment will transition to `paid`.
*
* @since 4.3.0
* @see https://docs.mollie.com/reference/release-authorization
*/
public releaseAuthorization(id: string, parameters?: ReleaseParameters): Promise<true>;
public releaseAuthorization(id: string, parameters: ReleaseParameters, callback: Callback<Page<true>>): void;
public releaseAuthorization(id: string, parameters?: ReleaseParameters) {
if (renege(this, this.releaseAuthorization, ...arguments)) return;
assertPaymentResource(id);
return this.networkClient.post<PaymentData, true>(`${pathSegment}/${id}/release-authorization`, parameters ?? {});
}
}
4 changes: 4 additions & 0 deletions src/binders/payments/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,7 @@ export type UpdateParameters = Pick<PaymentData, 'redirectUrl' | 'cancelUrl' | '
export interface CancelParameters extends IdempotencyParameter {
testmode?: boolean;
}

export interface ReleaseParameters {
testmode?: boolean;
}
22 changes: 16 additions & 6 deletions src/communication/NetworkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,24 @@ const throwApiError = run(
async function processFetchResponse(response: ResponseWithIdempotencyKey) {
// Request was successful, but no content was returned.
if (response.status == 204) return true;
// Request was successful and content was returned.
const body = await response.json();
if (Math.floor(response.status / 100) == 2) {
return body;
const body = await response.text();
const isSuccessStatus = Math.floor(response.status / 100) == 2;
// this should have been a 204, but we'll be lenient...
if (body.length === 0 && isSuccessStatus) return true;
// Parse the response body as JSON.
// We could check the content-type header first here, but we always expect the mollie API to return JSON.
let json;
try {
json = JSON.parse(body);
} catch (error) {
throw new ApiError('Received unexpected response from the server');
}
if (isSuccessStatus) {
return json;
}
// Request was not successful, but the response body contains an error message.
if (null != body) {
throw ApiError.createFromResponse(body, response.idempotencyKey);
if (null != json) {
throw ApiError.createFromResponse(json, response.idempotencyKey);
}
// Request was not successful.
throw new ApiError('An unknown error has occurred');
Expand Down
63 changes: 61 additions & 2 deletions tests/integration/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('payments', () => {
return payment;
})
.catch(fail));
console.log('If you want to test the full refund flow, set the payment to paid:', openPayment.getCheckoutUrl());
console.log(`If you want to test the full refund flow, set the payment ${openPayment.id} to paid:`, openPayment.getCheckoutUrl());
return;
}

Expand Down Expand Up @@ -153,7 +153,7 @@ describe('payments', () => {
return payment;
})
.catch(fail));
console.log('If you want to test the full authorize-then-capture flow, set the payment to authorized:', openPayment.getCheckoutUrl());
console.log(`If you want to test the full authorize-then-capture flow, set the payment ${openPayment.id} to authorized:`, openPayment.getCheckoutUrl());
return;
}

Expand All @@ -176,4 +176,63 @@ describe('payments', () => {
const captureOnPayment = await getHead(updatedPayment.getCaptures());
expect(capture.id).toBe(captureOnPayment.id);
});

it('should release authorization', async () => {
/**
* This test will
* - check if an authorized payment created by this test exists (verified using metadata)
* - if yes: release the authorization - this tests the full flow and will work exactly once for every payment created by this test.
* - if no:
* - check if there's an open payment created by this test and if not create one
* - log the checkout URL of the open payment, which a user can use to set the status to `authorized` to be able to test the full flow
* - exit the test
*/
const metaIdentifier = 'release-auth-test';

const payments = await mollieClient.payments.page();
const releaseAuthPayments = payments.filter(payment => payment.metadata == metaIdentifier);
const authorizedPayment = releaseAuthPayments.find(payment => payment.status == PaymentStatus.authorized);

if (null == authorizedPayment) {
const openPayment =
releaseAuthPayments.find(payment => payment.status == PaymentStatus.open) ??
(await mollieClient.payments
.create({
amount: { value: '10.00', currency: 'EUR' },
description: 'Integration test payment',
redirectUrl: 'https://example.com/redirect',
metadata: metaIdentifier,
captureMode: CaptureMethod.manual,
method: PaymentMethod.creditcard,
})
.then(payment => {
expect(payment).toBeDefined();
expect(payment.captureMode).toBe('manual');
expect(payment.authorizedAt).toBeUndefined();
expect(payment.captureDelay).toBeUndefined();
expect(payment.captureBefore).toBeUndefined();

return payment;
})
.catch(fail));
console.log(`If you want to test the release authorization flow, set the payment ${openPayment.id} to authorized:`, openPayment.getCheckoutUrl());
return;
}

expect(authorizedPayment.authorizedAt).toBeDefined();
expect(authorizedPayment.captureBefore).toBeDefined();

// Release the authorization
const releaseResult = await mollieClient.payments
.releaseAuthorization(authorizedPayment.id)
.then(result => {
expect(result).toBe(true);
return result;
})
.catch(fail);

// Note: The payment status might not be updated immediately after calling releaseAuthorization (response code 202)
// Eventually, the payment status should change to 'canceled' for payments without captures
// or to 'paid' if there was a successful capture
});
});
Loading