Files
payments/test/e2e/api_payment_initiations_test.go
fgueryandGitHub a63e3a20a8 feat(EN-214): create events via storage instead of manually calling event activity (#594)
* feat(EN-214): add outbox table migration

* feat(EN-214): add outbox models and storage layer

* feat(EN-214): implement outbox publisher activity

* feat(EN-214): implement outbox schedule activity

* feat(EN-214): add outbox publisher workflow

* feat(EN-214): migrate accounts to outbox pattern

* feat(EN-214): migrate balances to outbox pattern

* feat(EN-214): migrate payments to outbox pattern

* feat(EN-214): migrate bank accounts to outbox pattern

* feat(EN-214): migrate pools to outbox pattern

* feat(EN-214): migrate payment initiations to outbox pattern

* feat(EN-214): migrate PSU and connections to outbox pattern

* feat(EN-214): migrate connectors to outbox pattern

* feat(EN-214): remove legacy send_events workflow

* feat(EN-214): update workflows to use outbox pattern

* feat(EN-214): improve outbox idempotency and error handling

* feat(EN-214): update storage tasks and tests

* test(EN-214): update e2e tests for outbox pattern

* test(EN-214): clean up workflow tests

* chore(EN-214): update docker compose configuration

* feat(EN-214): add e2e run config in ide

* fix: add back docker-compose-dev run config

* chore: improve connector uninstall comments & remove connector id being passed

* fix: ensure events are not pushed if balances are not created

* fix: ensure err is not overwritten when using rollback

* fix: ensure we use v3 provider format

* chore: apply the nitpick comments from coderabbit

* chore: consistentely use e() error handling

* feat: optionally disable publisher schedule to make e2e test more consistent

* fix: improve error handling on uninstall connector

* avoid using custom payload and rely on existing interfaces; also avoid using different types for events

* fix: remove unecessary unmarshalling

* fix: ensure that rollbackOnTxError picks up errors on tx.Commit()

* fix: add extra validation on iban & account number in the test

* test: cover marshall/unmarshalling of event payload

* fix: increase test timeout

* try to make test more resiliant

* fix: wrong pointer use in loop

* fix: error formatting issue

* fix: move oubtox schedule creation out of an activity and do it in the worker itself

* feat: OutboxEventDeleteAndSend as batch

* change outbox_event to have id only instead of idempotency key

* make e2e test less brittle, save eventID as idempotency key in publication

* feat: make outbox_event keep an history

* feat(EN-214): added a schedule to cleanup outbox events

* test: make cleanup actually clean

* ignore linting rules for now

* feat: do not add psu metadata to event anymore as it's irrelevant

* fix: create local variable instead of relying on pointers, even if not 100% necessary

* fix: remove temporal schedule describe calls
2025-11-24 16:05:42 +00:00

331 lines
15 KiB
Go

//go:build it
package test_suite
import (
"math/big"
"time"
"github.com/formancehq/go-libs/v3/logging"
"github.com/formancehq/go-libs/v3/pointer"
"github.com/formancehq/go-libs/v3/testing/deferred"
"github.com/formancehq/payments/internal/connectors/engine/workflow"
"github.com/formancehq/payments/internal/models"
"github.com/formancehq/payments/pkg/client/models/components"
"github.com/formancehq/payments/pkg/events"
"github.com/google/uuid"
. "github.com/formancehq/payments/pkg/testserver"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Context("Payments API Payment Initiation", Serial, func() {
var (
db = UseTemplatedDatabase()
ctx = logging.TestingContext()
app *deferred.Deferred[*Server]
)
app = NewTestServer(func() Configuration {
return Configuration{
Stack: stack,
PostgresConfiguration: db.GetValue().ConnectionOptions(),
NatsURL: natsServer.GetValue().ClientURL(),
TemporalNamespace: temporalServer.GetValue().DefaultNamespace(),
TemporalAddress: temporalServer.GetValue().Address(),
Output: GinkgoWriter,
SkipOutboxScheduleCreation: true,
}
})
createdAt, _ := time.Parse("2006-Jan-02", "2024-Nov-29")
AfterEach(func() {
flushRemainingWorkflows(ctx)
})
When("initiating a new transfer with v3", func() {
var (
err error
debtorID string
creditorID string
payReq *components.V3InitiatePaymentRequest
paymentInitiationID string
connectorID string
)
BeforeEach(func() {
connectorID, err = installConnector(ctx, app.GetValue(), uuid.New(), 3)
Expect(err).To(BeNil())
debtorID, creditorID = setupDebtorAndCreditorV3Accounts(ctx, app.GetValue(), connectorID, createdAt)
payReq = &components.V3InitiatePaymentRequest{
Reference: uuid.New().String(),
ConnectorID: connectorID,
Description: "some description",
Type: "TRANSFER",
Amount: big.NewInt(3200),
Asset: "EUR/2",
SourceAccountID: &debtorID,
DestinationAccountID: &creditorID,
Metadata: map[string]string{"key": "val"},
}
createResponse, err := app.GetValue().SDK().Payments.V3.InitiatePayment(ctx, pointer.For(false), payReq)
Expect(err).To(BeNil())
Expect(createResponse.GetV3InitiatePaymentResponse().Data.TaskID).ToNot(BeNil())
Expect(*createResponse.GetV3InitiatePaymentResponse().Data.TaskID).To(Equal("")) // task empty when not sending to PSP
Expect(createResponse.GetV3InitiatePaymentResponse().Data.PaymentInitiationID).ToNot(BeNil()) // task nil when not sending to PSP
paymentInitiationID = *createResponse.GetV3InitiatePaymentResponse().Data.PaymentInitiationID
MustOutbox(ctx, app.GetValue(), events.EventTypeSavedPaymentInitiation)
var msg = struct {
PaymentInitiationID string `json:"paymentInitiationID"`
Status string `json:"status"`
}{
PaymentInitiationID: paymentInitiationID,
Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION.String(),
}
MustOutbox(ctx, app.GetValue(), events.EventTypeSavedPaymentInitiationAdjustment, WithPayloadSubset(msg))
})
AfterEach(func() {
uninstallConnector(ctx, app.GetValue(), connectorID)
})
It("can be processed", func() {
approveResponse, err := app.GetValue().SDK().Payments.V3.ApprovePaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(approveResponse.GetV3ApprovePaymentInitiationResponse().Data).NotTo(BeNil())
taskID, err := models.TaskIDFromString(approveResponse.GetV3ApprovePaymentInitiationResponse().Data.TaskID)
Expect(err).To(BeNil())
Expect(taskID.Reference).To(ContainSubstring("create-transfer"))
var paymentMsg = struct {
ConnectorID string `json:"connectorID"`
SourceAccountID string `json:"sourceAccountID,omitempty"`
DestinationAccountID string `json:"destinationAccountID,omitempty"`
}{
ConnectorID: connectorID,
SourceAccountID: debtorID,
DestinationAccountID: creditorID,
}
type PIAdjMsg struct {
PaymentInitiationID string `json:"paymentInitiationID"`
Status string `json:"status"`
}
processingPI := PIAdjMsg{
PaymentInitiationID: paymentInitiationID,
Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING.String(),
}
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPaymentInitiationAdjustment, WithPayloadSubset(processingPI))
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPayments, WithPayloadSubset(paymentMsg))
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPaymentInitiationRelatedPayment)
processedPI := PIAdjMsg{
PaymentInitiationID: paymentInitiationID,
Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED.String(),
}
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPaymentInitiationAdjustment, WithPayloadSubset(processedPI))
taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue())
Eventually(taskPoller(approveResponse.GetV3ApprovePaymentInitiationResponse().Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED))
getResponse, err := app.GetValue().SDK().Payments.V3.GetPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(string(getResponse.GetV3GetPaymentInitiationResponse().Data.Status)).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED.String()))
})
It("can be rejected", func() {
_, err := app.GetValue().SDK().Payments.V3.RejectPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
getResponse, err := app.GetValue().SDK().Payments.V3.GetPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(err).To(BeNil())
Expect(string(getResponse.GetV3GetPaymentInitiationResponse().Data.Status)).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED.String()))
})
It("cannot be reversed if the payment is unprocessed", func() {
reverseResponse, err := app.GetValue().SDK().Payments.V3.ReversePaymentInitiation(ctx, paymentInitiationID, &components.V3ReversePaymentInitiationRequest{
Reference: uuid.New().String(),
Description: payReq.Description,
Amount: payReq.Amount,
Asset: payReq.Asset,
Metadata: map[string]string{"reversal": "data"},
})
Expect(err).To(BeNil())
Expect(reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID).NotTo(BeNil())
taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue())
Eventually(taskPoller(*reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_FAILED, WithError(workflow.ErrPaymentInitiationNotProcessed)))
})
It("can be reversed", func() {
approveResponse, err := app.GetValue().SDK().Payments.V3.ApprovePaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
var msg = struct {
ConnectorID string `json:"connectorID"`
SourceAccountID string `json:"sourceAccountID,omitempty"`
DestinationAccountID string `json:"destinationAccountID,omitempty"`
}{
ConnectorID: connectorID,
SourceAccountID: debtorID,
DestinationAccountID: creditorID,
}
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPayments, WithPayloadSubset(msg))
taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue())
Eventually(taskPoller(approveResponse.GetV3ApprovePaymentInitiationResponse().Data.TaskID)).
WithTimeout(2 * time.Second).
Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED))
reverseResponse, err := app.GetValue().SDK().Payments.V3.ReversePaymentInitiation(ctx, paymentInitiationID, &components.V3ReversePaymentInitiationRequest{
Reference: uuid.New().String(),
Description: payReq.Description,
Amount: payReq.Amount,
Asset: payReq.Asset,
Metadata: map[string]string{"reversal": "data"},
})
Expect(err).To(BeNil())
Expect(reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID).NotTo(BeNil())
blockTillWorkflowComplete(ctx, connectorID, "reverse-transfer")
Eventually(taskPoller(*reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID)).
WithTimeout(2 * time.Second).
Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED))
getResponse, err := app.GetValue().SDK().Payments.V3.GetPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(string(getResponse.GetV3GetPaymentInitiationResponse().Data.Status)).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED.String()))
})
})
When("initiating a new payout with v3", func() {
var (
err error
debtorID string
creditorID string
payReq *components.V3InitiatePaymentRequest
paymentInitiationID string
connectorID string
)
BeforeEach(func() {
connectorID, err = installConnector(ctx, app.GetValue(), uuid.New(), 3)
Expect(err).To(BeNil())
debtorID, creditorID = setupDebtorAndCreditorV3Accounts(ctx, app.GetValue(), connectorID, createdAt)
payReq = &components.V3InitiatePaymentRequest{
Reference: uuid.New().String(),
ConnectorID: connectorID,
Description: "payout description",
Type: "PAYOUT",
Amount: big.NewInt(2233),
Asset: "EUR/2",
SourceAccountID: &debtorID,
DestinationAccountID: &creditorID,
Metadata: map[string]string{"pay": "out"},
}
createResponse, err := app.GetValue().SDK().Payments.V3.InitiatePayment(ctx, pointer.For(false), payReq)
Expect(err).To(BeNil())
Expect(createResponse.GetV3InitiatePaymentResponse().Data.TaskID).ToNot(BeNil()) // task nil when not sending to PSP
Expect(*createResponse.GetV3InitiatePaymentResponse().Data.TaskID).To(Equal("")) // task nil when not sending to PSP
Expect(createResponse.GetV3InitiatePaymentResponse().Data.PaymentInitiationID).ToNot(BeNil()) // task nil when not sending to PSP
paymentInitiationID = *createResponse.GetV3InitiatePaymentResponse().Data.PaymentInitiationID
})
AfterEach(func() {
uninstallConnector(ctx, app.GetValue(), connectorID)
})
It("can be processed", func() {
approveResponse, err := app.GetValue().SDK().Payments.V3.ApprovePaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(approveResponse.GetV3ApprovePaymentInitiationResponse().Data).NotTo(BeNil())
taskID, err := models.TaskIDFromString(approveResponse.GetV3ApprovePaymentInitiationResponse().Data.TaskID)
Expect(err).To(BeNil())
Expect(taskID.Reference).To(ContainSubstring("create-payout"))
var msg = struct {
ConnectorID string `json:"connectorID"`
SourceAccountID string `json:"sourceAccountID,omitempty"`
DestinationAccountID string `json:"destinationAccountID,omitempty"`
}{
ConnectorID: connectorID,
SourceAccountID: debtorID,
DestinationAccountID: creditorID,
}
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPayments, WithPayloadSubset(msg))
taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue())
Eventually(taskPoller(approveResponse.GetV3ApprovePaymentInitiationResponse().Data.TaskID)).
WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED))
getResponse, err := app.GetValue().SDK().Payments.V3.GetPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(string(getResponse.GetV3GetPaymentInitiationResponse().Data.Status)).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED.String()))
})
It("can be rejected", func() {
_, err := app.GetValue().SDK().Payments.V3.RejectPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
getResponse, err := app.GetValue().SDK().Payments.V3.GetPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(string(getResponse.GetV3GetPaymentInitiationResponse().Data.Status)).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED.String()))
})
It("cannot be reversed if the payment is unprocessed", func() {
reverseResponse, err := app.GetValue().SDK().Payments.V3.ReversePaymentInitiation(ctx, paymentInitiationID, &components.V3ReversePaymentInitiationRequest{
Reference: uuid.New().String(),
Description: payReq.Description,
Amount: payReq.Amount,
Asset: payReq.Asset,
Metadata: map[string]string{"reversal": "data"},
})
Expect(err).To(BeNil())
Expect(reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID).NotTo(BeNil())
taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue())
Eventually(taskPoller(*reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_FAILED, WithError(workflow.ErrPaymentInitiationNotProcessed)))
})
It("can be reversed", func() {
approveResponse, err := app.GetValue().SDK().Payments.V3.ApprovePaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
var msg = struct {
ConnectorID string `json:"connectorID"`
SourceAccountID string `json:"sourceAccountID,omitempty"`
DestinationAccountID string `json:"destinationAccountID,omitempty"`
}{
ConnectorID: connectorID,
SourceAccountID: debtorID,
DestinationAccountID: creditorID,
}
MustEventuallyOutbox(ctx, app.GetValue(), events.EventTypeSavedPayments, WithPayloadSubset(msg))
taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue())
Eventually(taskPoller(approveResponse.GetV3ApprovePaymentInitiationResponse().Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED))
reverseResponse, err := app.GetValue().SDK().Payments.V3.ReversePaymentInitiation(ctx, paymentInitiationID, &components.V3ReversePaymentInitiationRequest{
Reference: uuid.New().String(),
Description: payReq.Description,
Amount: payReq.Amount,
Asset: payReq.Asset,
Metadata: map[string]string{"reversal": "data"},
})
Expect(err).To(BeNil())
Expect(reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID).NotTo(BeNil())
blockTillWorkflowComplete(ctx, connectorID, "reverse-payout")
Eventually(taskPoller(*reverseResponse.GetV3ReversePaymentInitiationResponse().Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED))
getResponse, err := app.GetValue().SDK().Payments.V3.GetPaymentInitiation(ctx, paymentInitiationID)
Expect(err).To(BeNil())
Expect(string(getResponse.GetV3GetPaymentInitiationResponse().Data.Status)).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED.String()))
})
})
})