From b9820ee380ec188369bfc002ec15c39891e741ee Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Tue, 10 Sep 2024 12:59:47 +0200 Subject: [PATCH 1/8] add captures api --- .../captures/PaymentCapturesBinder.ts | 28 ++++++++++---- src/binders/payments/captures/parameters.ts | 14 ++++--- src/binders/payments/parameters.ts | 2 +- src/createMollieClient.ts | 4 +- src/data/payments/captures/CaptureHelper.ts | 15 +++++++- src/data/payments/captures/data.ts | 37 +++++++++++++++---- src/data/payments/data.ts | 35 ++++++++++++++++++ src/data/settlements/data.ts | 4 ++ 8 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/binders/payments/captures/PaymentCapturesBinder.ts b/src/binders/payments/captures/PaymentCapturesBinder.ts index 2bec810e..6a24757e 100644 --- a/src/binders/payments/captures/PaymentCapturesBinder.ts +++ b/src/binders/payments/captures/PaymentCapturesBinder.ts @@ -8,7 +8,7 @@ import checkId from '../../../plumbing/checkId'; import renege from '../../../plumbing/renege'; import type Callback from '../../../types/Callback'; import Binder from '../../Binder'; -import { type GetParameters, type IterateParameters, type PageParameters } from './parameters'; +import { type CreateParameters, type GetParameters, type IterateParameters, type PageParameters } from './parameters'; function getPathSegments(paymentId: string) { return `payments/${paymentId}/captures`; @@ -20,11 +20,27 @@ export default class PaymentCapturesBinder extends Binder alias(this, { page: ['all', 'list'] }); } + /** + * Capture an *authorized* payment. + * + * @since 4.1.0 + * @see https://docs.mollie.com/reference/create-capture + */ + public create(parameters: CreateParameters): Promise; + public create(parameters: CreateParameters, callback: Callback): void; + public create(parameters: CreateParameters) { + if (renege(this, this.create, ...arguments)) return; + const { paymentId, ...data } = parameters; + if (!checkId(paymentId, 'payment')) { + throw new ApiError('The payment id is invalid'); + } + return this.networkClient.post(getPathSegments(paymentId), data); + } + /** * Retrieve a single capture by its ID. Note the original payment's ID is needed as well. * - * Captures are used for payments that have the *authorize-then-capture* flow. The only payment methods at the moment that have this flow are **Klarna Pay now**, **Klarna Pay later** and **Klarna - * Slice it**. + * Captures are used for payments that have the *authorize-then-capture* flow. Mollie currently supports captures for **Cards** and **Klarna**. * * @since 1.1.1 * @see https://docs.mollie.com/reference/v2/captures-api/get-capture @@ -46,8 +62,7 @@ export default class PaymentCapturesBinder extends Binder /** * Retrieve all captures for a certain payment. * - * Captures are used for payments that have the *authorize-then-capture* flow. The only payment methods at the moment that have this flow are *Klarna Pay now*, *Klarna Pay later* and *Klarna Slice - * it*. + * Captures are used for payments that have the *authorize-then-capture* flow. Mollie currently supports captures for **Cards** and **Klarna**. * * @since 3.0.0 * @see https://docs.mollie.com/reference/v2/captures-api/list-captures @@ -66,8 +81,7 @@ export default class PaymentCapturesBinder extends Binder /** * Retrieve all captures for a certain payment. * - * Captures are used for payments that have the *authorize-then-capture* flow. The only payment methods at the moment that have this flow are *Klarna Pay now*, *Klarna Pay later* and *Klarna Slice - * it*. + * Captures are used for payments that have the *authorize-then-capture* flow. Mollie currently supports captures for **Cards** and **Klarna**. * * @since 3.6.0 * @see https://docs.mollie.com/reference/v2/captures-api/list-captures diff --git a/src/binders/payments/captures/parameters.ts b/src/binders/payments/captures/parameters.ts index d4a52d69..38faf2fb 100644 --- a/src/binders/payments/captures/parameters.ts +++ b/src/binders/payments/captures/parameters.ts @@ -1,18 +1,22 @@ -import { type CaptureEmbed } from '../../../data/payments/captures/data'; -import { type PaginationParameters, type ThrottlingParameter } from '../../../types/parameters'; +import { type CaptureData, type CaptureInclude } from '../../../data/payments/captures/data'; +import { type IdempotencyParameter, type PaginationParameters, type ThrottlingParameter } from '../../../types/parameters'; +import type PickOptional from '../../../types/PickOptional'; interface ContextParameters { paymentId: string; - testmode?: boolean; } +export type CreateParameters = ContextParameters & PickOptional & IdempotencyParameter; + export type GetParameters = ContextParameters & { - embed?: CaptureEmbed[]; + include?: CaptureInclude; + testmode?: boolean; }; export type PageParameters = ContextParameters & PaginationParameters & { - embed?: CaptureEmbed[]; + include?: CaptureInclude; + testmode?: boolean; }; export type IterateParameters = Omit & ThrottlingParameter; diff --git a/src/binders/payments/parameters.ts b/src/binders/payments/parameters.ts index 54c4b029..bc674777 100644 --- a/src/binders/payments/parameters.ts +++ b/src/binders/payments/parameters.ts @@ -5,7 +5,7 @@ import { type IdempotencyParameter, type PaginationParameters, type ThrottlingPa import type PickOptional from '../../types/PickOptional'; export type CreateParameters = Pick & - PickOptional & { + PickOptional & { /** * Normally, a payment method screen is shown. However, when using this parameter, you can choose a specific payment method and your customer will skip the selection screen and is sent directly to * the chosen payment method. The parameter enables you to fully integrate the payment method selection into your website. diff --git a/src/createMollieClient.ts b/src/createMollieClient.ts index d6cf0e68..96c1debf 100644 --- a/src/createMollieClient.ts +++ b/src/createMollieClient.ts @@ -187,12 +187,12 @@ export default function createMollieClient(options: Options) { export { createMollieClient }; export { ApiMode, Locale, PaymentMethod, HistoricPaymentMethod, SequenceType } from './data/global'; -export { CaptureEmbed } from './data/payments/captures/data'; +export { CaptureInclude, CaptureStatus } from './data/payments/captures/data'; export { MandateMethod, MandateStatus } from './data/customers/mandates/data'; export { MethodImageSize, MethodInclude } from './data/methods/data'; export { OrderEmbed, OrderStatus } from './data/orders/data'; export { OrderLineType } from './data/orders/orderlines/OrderLine'; -export { PaymentEmbed, PaymentStatus } from './data/payments/data'; +export { PaymentEmbed, PaymentStatus, CaptureMethod } from './data/payments/data'; export { RefundEmbed, RefundStatus } from './data/refunds/data'; export { SubscriptionStatus } from './data/subscriptions/data'; export { ProfileStatus } from './data/profiles/data'; diff --git a/src/data/payments/captures/CaptureHelper.ts b/src/data/payments/captures/CaptureHelper.ts index dd8feafc..1fb594f4 100644 --- a/src/data/payments/captures/CaptureHelper.ts +++ b/src/data/payments/captures/CaptureHelper.ts @@ -12,6 +12,7 @@ import type Payment from '../Payment'; import { type PaymentData } from '../data'; import type Capture from './Capture'; import { type CaptureData } from './data'; +import type { Settlement, SettlementData } from '../../settlements/data'; export default class CaptureHelper extends Helper { constructor(networkClient: TransformingNetworkClient, protected readonly links: CaptureData['_links'], protected readonly embedded: Capture['_embedded']) { @@ -31,7 +32,7 @@ export default class CaptureHelper extends Helper { } /** - * Returns the shipment that triggered the capture to be created. + * Returns the shipment that triggered the capture to be created (if any). * * @since 3.6.0 */ @@ -41,4 +42,16 @@ export default class CaptureHelper extends Helper { if (renege(this, this.getShipment, ...arguments)) return; return runIf(this.links.shipment, ({ href }) => this.networkClient.get(href)) ?? undefinedPromise; } + + /** + * Returns the shipment this capture has been settled with (if any). + * + * @since 4.1.0 + */ + public getSettlement(): Promise | Promise; + public getSettlement(callback: Callback>): void; + public getSettlement() { + if (renege(this, this.getSettlement, ...arguments)) return; + return runIf(this.links.settlement, ({ href }) => this.networkClient.get(href)) ?? undefinedPromise; + } } diff --git a/src/data/payments/captures/data.ts b/src/data/payments/captures/data.ts index 9279f2cc..b8d881b5 100644 --- a/src/data/payments/captures/data.ts +++ b/src/data/payments/captures/data.ts @@ -1,6 +1,6 @@ import { type Amount, type ApiMode, type Links, type Url } from '../../global'; import type Model from '../../Model'; -import { type PaymentData } from '../data'; +import type { PaymentData } from '../data'; export interface CaptureData extends Model<'capture'> { /** @@ -11,6 +11,12 @@ export interface CaptureData extends Model<'capture'> { * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=mode#response */ mode: ApiMode; + /** + * The description of the capture. + * + * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=description#response + */ + description: string; /** * The amount captured. * @@ -18,11 +24,23 @@ export interface CaptureData extends Model<'capture'> { */ amount: Amount; /** - * This optional field will contain the amount that will be settled to your account, converted to the currency your account is settled in. It follows the same syntax as the `amount` property. + * This optional field will contain the approximate amount that will be settled to your account, converted to the currency your account is settled in. * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=settlementAmount#response */ settlementAmount: Amount; + /** + * The capture's status. + * + * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=status#response + */ + status: CaptureStatus; + /** + * The capture's status. + * + * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=metadata#response + */ + metadata: any; /** * The unique identifier of the payment this capture was created for, for example: `tr_7UhSN1zuXS`. The full payment object can be retrieved via the `payment` URL in the `_links` object. * @@ -30,14 +48,13 @@ export interface CaptureData extends Model<'capture'> { */ paymentId: string; /** - * The unique identifier of the shipment that triggered the creation of this capture, for example: `shp_3wmsgCJN4U`. The full shipment object can be retrieved via the `shipment` URL in the `_links` - * object. + * The unique identifier of the shipment that triggered the creation of this capture, if applicable. For example: `shp_3wmsgCJN4U`. * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=shipmentId#response */ shipmentId?: string; /** - * The unique identifier of the settlement this capture was settled with, for example: `stl_jDk30akdN`. The full settlement object can be retrieved via the `capture` URL in the `_links` object. + * The identifier referring to the settlement this capture was settled with. For example, `stl_BkEjN2eBb`. This field is omitted if the capture is not settled (yet). * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=settlementId#response */ @@ -59,11 +76,17 @@ export interface CaptureData extends Model<'capture'> { }; } -export enum CaptureEmbed { +export enum CaptureStatus { + pending = 'pending', + succeeded = 'succeeded', + failed = 'failed', +} + +export enum CaptureInclude { payment = 'payment', } -export interface CaptureLinks extends Links { +interface CaptureLinks extends Links { /** * The API resource URL of the payment the capture belongs to. * diff --git a/src/data/payments/data.ts b/src/data/payments/data.ts index 644896ec..39041ab3 100644 --- a/src/data/payments/data.ts +++ b/src/data/payments/data.ts @@ -162,6 +162,36 @@ export interface PaymentData extends Model<'payment'> { * @see https://docs.mollie.com/reference/v2/payments-api/get-payment?path=metadata#response */ metadata: unknown; + /** + * **Only relevant if you wish to manage authorization and capturing separately.** + * + * By default, the customer's card or bank account is immediately charged when they complete the payment. + * + * Some payment methods also allow placing a hold on the card or bank account. This hold or 'authorization' can then at a later point either be 'captured' or canceled. + * + * To enable this way of working, set the capture mode to `manual` and capture the payment manually using the `paymentCaptures.create` API. + */ + captureMode?: CaptureMethod; + /** + * **Only relevant if you wish to manage authorization and capturing separately.** + * + * Some payment methods allow placing a hold on the card or bank account. This hold or 'authorization' can then at a later point either be 'captured' or canceled. + * + * By default, we charge the customer's card or bank account immediately when they complete the payment. If you set a capture delay however, we will delay the automatic capturing of the payment for the specified amount of time. For example `8 hours` or `2 days`. + * + * To schedule an automatic capture, the `captureMode` must be set to `automatic`. + * + * The maximum delay is 7 days (168 hours). + * + * Possible values: `... hours`, `... days` + */ + captureDelay?: number; + /** + * **Only relevant if you wish to manage authorization and capturing separately.** + * + * Indicates the date before which the payment needs to be captured, in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. From this date onwards we can no longer guarantee a successful capture. The parameter is omitted if the payment is not authorized (yet). + */ + captureBefore?: number; /** * The customer's locale, either forced on creation by specifying the `locale` parameter, or detected by us during checkout. Will be a full locale, for example `nl_NL`. * @@ -868,6 +898,11 @@ export enum PaymentEmbed { captures = 'captures', } +export enum CaptureMethod { + automatic = 'automatic', + manual = 'manual', +} + export interface GiftCard { /** * The ID of the gift card brand that was used during the payment. diff --git a/src/data/settlements/data.ts b/src/data/settlements/data.ts index 96cb16eb..f119a0ce 100644 --- a/src/data/settlements/data.ts +++ b/src/data/settlements/data.ts @@ -1,6 +1,8 @@ import type Nullable from '../../types/Nullable'; +import type Seal from '../../types/Seal'; import { type Amount, type Links, type Url } from '../global'; import type Model from '../Model'; +import type SettlementHelper from './SettlementHelper'; export interface SettlementData extends Model<'settlement'> { /** @@ -54,6 +56,8 @@ export interface SettlementData extends Model<'settlement'> { _links: SettlementLinks; } +export type Settlement = Seal; + interface Period { /** * An array of revenue objects containing the total revenue for each payment method during this period. Each object has the following fields. From de1c64c37e7a4c05277a783eec8684904eb927a3 Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Wed, 11 Sep 2024 13:03:07 +0200 Subject: [PATCH 2/8] export missing mandate types from package --- src/createMollieClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/createMollieClient.ts b/src/createMollieClient.ts index d6cf0e68..462f20e3 100644 --- a/src/createMollieClient.ts +++ b/src/createMollieClient.ts @@ -188,7 +188,7 @@ export { createMollieClient }; export { ApiMode, Locale, PaymentMethod, HistoricPaymentMethod, SequenceType } from './data/global'; export { CaptureEmbed } from './data/payments/captures/data'; -export { MandateMethod, MandateStatus } from './data/customers/mandates/data'; +export { type MandateDetails, type MandateDetailsCreditCard, type MandateDetailsDirectDebit, MandateMethod, MandateStatus } from './data/customers/mandates/data'; export { MethodImageSize, MethodInclude } from './data/methods/data'; export { OrderEmbed, OrderStatus } from './data/orders/data'; export { OrderLineType } from './data/orders/orderlines/OrderLine'; From 6e1ed757f61f9a05847fe0aae94219a168b91532 Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Thu, 26 Sep 2024 13:30:04 +0200 Subject: [PATCH 3/8] fix property types --- src/data/payments/PaymentHelper.ts | 3 +-- src/data/payments/data.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/data/payments/PaymentHelper.ts b/src/data/payments/PaymentHelper.ts index 3465105a..79b4998b 100644 --- a/src/data/payments/PaymentHelper.ts +++ b/src/data/payments/PaymentHelper.ts @@ -12,7 +12,6 @@ import { type ThrottlingParameter } from '../../types/parameters'; import Helper from '../Helper'; import type Chargeback from '../chargebacks/Chargeback'; import { type ChargebackData } from '../chargebacks/Chargeback'; -import { SequenceType, type Amount } from '../global'; import type Order from '../orders/Order'; import { type OrderData } from '../orders/data'; import type Refund from '../refunds/Refund'; @@ -20,7 +19,7 @@ import { type RefundData } from '../refunds/data'; import type Payment from './Payment'; import type Capture from './captures/Capture'; import { type CaptureData } from './captures/data'; -import { PaymentStatus, type BankTransferLinks, type PaymentData } from './data'; +import { type BankTransferLinks, type PaymentData } from './data'; export default class PaymentHelper extends Helper { constructor(networkClient: TransformingNetworkClient, protected readonly links: PaymentData['_links'], protected readonly embedded: Payment['_embedded']) { diff --git a/src/data/payments/data.ts b/src/data/payments/data.ts index 39041ab3..be2f4b2e 100644 --- a/src/data/payments/data.ts +++ b/src/data/payments/data.ts @@ -185,13 +185,13 @@ export interface PaymentData extends Model<'payment'> { * * Possible values: `... hours`, `... days` */ - captureDelay?: number; + captureDelay?: string; /** * **Only relevant if you wish to manage authorization and capturing separately.** * * Indicates the date before which the payment needs to be captured, in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. From this date onwards we can no longer guarantee a successful capture. The parameter is omitted if the payment is not authorized (yet). */ - captureBefore?: number; + captureBefore?: string; /** * The customer's locale, either forced on creation by specifying the `locale` parameter, or detected by us during checkout. Will be a full locale, for example `nl_NL`. * From 783d195ac0a114c3f8b99a208d6e555ecaaab277 Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Thu, 26 Sep 2024 13:38:49 +0200 Subject: [PATCH 4/8] add tests for captures --- tests/integration/payments.test.ts | 34 +++++++++- .../unit/resources/payments/captures.test.ts | 66 +++++++++++++------ 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/tests/integration/payments.test.ts b/tests/integration/payments.test.ts index 06c1c7c6..f5e58644 100644 --- a/tests/integration/payments.test.ts +++ b/tests/integration/payments.test.ts @@ -1,7 +1,8 @@ import dotenv from 'dotenv'; import { fail } from 'node:assert'; -import createMollieClient, { PaymentStatus } from '../..'; +import createMollieClient, { CaptureMethod, PaymentMethod, PaymentStatus } from '../..'; +import getHead from '../getHead'; /** * Load the API_KEY environment variable @@ -121,4 +122,35 @@ describe('payments', () => { expect(payments.length).toEqual(2); expect(payments[0].id).toEqual(nextPageCursor); }); + + it.skip('should create a capture', async () => { + // Create a payment. + const payment = await mollieClient.payments.create({ + amount: { value: '10.00', currency: 'EUR' }, + description: 'Original description', + redirectUrl: 'https://example.com/redirect', + captureMode: CaptureMethod.manual, + method: PaymentMethod.creditcard, + }); + expect(payment.captureDelay).toBeUndefined(); + expect(payment.captureMode).toBe('manual'); + expect(payment.authorizedAt).toBeUndefined(); + + expect(payment.captureBefore).toBeUndefined(); + + // TODO: the payment needs to be authorized here, but there doesn't seem to be a way to do this currently... + + payment.refresh(); + expect(payment.captureBefore).not.toBeUndefined(); + + // Create a capture for this payment. + const capture = await mollieClient.paymentCaptures.create({ + paymentId: payment.id, + amount: { value: '10.00', currency: 'EUR' }, + }); + // check if the capture was created and assigned to the payment. + payment.refresh(); + const captureOnPayment = await getHead(payment.getCaptures()); + expect(capture.id).toBe(captureOnPayment.id); + }); }); diff --git a/tests/unit/resources/payments/captures.test.ts b/tests/unit/resources/payments/captures.test.ts index 17c9e05a..d34bf3e9 100644 --- a/tests/unit/resources/payments/captures.test.ts +++ b/tests/unit/resources/payments/captures.test.ts @@ -1,3 +1,4 @@ +import { CaptureStatus } from '../../../..'; import NetworkMocker, { getApiKeyClientProvider } from '../../../NetworkMocker'; function composeCaptureResponse(paymentId = 'tr_WDqYK6vllg', captureId = 'cpt_4qqhO89gsT') { @@ -5,6 +6,7 @@ function composeCaptureResponse(paymentId = 'tr_WDqYK6vllg', captureId = 'cpt_4q resource: 'capture', id: captureId, mode: 'live', + description: 'Capture for cart #12345', amount: { value: '1027.99', currency: 'EUR', @@ -13,6 +15,8 @@ function composeCaptureResponse(paymentId = 'tr_WDqYK6vllg', captureId = 'cpt_4q value: '399.00', currency: 'EUR', }, + status: CaptureStatus.pending, + metadata: '{"bookkeeping_id":12345}', paymentId: paymentId, shipmentId: 'shp_3wmsgCJN4U', settlementId: 'stl_jDk30akdN', @@ -42,26 +46,30 @@ function composeCaptureResponse(paymentId = 'tr_WDqYK6vllg', captureId = 'cpt_4q }; } -function testCapture(capture) { +function testCapture(capture, paymentId = 'tr_WDqYK6vllg', captureId = 'cpt_4qqhO89gsT') { expect(capture.resource).toBe('capture'); - expect(capture.id).toBe('cpt_4qqhO89gsT'); + expect(capture.id).toBe(captureId); expect(capture.mode).toBe('live'); - expect(capture.paymentId).toBe('tr_WDqYK6vllg'); + expect(capture.description).toBe('Capture for cart #12345'); + expect(capture.paymentId).toBe(paymentId); expect(capture.shipmentId).toBe('shp_3wmsgCJN4U'); expect(capture.settlementId).toBe('stl_jDk30akdN'); expect(capture.amount).toEqual({ value: '1027.99', currency: 'EUR' }); expect(capture.settlementAmount).toEqual({ value: '399.00', currency: 'EUR' }); + expect(capture.status).toBe('pending'); + expect(capture.metadata).toBe('{"bookkeeping_id":12345}'); + expect(capture.createdAt).toBe('2018-08-02T09:29:56+00:00'); expect(capture._links.self).toEqual({ - href: 'https://api.mollie.com/v2/payments/tr_WDqYK6vllg/captures/cpt_4qqhO89gsT', + href: `https://api.mollie.com/v2/payments/${paymentId}/captures/${captureId}`, type: 'application/hal+json', }); expect(capture._links.payment).toEqual({ - href: 'https://api.mollie.com/v2/payments/tr_WDqYK6vllg', + href: `https://api.mollie.com/v2/payments/${paymentId}`, type: 'application/hal+json', }); @@ -81,36 +89,52 @@ function testCapture(capture) { }); } +test('createCapture', () => { + return new NetworkMocker(getApiKeyClientProvider()).use(async ([mollieClient, networkMocker]) => { + networkMocker.intercept('POST', '/payments/tr_WDqYK6vllg/captures', 200, composeCaptureResponse('tr_7UhSN1zuXS', 'cpt_mNepDkEtco6ah3QNPUGYH')).twice(); + + const capture = await bluster(mollieClient.paymentCaptures.create.bind(mollieClient.paymentCaptures))({ paymentId: 'tr_WDqYK6vllg' }); + + testCapture(capture, 'tr_7UhSN1zuXS', 'cpt_mNepDkEtco6ah3QNPUGYH'); + }); +}); + test('getCapture', () => { return new NetworkMocker(getApiKeyClientProvider()).use(async ([mollieClient, networkMocker]) => { networkMocker.intercept('GET', '/payments/tr_WDqYK6vllg/captures/cpt_4qqhO89gsT', 200, composeCaptureResponse('tr_WDqYK6vllg', 'cpt_4qqhO89gsT')).twice(); const capture = await bluster(mollieClient.paymentCaptures.get.bind(mollieClient.paymentCaptures))('cpt_4qqhO89gsT', { paymentId: 'tr_WDqYK6vllg' }); + expect(typeof capture.getPayment).toBe('function'); + expect(typeof capture.getSettlement).toBe('function'); + expect(typeof capture.getShipment).toBe('function'); + testCapture(capture); }); }); test('listCaptures', () => { return new NetworkMocker(getApiKeyClientProvider()).use(async ([mollieClient, networkMocker]) => { - networkMocker.intercept('GET', '/payments/tr_WDqYK6vllg/captures', 200, { - _embedded: { - captures: [composeCaptureResponse('tr_WDqYK6vllg', 'cpt_4qqhO89gsT')], - }, - count: 1, - _links: { - documentation: { - href: 'https://docs.mollie.com/reference/v2/captures-api/list-captures', - type: 'text/html', + networkMocker + .intercept('GET', '/payments/tr_WDqYK6vllg/captures', 200, { + _embedded: { + captures: [composeCaptureResponse('tr_WDqYK6vllg', 'cpt_4qqhO89gsT')], }, - self: { - href: 'https://api.mollie.dev/v2/payments/tr_WDqYK6vllg/captures?limit=50', - type: 'application/hal+json', + count: 1, + _links: { + documentation: { + href: 'https://docs.mollie.com/reference/v2/captures-api/list-captures', + type: 'text/html', + }, + self: { + href: 'https://api.mollie.dev/v2/payments/tr_WDqYK6vllg/captures?limit=50', + type: 'application/hal+json', + }, + previous: null, + next: null, }, - previous: null, - next: null, - }, - }).twice(); + }) + .twice(); const captures = await bluster(mollieClient.paymentCaptures.page.bind(mollieClient.paymentCaptures))({ paymentId: 'tr_WDqYK6vllg' }); From 8ba1531401125a474df04a3fd8d6a0fe4b28a512 Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Thu, 26 Sep 2024 16:13:18 +0200 Subject: [PATCH 5/8] update integration tests to include full flow capture option --- tests/integration/payments.test.ts | 90 ++++++++++++++++++------------ 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/tests/integration/payments.test.ts b/tests/integration/payments.test.ts index f5e58644..6efc30d4 100644 --- a/tests/integration/payments.test.ts +++ b/tests/integration/payments.test.ts @@ -15,29 +15,26 @@ describe('payments', () => { it('should integrate', async () => { const payments = await mollieClient.payments.page(); - let paymentExists; + const existingPayment = payments.find(payment => payment.metadata == 'refund-test' && payment.status == PaymentStatus.paid); - if (!payments.length || payments[0].status == PaymentStatus.expired) { - paymentExists = mollieClient.payments + const payment = + existingPayment ?? + (await mollieClient.payments .create({ amount: { value: '10.00', currency: 'EUR' }, description: 'Integration test payment', redirectUrl: 'https://example.com/redirect', + method: PaymentMethod.creditcard, // we want the amount to be immediately refundable, which is not the case for all payment methods + metadata: 'refund-test', }) .then(payment => { expect(payment).toBeDefined(); - return payment; }) - .catch(fail); - } else { - paymentExists = Promise.resolve(payments[0]); - } - - const payment = await paymentExists; + .catch(fail)); if (payment.status != PaymentStatus.paid) { - console.log('If you want to test the full flow, set the payment to paid:', payment.getCheckoutUrl()); + console.log('If you want to test the full refund flow, set the payment to paid:', payment.getCheckoutUrl()); return; } @@ -53,8 +50,8 @@ describe('payments', () => { if (!paymentRefunds.length) { refundExists = mollieClient.paymentRefunds .create({ - paymentId: payments[0].id, - amount: { value: '5.00', currency: payments[0].amount.currency }, + paymentId: payment.id, + amount: { value: '5.00', currency: payment.amount.currency }, }) .then(refund => { expect(refund).toBeDefined(); @@ -70,7 +67,7 @@ describe('payments', () => { await mollieClient.paymentRefunds .get(paymentRefund.id, { - paymentId: payments[0].id, + paymentId: payment.id, }) .then(result => { expect(result).toBeDefined(); @@ -123,34 +120,55 @@ describe('payments', () => { expect(payments[0].id).toEqual(nextPageCursor); }); - it.skip('should create a capture', async () => { - // Create a payment. - const payment = await mollieClient.payments.create({ - amount: { value: '10.00', currency: 'EUR' }, - description: 'Original description', - redirectUrl: 'https://example.com/redirect', - captureMode: CaptureMethod.manual, - method: PaymentMethod.creditcard, - }); - expect(payment.captureDelay).toBeUndefined(); - expect(payment.captureMode).toBe('manual'); - expect(payment.authorizedAt).toBeUndefined(); + it('should capture a payment', async () => { + const payments = await mollieClient.payments.page(); - expect(payment.captureBefore).toBeUndefined(); + const existingPayment = payments.find(payment => payment.metadata == 'capture-test' && payment.status == PaymentStatus.authorized); - // TODO: the payment needs to be authorized here, but there doesn't seem to be a way to do this currently... + const payment = + existingPayment ?? + (await mollieClient.payments + .create({ + amount: { value: '10.00', currency: 'EUR' }, + description: 'Integration test payment', + redirectUrl: 'https://example.com/redirect', + metadata: 'capture-test', + 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)); + + if (payment.status != PaymentStatus.authorized) { + console.log('If you want to test the full authorize-then-capture flow, set the payment to authorized:', payment.getCheckoutUrl()); + return; + } - payment.refresh(); - expect(payment.captureBefore).not.toBeUndefined(); + expect(payment.authorizedAt).toBeDefined(); + expect(payment.captureBefore).toBeDefined(); // Create a capture for this payment. - const capture = await mollieClient.paymentCaptures.create({ - paymentId: payment.id, - amount: { value: '10.00', currency: 'EUR' }, - }); + const capture = await mollieClient.paymentCaptures + .create({ + paymentId: payment.id, + amount: { value: '10.00', currency: 'EUR' }, + }) + .then(capture => { + expect(capture).toBeDefined(); + return capture; + }) + .catch(fail); // check if the capture was created and assigned to the payment. - payment.refresh(); - const captureOnPayment = await getHead(payment.getCaptures()); + const updatedPayment = await payment.refresh(); + const captureOnPayment = await getHead(updatedPayment.getCaptures()); expect(capture.id).toBe(captureOnPayment.id); }); }); From 5ffef069bb5c1a210a8569621a584e80556adf57 Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Fri, 27 Sep 2024 11:44:57 +0200 Subject: [PATCH 6/8] fix docs --- src/data/payments/captures/CaptureHelper.ts | 2 +- src/data/payments/captures/data.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/data/payments/captures/CaptureHelper.ts b/src/data/payments/captures/CaptureHelper.ts index 1fb594f4..c954bf1a 100644 --- a/src/data/payments/captures/CaptureHelper.ts +++ b/src/data/payments/captures/CaptureHelper.ts @@ -44,7 +44,7 @@ export default class CaptureHelper extends Helper { } /** - * Returns the shipment this capture has been settled with (if any). + * Returns the settlement this capture has been settled with (if any). * * @since 4.1.0 */ diff --git a/src/data/payments/captures/data.ts b/src/data/payments/captures/data.ts index b8d881b5..43047f9b 100644 --- a/src/data/payments/captures/data.ts +++ b/src/data/payments/captures/data.ts @@ -18,7 +18,7 @@ export interface CaptureData extends Model<'capture'> { */ description: string; /** - * The amount captured. + * The amount captured. If no amount is provided, the full authorized amount is captured. * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=amount#response */ @@ -26,6 +26,8 @@ export interface CaptureData extends Model<'capture'> { /** * This optional field will contain the approximate amount that will be settled to your account, converted to the currency your account is settled in. * + * Since the field contains an estimated amount during capture processing, it may change over time. To retrieve accurate settlement amounts we recommend using the List balance transactions endpoint instead. + * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=settlementAmount#response */ settlementAmount: Amount; @@ -33,14 +35,15 @@ export interface CaptureData extends Model<'capture'> { * The capture's status. * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=status#response - */ + */ status: CaptureStatus; /** - * The capture's status. + * Provide any data you like, for example a string or a JSON object. We will save the data alongside the entity. Whenever you fetch the entity with our API, we will also include the metadata. + * You can use up to approximately 1kB. * * @see https://docs.mollie.com/reference/v2/captures-api/get-capture?path=metadata#response - */ - metadata: any; + */ + metadata: unknown; /** * The unique identifier of the payment this capture was created for, for example: `tr_7UhSN1zuXS`. The full payment object can be retrieved via the `payment` URL in the `_links` object. * From 7ee15ea7004db9fa580a8a7ea08afcfaa9467358 Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Fri, 27 Sep 2024 13:05:25 +0200 Subject: [PATCH 7/8] improve integration tests as per https://github.com/mollie/mollie-api-node/pull/371#discussion_r1778343937 --- tests/integration/payments.test.ts | 163 +++++++++++++++-------------- 1 file changed, 84 insertions(+), 79 deletions(-) diff --git a/tests/integration/payments.test.ts b/tests/integration/payments.test.ts index 6efc30d4..a2f02eaf 100644 --- a/tests/integration/payments.test.ts +++ b/tests/integration/payments.test.ts @@ -13,61 +13,56 @@ const mollieClient = createMollieClient({ apiKey: process.env.API_KEY }); describe('payments', () => { it('should integrate', async () => { - const payments = await mollieClient.payments.page(); - - const existingPayment = payments.find(payment => payment.metadata == 'refund-test' && payment.status == PaymentStatus.paid); - - const payment = - existingPayment ?? - (await mollieClient.payments - .create({ - amount: { value: '10.00', currency: 'EUR' }, - description: 'Integration test payment', - redirectUrl: 'https://example.com/redirect', - method: PaymentMethod.creditcard, // we want the amount to be immediately refundable, which is not the case for all payment methods - metadata: 'refund-test', - }) - .then(payment => { - expect(payment).toBeDefined(); - return payment; - }) - .catch(fail)); - - if (payment.status != PaymentStatus.paid) { - console.log('If you want to test the full refund flow, set the payment to paid:', payment.getCheckoutUrl()); - return; - } + /** + * This test will + * - check if a refundable payment created by this test exists (verified using metadata) + * - if yes: refund the payment - 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 `paid` to be able to test the full flow + * - exit the test + */ + const metaIdentifier = 'refund-test'; - if (!payment.isRefundable()) { - console.log('This payment is not refundable, you cannot test the full flow.'); + const payments = await mollieClient.payments.page(); + const refundPayments = payments.filter(payment => payment.metadata == metaIdentifier); + const refundablePayment = refundPayments.find(payment => payment.status == PaymentStatus.paid && payment.amountRemaining != null && parseFloat(payment.amountRemaining.value) > 0); + + if (null == refundablePayment) { + const openPayment = + refundPayments.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', + method: PaymentMethod.creditcard, // we want the amount to be immediately refundable, which is not the case for all payment methods + metadata: metaIdentifier, + }) + .then(payment => { + expect(payment).toBeDefined(); + return payment; + }) + .catch(fail)); + console.log('If you want to test the full refund flow, set the payment to paid:', openPayment.getCheckoutUrl()); return; } - const paymentRefunds = await mollieClient.paymentRefunds.page({ paymentId: payment.id }); - - let refundExists; - - if (!paymentRefunds.length) { - refundExists = mollieClient.paymentRefunds - .create({ - paymentId: payment.id, - amount: { value: '5.00', currency: payment.amount.currency }, - }) - .then(refund => { - expect(refund).toBeDefined(); - - return refund; - }) - .catch(fail); - } else { - refundExists = Promise.resolve(paymentRefunds[0]); - } + const paymentRefund = await mollieClient.paymentRefunds + .create({ + paymentId: refundablePayment.id, + amount: refundablePayment.amountRemaining, + }) + .then(refund => { + expect(refund).toBeDefined(); - const paymentRefund = await refundExists; + return refund; + }) + .catch(fail); await mollieClient.paymentRefunds .get(paymentRefund.id, { - paymentId: payment.id, + paymentId: refundablePayment.id, }) .then(result => { expect(result).toBeDefined(); @@ -121,45 +116,55 @@ describe('payments', () => { }); it('should capture a payment', async () => { - const payments = await mollieClient.payments.page(); + /** + * This test will + * - check if a capturable payment created by this test exists (verified using metadata) + * - if yes: capure the payment - 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 = 'capture-test'; - const existingPayment = payments.find(payment => payment.metadata == 'capture-test' && payment.status == PaymentStatus.authorized); - - const payment = - existingPayment ?? - (await mollieClient.payments - .create({ - amount: { value: '10.00', currency: 'EUR' }, - description: 'Integration test payment', - redirectUrl: 'https://example.com/redirect', - metadata: 'capture-test', - 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)); - - if (payment.status != PaymentStatus.authorized) { - console.log('If you want to test the full authorize-then-capture flow, set the payment to authorized:', payment.getCheckoutUrl()); + const payments = await mollieClient.payments.page(); + const refundPayments = payments.filter(payment => payment.metadata == metaIdentifier); + const authorizedPayment = refundPayments.find(payment => payment.status == PaymentStatus.authorized); + + if (null == authorizedPayment) { + const openPayment = + refundPayments.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: 'capture-test', + 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 full authorize-then-capture flow, set the payment to authorized:', openPayment.getCheckoutUrl()); return; } - expect(payment.authorizedAt).toBeDefined(); - expect(payment.captureBefore).toBeDefined(); + expect(authorizedPayment.authorizedAt).toBeDefined(); + expect(authorizedPayment.captureBefore).toBeDefined(); // Create a capture for this payment. const capture = await mollieClient.paymentCaptures .create({ - paymentId: payment.id, - amount: { value: '10.00', currency: 'EUR' }, + paymentId: authorizedPayment.id, + amount: authorizedPayment.amount, }) .then(capture => { expect(capture).toBeDefined(); @@ -167,7 +172,7 @@ describe('payments', () => { }) .catch(fail); // check if the capture was created and assigned to the payment. - const updatedPayment = await payment.refresh(); + const updatedPayment = await authorizedPayment.refresh(); const captureOnPayment = await getHead(updatedPayment.getCaptures()); expect(capture.id).toBe(captureOnPayment.id); }); From 9b065b7d95d769c92e9ad362fc2b6715a7fd2aaf Mon Sep 17 00:00:00 2001 From: Jan Paepke Date: Fri, 27 Sep 2024 13:10:59 +0200 Subject: [PATCH 8/8] use assertion --- src/binders/payments/captures/PaymentCapturesBinder.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/binders/payments/captures/PaymentCapturesBinder.ts b/src/binders/payments/captures/PaymentCapturesBinder.ts index 60417515..6290fb38 100644 --- a/src/binders/payments/captures/PaymentCapturesBinder.ts +++ b/src/binders/payments/captures/PaymentCapturesBinder.ts @@ -30,9 +30,7 @@ export default class PaymentCapturesBinder extends Binder public create(parameters: CreateParameters) { if (renege(this, this.create, ...arguments)) return; const { paymentId, ...data } = parameters; - if (!checkId(paymentId, 'payment')) { - throw new ApiError('The payment id is invalid'); - } + assertWellFormedId(paymentId, 'payment'); return this.networkClient.post(getPathSegments(paymentId), data); }