diff --git a/CHANGELOG.md b/CHANGELOG.md index 072ea106f9..08dad0f49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [v6.67.0](https://github.com/opengovsg/FormSG/compare/v6.66.0...v6.67.0) +#### [v6.67.1](https://github.com/opengovsg/FormSG/compare/v6.67.0...v6.67.1) + +- fix: paynow payout being ignored [`#6556`](https://github.com/opengovsg/FormSG/pull/6556) +- build: release v6.67.0 [`#6555`](https://github.com/opengovsg/FormSG/pull/6555) + +#### [v6.67.0](https://github.com/opengovsg/FormSG/compare/v6.66.1...v6.67.0) + +> 20 July 2023 - feat: experimental login integration with SGID [`#6552`](https://github.com/opengovsg/FormSG/pull/6552) - build: merge v6.66.0 into develop [`#6554`](https://github.com/opengovsg/FormSG/pull/6554) @@ -17,11 +24,23 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump word-wrap from 1.2.3 to 1.2.4 [`#6547`](https://github.com/opengovsg/FormSG/pull/6547) - build(deps): bump winston-cloudwatch to v6.2.0 [`#6545`](https://github.com/opengovsg/FormSG/pull/6545) - chore: bump version to v6.66.0 [`0233ba8`](https://github.com/opengovsg/FormSG/commit/0233ba8018532d4978d13717fac065f81e2eb515) +- chore: bump version to v6.67.0 [`6ca03c6`](https://github.com/opengovsg/FormSG/commit/6ca03c68cb162a4ae3af01f172425b3305742ae0) -#### [v6.66.0](https://github.com/opengovsg/FormSG/compare/v6.65.0...v6.66.0) +#### [v6.66.1](https://github.com/opengovsg/FormSG/compare/v6.66.0...v6.66.1) > 18 July 2023 +- revert: build(deps): bump winston-cloudwatch version to 6.2.0 [`#6542`](https://github.com/opengovsg/FormSG/pull/6542) +- chore: bump version to v6.66.0 [`b3a9818`](https://github.com/opengovsg/FormSG/commit/b3a9818762e334bc7126c029cdba63156bac85ed) + +#### [v6.66.0](https://github.com/opengovsg/FormSG/compare/v6.65.0...v6.66.0) + +> 19 July 2023 + +- fix: use proof-of-payment for payment receipt/invoice [`#6549`](https://github.com/opengovsg/FormSG/pull/6549) +- build(deps-dev): bump word-wrap from 1.2.3 to 1.2.4 in /frontend [`#6548`](https://github.com/opengovsg/FormSG/pull/6548) +- fix(deps): bump word-wrap from 1.2.3 to 1.2.4 [`#6547`](https://github.com/opengovsg/FormSG/pull/6547) +- build(deps): bump winston-cloudwatch to v6.2.0 [`#6545`](https://github.com/opengovsg/FormSG/pull/6545) - fix(deps): [Snyk] Security upgrade mongoose from 5.13.15 to 5.13.20 [`#6541`](https://github.com/opengovsg/FormSG/pull/6541) - feat: indicate if GST has been applied to a payment transaction [`#6538`](https://github.com/opengovsg/FormSG/pull/6538) - chore: reduce max payment limit [`#6543`](https://github.com/opengovsg/FormSG/pull/6543) @@ -33,7 +52,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge v6.65.0 into develop [`#6531`](https://github.com/opengovsg/FormSG/pull/6531) - build: release v6.65.0 [`#6528`](https://github.com/opengovsg/FormSG/pull/6528) - fix: add FE validation rule that email domains must be non empty [`#6529`](https://github.com/opengovsg/FormSG/pull/6529) -- chore: bump version to v6.66.0 [`b3a9818`](https://github.com/opengovsg/FormSG/commit/b3a9818762e334bc7126c029cdba63156bac85ed) +- chore: bump version to v6.66.0 [`0233ba8`](https://github.com/opengovsg/FormSG/commit/0233ba8018532d4978d13717fac065f81e2eb515) #### [v6.65.0](https://github.com/opengovsg/FormSG/compare/v6.64.0...v6.65.0) diff --git a/package-lock.json b/package-lock.json index f4086cb529..64c6d539ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.67.0", + "version": "6.67.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.67.0", + "version": "6.67.1", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index 7e9c1c4c29..a76d5a5a83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.67.0", + "version": "6.67.1", "homepage": "https://form.gov.sg", "authors": [ "FormSG " diff --git a/src/app/modules/payments/__tests__/stripe.service.spec.ts b/src/app/modules/payments/__tests__/stripe.service.spec.ts index 6c46c7a530..f0f1c9f826 100644 --- a/src/app/modules/payments/__tests__/stripe.service.spec.ts +++ b/src/app/modules/payments/__tests__/stripe.service.spec.ts @@ -240,6 +240,40 @@ const MOCK_STRIPE_EVENTS = [ }, type: 'payout.canceled', }, + { + id: 'evt_PAYOUT_PAID', + object: 'event', + created: 1688972601, + account: 'acct_MOCK_ACCOUNT_ID', + data: { + object: { + id: 'po_1NS4j4CJpScV6kYOpH4bAqkr', + object: 'payout', + amount: 161, + arrival_date: 1688947200, + automatic: true, + balance_transaction: 'txn_1NS4j4CJpScV6kYO6tdVcHWj', + created: 1688936462, + currency: 'sgd', + description: 'STRIPE PAYOUT', + destination: 'ba_1MogcfCJpScV6kYOtuuO39wX', + failure_balance_transaction: null, + failure_code: null, + failure_message: null, + livemode: true, + metadata: {}, + method: 'standard', + original_payout: null, + reconciliation_status: 'completed', + reversed_by: null, + source_type: 'card', + statement_descriptor: null, + status: 'paid', + type: 'bank_account', + }, + }, + type: 'payout.paid', + }, ] as unknown as Stripe.DiscriminatedEvent[] const MOCK_STRIPE_EVENTS_MAP = keyBy(MOCK_STRIPE_EVENTS, 'id') @@ -812,6 +846,201 @@ describe('stripe.service', () => { expect(processStripeEventSpy).toHaveBeenCalledOnce() expect(result.isOk()).toBeTrue() }) + + describe('with event.type of payout.paid', () => { + const chargesResponseType = { + type: 'charge', + amount: 53, + status: 'succeeded', + source: { + object: 'charge', + amount: 53, + amount_captured: 53, + payment_method_details: { + card: { + brand: 'visa', + checks: { + address_line1_check: null, + address_postal_code_check: null, + cvc_check: 'pass', + }, + country: 'SG', + exp_month: 12, + exp_year: 2024, + fingerprint: 'fingerprint', + funding: 'prepaid', + installments: null, + last4: '1234', + mandate: null, + network: 'visa', + network_token: { + used: false, + }, + three_d_secure: null, + wallet: null, + }, + type: 'card', + }, + }, + } as Stripe.BalanceTransaction + + const paymentResponseType = { + type: 'payment', + amount: 110, + status: 'succeeded', + source: { + id: 'py_3NR4JHCJpScV6kYO1UnwVvkM', + object: 'charge', + amount: 110, + amount_captured: 110, + payment_method_details: { + paynow: { + reference: '3NR4JHCJpScV6kYO1IZnPfoW', + }, + type: 'paynow', + }, + }, + } as Stripe.BalanceTransaction + + const payoutResponseType = { + type: 'payout', + amount: -161, + status: 'available', + source: { + id: 'po_1NS4j4CJpScV6kYOpH4bAqkr', + object: 'payout', + amount: 161, + arrival_date: 1688947200, + automatic: true, + balance_transaction: 'txn_1NS4j4CJpScV6kYO6tdVcHWj', + created: 1688936462, + currency: 'sgd', + description: 'STRIPE PAYOUT', + destination: 'ba_DESTINATION', + failure_balance_transaction: null, + failure_code: null, + failure_message: null, + livemode: true, + metadata: {}, + method: 'standard', + original_payout: null, + reconciliation_status: 'completed', + reversed_by: null, + source_type: 'card', + statement_descriptor: null, + status: 'paid', + type: 'bank_account', + }, + } as Stripe.BalanceTransaction + + let createBalanceTransactionApiSpy: ( + balanceTransactions: Array, + ) => jest.SpyInstance> + let getMetadataPaymentIdSpy: jest.SpyInstance + let processStripeEventSpy: jest.SpyInstance + + beforeEach(() => { + createBalanceTransactionApiSpy = ( + balanceTransactionResponses: Array, + ) => + jest + .spyOn(stripe.balanceTransactions, 'list') + .mockImplementationOnce( + () => + ({ + autoPagingEach: (fn) => { + balanceTransactionResponses.forEach((resp) => { + if (!fn(resp)) return Promise.reject('fail case') + }) + return Promise.resolve('pass') + }, + } as unknown as Stripe.ApiListPromise), + ) + getMetadataPaymentIdSpy = jest + .spyOn(StripeUtils, 'getMetadataPaymentId') + .mockImplementation(() => ok('still gud')) + processStripeEventSpy = jest + .spyOn(StripeService, 'processStripeEvent') + .mockImplementationOnce(() => okAsync(undefined)) + }) + + it('should not process payout transactions', async () => { + // Arrange + const balanceTransactionResponses = [ + payoutResponseType, + payoutResponseType, + ] + const balanceTransactionApiSpy = createBalanceTransactionApiSpy( + balanceTransactionResponses, + ) + + // Act + const result = await StripeService.handleStripeEvent( + MOCK_STRIPE_EVENTS_MAP['evt_PAYOUT_PAID'], + ) + + // Assert + expect(balanceTransactionApiSpy).toHaveBeenCalledOnce() + expect(getMetadataPaymentIdSpy).toHaveBeenCalledTimes(0) + expect(processStripeEventSpy).toHaveBeenCalledTimes(0) + expect(result.isOk()).toBeTrue() + }) + + it('should ignore only payout transactions', async () => { + // Arrange + const balanceTransactionResponses = [ + payoutResponseType, + chargesResponseType, + paymentResponseType, + ] + const balanceTransactionApiSpy = createBalanceTransactionApiSpy( + balanceTransactionResponses, + ) + // excluding payout type + const expectedCallCount = balanceTransactionResponses.filter( + (resp) => resp.type !== 'payout', + ).length + + // Act + const result = await StripeService.handleStripeEvent( + MOCK_STRIPE_EVENTS_MAP['evt_PAYOUT_PAID'], + ) + + // Assert + expect(balanceTransactionApiSpy).toHaveBeenCalledOnce() + expect(getMetadataPaymentIdSpy).toHaveBeenCalledTimes( + expectedCallCount, + ) + expect(processStripeEventSpy).toHaveBeenCalledTimes(expectedCallCount) + expect(result.isOk()).toBeTrue() + }) + + it('should process all bank cards and paynow transactions', async () => { + // Arrange + const balanceTransactionResponses = [ + chargesResponseType, + paymentResponseType, + ] + const balanceTransactionApiSpy = createBalanceTransactionApiSpy( + balanceTransactionResponses, + ) + + // Act + const result = await StripeService.handleStripeEvent( + MOCK_STRIPE_EVENTS_MAP['evt_PAYOUT_PAID'], + ) + + // Assert + expect(balanceTransactionApiSpy).toHaveBeenCalledOnce() + expect(getMetadataPaymentIdSpy).toHaveBeenCalledTimes( + balanceTransactionResponses.length, + ) + expect(processStripeEventSpy).toHaveBeenCalledTimes( + balanceTransactionResponses.length, + ) + expect(result.isOk()).toBeTrue() + }) + }) }) }) }) diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index f24433a177..f09b7df421 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -559,7 +559,7 @@ export const handleStripeEvent = ( { stripeAccount: event.account }, ) .autoPagingEach(async (balanceTransaction) => { - if (balanceTransaction.type !== 'charge') return + if (!['charge', 'payment'].includes(balanceTransaction.type)) return const charge = balanceTransaction.source as Stripe.Charge await getMetadataPaymentId(charge.metadata)