diff --git a/.github/actions/env/action.yml b/.github/actions/env/action.yml index 6cb2e583..e32a8d64 100644 --- a/.github/actions/env/action.yml +++ b/.github/actions/env/action.yml @@ -7,11 +7,6 @@ inputs: runs: using: composite steps: - - uses: earthly/actions-setup@v1 - with: - github-token: ${{ inputs.token }} - version: "latest" - use-cache: true - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9b4cdf71..bd9099e7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} Dirty: - runs-on: "ubuntu-latest" + runs-on: "formance-runner" steps: - uses: 'actions/checkout@v4' with: @@ -54,7 +54,7 @@ jobs: fi Tests: - runs-on: "ubuntu-latest" + runs-on: "formance-runner" needs: - Dirty steps: @@ -76,15 +76,11 @@ jobs: SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} GoReleaser: - runs-on: "ubuntu-latest" + runs-on: "formance-runner" if: contains(github.event.pull_request.labels.*.name, 'build-images') || github.ref == 'refs/heads/main' || github.event_name == 'merge_group' needs: - Dirty steps: - - uses: earthly/actions-setup@v1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - version: "latest" - uses: 'actions/checkout@v4' with: fetch-depth: 0 diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index b6dc7fbe..1bd3d964 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -13,6 +13,11 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 + - uses: earthly/actions-setup@v1 + with: + github-token: ${{ inputs.token }} + version: "latest" + use-cache: true - name: Setup Env uses: ./.github/actions/env with: diff --git a/.gitignore b/.gitignore index 0f1636bb..bc16d5e4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage.out dist/ .env payments +tools/ \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..dd051230 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @formancehq/backend \ No newline at end of file diff --git a/Earthfile b/Earthfile index 341aa18c..8d33e254 100644 --- a/Earthfile +++ b/Earthfile @@ -5,17 +5,47 @@ IMPORT github.com/formancehq/earthly:tags/v0.16.2 AS core FROM core+base-image +postgres: + FROM postgres:15-alpine + sources: WORKDIR src WORKDIR /src COPY go.* . - COPY --dir pkg cmd internal . + COPY --dir cmd pkg internal . COPY main.go . SAVE ARTIFACT /src +compile-plugins: + FROM core+builder-image + COPY (+sources/*) /src + COPY (+compile-configs/configs.json) /src/internal/connectors/plugins/configs.json + WORKDIR /src/internal/connectors/plugins/public + RUN printf "package public\n\n" > list.go + RUN printf "import (\n" >> list.go + FOR c IN $(ls -d */ | sed 's#/##') + RUN printf " _ \"github.com/formancehq/payments/internal/connectors/plugins/public/$c\"\n" >> list.go + END + RUN printf ")\n" >> list.go + SAVE ARTIFACT /src/internal/connectors/plugins/public/list.go /list.go + +compile-configs: + FROM core+builder-image + COPY (+sources/*) /src + WORKDIR /src/internal/connectors/plugins/public + FOR c IN $(ls -d */ | sed 's#/##') + RUN echo "{\"$c\":" >> raw_configs.json + RUN cat /src/internal/connectors/plugins/public/$c/config.json >> raw_configs.json + RUN echo "}" >> raw_configs.json + END + RUN jq --slurp 'add' raw_configs.json > configs.json + SAVE ARTIFACT /src/internal/connectors/plugins/public/configs.json /configs.json + compile: FROM core+builder-image COPY (+sources/*) /src + COPY (+compile-configs/configs.json) /src/internal/connectors/plugins/configs.json + COPY (+compile-plugins/list.go) /src/internal/connectors/plugins/public/list.go WORKDIR /src ARG VERSION=latest DO --pass-args core+GO_COMPILE --VERSION=$VERSION @@ -25,16 +55,33 @@ build-image: ENTRYPOINT ["/bin/payments"] CMD ["serve"] COPY (+compile/main) /bin/payments + FOR c IN $(ls /plugins/*) + RUN chmod +x $c + END ARG REPOSITORY=ghcr.io ARG tag=latest DO core+SAVE_IMAGE --COMPONENT=payments --REPOSITORY=${REPOSITORY} --TAG=$tag tests: - FROM core+builder-image + FROM +tidy COPY (+sources/*) /src WORKDIR /src - WITH DOCKER --pull=postgres:15-alpine - DO --pass-args core+GO_TESTS + ARG includeIntegrationTests="true" + + ENV CGO_ENABLED=1 # required for -race + + LET goFlags="-race" + IF [ "$includeIntegrationTests" = "true" ] + COPY (+compile-configs/configs.json) /src/internal/connectors/plugins/configs.json + COPY (+compile-plugins/list.go) /src/internal/connectors/plugins/public/list.go + SET goFlags="$goFlags -tags it" + WITH DOCKER --load=postgres:15-alpine=+postgres + RUN go test $goFlags ./... + END + ELSE + WITH DOCKER --pull=postgres:15-alpine + DO --pass-args +GO_TESTS + END END deploy: @@ -74,26 +121,36 @@ tidy: FROM core+builder-image COPY --pass-args (+sources/src) /src WORKDIR /src + COPY --dir test . DO --pass-args core+GO_TIDY -generate-generic-connector-client: - FROM openapitools/openapi-generator-cli:v6.6.0 +generate: + FROM core+builder-image + RUN apk update && apk add openjdk11 + DO --pass-args core+GO_INSTALL --package=go.uber.org/mock/mockgen@latest + COPY (+sources/*) /src WORKDIR /src - COPY cmd/connectors/internal/connectors/generic/client/generic-openapi.yaml . - RUN docker-entrypoint.sh generate \ - -i ./generic-openapi.yaml \ - -g go \ - -o ./generated \ - --git-user-id=formancehq \ - --git-repo-id=payments \ - -p packageVersion=latest \ - -p isGoSubmodule=true \ - -p packageName=genericclient - RUN rm -rf ./generated/test - SAVE ARTIFACT ./generated AS LOCAL ./cmd/connectors/internal/connectors/generic/client/generated + DO --pass-args core+GO_GENERATE + SAVE ARTIFACT internal AS LOCAL internal + +# generate-generic-connector-client: +# FROM openapitools/openapi-generator-cli:v6.6.0 +# WORKDIR /src +# COPY cmd/connectors/internal/connectors/generic/client/generic-openapi.yaml . +# RUN docker-entrypoint.sh generate \ +# -i ./generic-openapi.yaml \ +# -g go \ +# -o ./generated \ +# --git-user-id=formancehq \ +# --git-repo-id=payments \ +# -p packageVersion=latest \ +# -p isGoSubmodule=true \ +# -p packageName=genericclient +# RUN rm -rf ./generated/test +# SAVE ARTIFACT ./generated AS LOCAL ./cmd/connectors/internal/connectors/generic/client/generated release: FROM core+builder-image ARG mode=local COPY --dir . /src - DO core+GORELEASER --mode=$mode \ No newline at end of file + DO core+GORELEASER --mode=$mode diff --git a/cmd/api/internal/api/accounts.go b/cmd/api/internal/api/accounts.go deleted file mode 100644 index b2e9468a..00000000 --- a/cmd/api/internal/api/accounts.go +++ /dev/null @@ -1,245 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" -) - -type accountResponse struct { - ID string `json:"id"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` - DefaultCurrency string `json:"defaultCurrency"` // Deprecated: should be removed soon - DefaultAsset string `json:"defaultAsset"` - AccountName string `json:"accountName"` - Type string `json:"type"` - Metadata map[string]string `json:"metadata"` - Pools []uuid.UUID `json:"pools"` - Raw interface{} `json:"raw"` -} - -func createAccountHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "createAccountHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - var req service.CreateAccountRequest - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - span.SetAttributes( - attribute.String("request.reference", req.Reference), - attribute.String("request.type", req.Type), - attribute.String("request.connectorID", req.ConnectorID), - attribute.String("request.createdAt", req.CreatedAt.String()), - attribute.String("request.accountName", req.AccountName), - attribute.String("request.defaultAsset", req.DefaultAsset), - ) - - if err := req.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - account, err := b.GetService().CreateAccount(ctx, &req) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - data := &accountResponse{ - ID: account.ID.String(), - Reference: account.Reference, - CreatedAt: account.CreatedAt, - ConnectorID: account.ConnectorID.String(), - Provider: account.ConnectorID.Provider.String(), - DefaultCurrency: account.DefaultAsset.String(), - DefaultAsset: account.DefaultAsset.String(), - AccountName: account.AccountName, - Type: account.Type.String(), - Raw: account.RawData, - Pools: make([]uuid.UUID, 0), - } - - if account.Metadata != nil { - metadata := make(map[string]string) - for k, v := range account.Metadata { - metadata[k] = v - } - data.Metadata = metadata - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[accountResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func listAccountsHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listAccountsHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - query, err := bunpaginate.Extract[storage.ListAccountsQuery](r, func() (*storage.ListAccountsQuery, error) { - options, err := getPagination(r, storage.AccountQuery{}) - if err != nil { - return nil, err - } - return pointer.For(storage.NewListAccountsQuery(*options)), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - cursor, err := b.GetService().ListAccounts(ctx, *query) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - ret := cursor.Data - data := make([]*accountResponse, len(ret)) - - for i := range ret { - accountType := ret[i].Type - if accountType == models.AccountTypeExternalFormance { - accountType = models.AccountTypeExternal - } - - data[i] = &accountResponse{ - ID: ret[i].ID.String(), - Reference: ret[i].Reference, - CreatedAt: ret[i].CreatedAt, - ConnectorID: ret[i].ConnectorID.String(), - Provider: ret[i].ConnectorID.Provider.String(), - DefaultCurrency: ret[i].DefaultAsset.String(), - DefaultAsset: ret[i].DefaultAsset.String(), - AccountName: ret[i].AccountName, - Type: accountType.String(), - Raw: ret[i].RawData, - } - - if ret[i].Metadata != nil { - metadata := make(map[string]string) - for k, v := range ret[i].Metadata { - metadata[k] = v - } - data[i].Metadata = metadata - } - - data[i].Pools = make([]uuid.UUID, len(ret[i].PoolAccounts)) - for j := range ret[i].PoolAccounts { - data[i].Pools[j] = ret[i].PoolAccounts[j].PoolID - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[*accountResponse]{ - Cursor: &bunpaginate.Cursor[*accountResponse]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func readAccountHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readAccountHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - accountID := mux.Vars(r)["accountID"] - - span.SetAttributes(attribute.String("request.accountID", accountID)) - - account, err := b.GetService().GetAccount(ctx, accountID) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - accountType := account.Type - if accountType == models.AccountTypeExternalFormance { - accountType = models.AccountTypeExternal - } - - data := &accountResponse{ - ID: account.ID.String(), - Reference: account.Reference, - CreatedAt: account.CreatedAt, - ConnectorID: account.ConnectorID.String(), - Provider: account.ConnectorID.Provider.String(), - DefaultCurrency: account.DefaultAsset.String(), - DefaultAsset: account.DefaultAsset.String(), - AccountName: account.AccountName, - Type: accountType.String(), - Raw: account.RawData, - } - - if account.Metadata != nil { - metadata := make(map[string]string) - for k, v := range account.Metadata { - metadata[k] = v - } - data.Metadata = metadata - } - - data.Pools = make([]uuid.UUID, len(account.PoolAccounts)) - for j := range account.PoolAccounts { - data.Pools[j] = account.PoolAccounts[j].PoolID - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[accountResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - } -} diff --git a/cmd/api/internal/api/accounts_test.go b/cmd/api/internal/api/accounts_test.go deleted file mode 100644 index ed1ccf40..00000000 --- a/cmd/api/internal/api/accounts_test.go +++ /dev/null @@ -1,749 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestCreateAccounts(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.CreateAccountRequest - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - testCases := []testCase{ - { - name: "nomimal", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - }, - { - name: "no default asset, but should still pass", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - }, - { - name: "missing reference", - req: &service.CreateAccountRequest{ - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing connectorID", - req: &service.CreateAccountRequest{ - Reference: "test", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing createdAt", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "createdAt zero", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Time{}, - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing accountName", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing type", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid type", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "unknown", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - req: &service.CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - createAccountResponse := &models.Account{ - ID: models.AccountID{ - Reference: testCase.req.Reference, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: testCase.req.CreatedAt, - Reference: testCase.req.Reference, - DefaultAsset: models.Asset(testCase.req.DefaultAsset), - AccountName: testCase.req.AccountName, - Type: models.AccountType(testCase.req.Type), - Metadata: map[string]string{ - "foo": "bar", - }, - PoolAccounts: make([]*models.PoolAccounts, 0), - } - - expectedCreateAccountResponse := &accountResponse{ - ID: createAccountResponse.ID.String(), - Reference: createAccountResponse.Reference, - CreatedAt: createAccountResponse.CreatedAt, - ConnectorID: createAccountResponse.ConnectorID.String(), - Provider: createAccountResponse.ConnectorID.Provider.String(), - DefaultCurrency: createAccountResponse.DefaultAsset.String(), - DefaultAsset: createAccountResponse.DefaultAsset.String(), - AccountName: createAccountResponse.AccountName, - Type: createAccountResponse.Type.String(), - Metadata: map[string]string{ - "foo": "bar", - }, - Pools: make([]uuid.UUID, 0), - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - CreateAccount(gomock.Any(), testCase.req). - Return(createAccountResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - CreateAccount(gomock.Any(), testCase.req). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[accountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedCreateAccountResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestListAccounts(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - queryParams url.Values - pageSize int - expectedQuery storage.ListAccountsQuery - expectedStatusCode int - serviceError error - expectedErrorCode string - } - - testCases := []testCase{ - { - name: "nomimal", - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too low", - queryParams: url.Values{ - "pageSize": {"0"}, - }, - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - }, - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(100), - ), - pageSize: 100, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid query builder json", - queryParams: url.Values{ - "query": {"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "valid query builder json", - queryParams: url.Values{ - "query": {"{\"$match\": {\"source_account_id\": \"acc1\"}}"}, - }, - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15). - WithQueryBuilder(query.Match("source_account_id", "acc1")), - ), - pageSize: 15, - }, - { - name: "valid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:asc"}, - }, - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15). - WithSorter(storage.Sorter{}.Add("source_account_id", storage.SortOrderAsc)), - ), - pageSize: 15, - }, - { - name: "invalid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15), - ), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - expectedQuery: storage.NewListAccountsQuery( - storage.NewPaginatedQueryOptions(storage.AccountQuery{}). - WithPageSize(15), - ), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - accounts := []models.Account{ - { - ID: models.AccountID{Reference: "acc1", ConnectorID: models.ConnectorID{Reference: uuid.New(), Provider: models.ConnectorProviderDummyPay}}, - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Reference: "acc1", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo": "bar", - }, - }, - { - ID: models.AccountID{Reference: "acc2", ConnectorID: models.ConnectorID{Reference: uuid.New(), Provider: models.ConnectorProviderDummyPay}}, - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Reference: "acc2", - Type: models.AccountTypeExternalFormance, - }, - } - - listAccountsResponse := &bunpaginate.Cursor[models.Account]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: accounts, - } - - expectedAccountsResponse := []*accountResponse{ - { - ID: accounts[0].ID.String(), - Reference: accounts[0].Reference, - CreatedAt: accounts[0].CreatedAt, - ConnectorID: accounts[0].ConnectorID.String(), - Provider: accounts[0].ConnectorID.Provider.String(), - DefaultCurrency: accounts[0].DefaultAsset.String(), - DefaultAsset: accounts[0].DefaultAsset.String(), - AccountName: accounts[0].AccountName, - Type: accounts[0].Type.String(), - Pools: []uuid.UUID{}, - Metadata: accounts[0].Metadata, - }, - { - ID: accounts[1].ID.String(), - Reference: accounts[1].Reference, - CreatedAt: accounts[1].CreatedAt, - ConnectorID: accounts[1].ConnectorID.String(), - Provider: accounts[1].ConnectorID.Provider.String(), - DefaultCurrency: accounts[1].DefaultAsset.String(), - DefaultAsset: accounts[1].DefaultAsset.String(), - AccountName: accounts[1].AccountName, - Pools: []uuid.UUID{}, - // Type is converted to external when it is external formance - Type: string(models.AccountTypeExternal), - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListAccounts(gomock.Any(), testCase.expectedQuery). - Return(listAccountsResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListAccounts(gomock.Any(), testCase.expectedQuery). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, "/accounts", nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[*accountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedAccountsResponse, resp.Cursor.Data) - require.Equal(t, listAccountsResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listAccountsResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listAccountsResponse.Next, resp.Cursor.Next) - require.Equal(t, listAccountsResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestGetAccount(t *testing.T) { - t.Parallel() - - accountID1 := models.AccountID{ - Reference: "acc1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - accountID2 := models.AccountID{ - Reference: "acc2", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - type testCase struct { - name string - accountID string - serviceError error - expectedAccountID models.AccountID - expectedStatusCode int - expectedErrorCode string - } - - testCases := []testCase{ - { - name: "nomimal acc1", - accountID: accountID1.String(), - expectedAccountID: accountID1, - }, - { - name: "nomimal acc2", - accountID: accountID2.String(), - expectedAccountID: accountID2, - }, - { - name: "err validation from backend", - accountID: accountID1.String(), - expectedAccountID: accountID1, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - accountID: accountID1.String(), - expectedAccountID: accountID1, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - accountID: accountID1.String(), - expectedAccountID: accountID1, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - accountID: accountID1.String(), - expectedAccountID: accountID1, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - var getAccountResponse *models.Account - var expectedAccountsResponse *accountResponse - if testCase.expectedAccountID == accountID1 { - getAccountResponse = &models.Account{ - ID: models.AccountID{Reference: "acc1", ConnectorID: models.ConnectorID{Reference: uuid.New(), Provider: models.ConnectorProviderDummyPay}}, - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Reference: "acc1", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo": "bar", - }, - } - - expectedAccountsResponse = &accountResponse{ - ID: getAccountResponse.ID.String(), - Reference: getAccountResponse.Reference, - CreatedAt: getAccountResponse.CreatedAt, - ConnectorID: getAccountResponse.ConnectorID.String(), - Provider: getAccountResponse.ConnectorID.Provider.String(), - DefaultCurrency: getAccountResponse.DefaultAsset.String(), - DefaultAsset: getAccountResponse.DefaultAsset.String(), - AccountName: getAccountResponse.AccountName, - Metadata: getAccountResponse.Metadata, - Pools: []uuid.UUID{}, - Type: getAccountResponse.Type.String(), - } - } else { - getAccountResponse = &models.Account{ - ID: models.AccountID{Reference: "acc2", ConnectorID: models.ConnectorID{Reference: uuid.New(), Provider: models.ConnectorProviderDummyPay}}, - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Reference: "acc2", - Type: models.AccountTypeExternalFormance, - } - expectedAccountsResponse = &accountResponse{ - ID: getAccountResponse.ID.String(), - Reference: getAccountResponse.Reference, - CreatedAt: getAccountResponse.CreatedAt, - ConnectorID: getAccountResponse.ConnectorID.String(), - Provider: getAccountResponse.ConnectorID.Provider.String(), - DefaultCurrency: getAccountResponse.DefaultAsset.String(), - DefaultAsset: getAccountResponse.DefaultAsset.String(), - AccountName: getAccountResponse.AccountName, - Pools: []uuid.UUID{}, - // Type is converted to external when it is external formance - Type: models.AccountTypeExternal.String(), - } - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - GetAccount(gomock.Any(), testCase.expectedAccountID.String()). - Return(getAccountResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - GetAccount(gomock.Any(), testCase.expectedAccountID.String()). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/accounts/%s", testCase.accountID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[accountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedAccountsResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/api_utils_test.go b/cmd/api/internal/api/api_utils_test.go deleted file mode 100644 index 149478e4..00000000 --- a/cmd/api/internal/api/api_utils_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package api - -import ( - "testing" - - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/golang/mock/gomock" -) - -func newTestingBackend(t *testing.T) (*backend.MockBackend, *backend.MockService) { - ctrl := gomock.NewController(t) - mockService := backend.NewMockService(ctrl) - backend := backend.NewMockBackend(ctrl) - backend. - EXPECT(). - GetService(). - MinTimes(0). - Return(mockService) - t.Cleanup(func() { - ctrl.Finish() - }) - return backend, mockService -} diff --git a/cmd/api/internal/api/backend/backend.go b/cmd/api/internal/api/backend/backend.go deleted file mode 100644 index 72429a25..00000000 --- a/cmd/api/internal/api/backend/backend.go +++ /dev/null @@ -1,54 +0,0 @@ -package backend - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -//go:generate mockgen -source backend.go -destination backend_generated.go -package backend . Service -type Service interface { - Ping() error - CreateAccount(ctx context.Context, req *service.CreateAccountRequest) (*models.Account, error) - ListAccounts(ctx context.Context, q storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) - GetAccount(ctx context.Context, id string) (*models.Account, error) - ListBalances(ctx context.Context, q storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) - ListBankAccounts(ctx context.Context, a storage.ListBankAccountQuery) (*bunpaginate.Cursor[models.BankAccount], error) - GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) - ListTransferInitiations(ctx context.Context, q storage.ListTransferInitiationsQuery) (*bunpaginate.Cursor[models.TransferInitiation], error) - ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) - CreatePayment(ctx context.Context, req *service.CreatePaymentRequest) (*models.Payment, error) - ListPayments(ctx context.Context, q storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) - GetPayment(ctx context.Context, id string) (*models.Payment, error) - UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error - CreatePool(ctx context.Context, req *service.CreatePoolRequest) (*models.Pool, error) - AddAccountToPool(ctx context.Context, poolID string, req *service.AddAccountToPoolRequest) error - RemoveAccountFromPool(ctx context.Context, poolID string, accountID string) error - ListPools(ctx context.Context, q storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) - GetPool(ctx context.Context, poolID string) (*models.Pool, error) - GetPoolBalance(ctx context.Context, poolID string, atTime string) (*service.GetPoolBalanceResponse, error) - DeletePool(ctx context.Context, poolID string) error -} - -type Backend interface { - GetService() Service -} - -type DefaultBackend struct { - service Service -} - -func (d DefaultBackend) GetService() Service { - return d.service -} - -func NewDefaultBackend(service Service) Backend { - return &DefaultBackend{ - service: service, - } -} diff --git a/cmd/api/internal/api/backend/backend_generated.go b/cmd/api/internal/api/backend/backend_generated.go deleted file mode 100644 index cc78a24b..00000000 --- a/cmd/api/internal/api/backend/backend_generated.go +++ /dev/null @@ -1,372 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: backend.go - -// Package backend is a generated GoMock package. -package backend - -import ( - context "context" - reflect "reflect" - - service "github.com/formancehq/payments/cmd/api/internal/api/service" - storage "github.com/formancehq/payments/cmd/api/internal/storage" - models "github.com/formancehq/payments/internal/models" - bunpaginate "github.com/formancehq/go-libs/bun/bunpaginate" - gomock "github.com/golang/mock/gomock" - uuid "github.com/google/uuid" -) - -// MockService is a mock of Service interface. -type MockService struct { - ctrl *gomock.Controller - recorder *MockServiceMockRecorder -} - -// MockServiceMockRecorder is the mock recorder for MockService. -type MockServiceMockRecorder struct { - mock *MockService -} - -// NewMockService creates a new mock instance. -func NewMockService(ctrl *gomock.Controller) *MockService { - mock := &MockService{ctrl: ctrl} - mock.recorder = &MockServiceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockService) EXPECT() *MockServiceMockRecorder { - return m.recorder -} - -// AddAccountToPool mocks base method. -func (m *MockService) AddAccountToPool(ctx context.Context, poolID string, req *service.AddAccountToPoolRequest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddAccountToPool", ctx, poolID, req) - ret0, _ := ret[0].(error) - return ret0 -} - -// AddAccountToPool indicates an expected call of AddAccountToPool. -func (mr *MockServiceMockRecorder) AddAccountToPool(ctx, poolID, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAccountToPool", reflect.TypeOf((*MockService)(nil).AddAccountToPool), ctx, poolID, req) -} - -// CreateAccount mocks base method. -func (m *MockService) CreateAccount(ctx context.Context, req *service.CreateAccountRequest) (*models.Account, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAccount", ctx, req) - ret0, _ := ret[0].(*models.Account) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CreateAccount indicates an expected call of CreateAccount. -func (mr *MockServiceMockRecorder) CreateAccount(ctx, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccount", reflect.TypeOf((*MockService)(nil).CreateAccount), ctx, req) -} - -// CreatePayment mocks base method. -func (m *MockService) CreatePayment(ctx context.Context, req *service.CreatePaymentRequest) (*models.Payment, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreatePayment", ctx, req) - ret0, _ := ret[0].(*models.Payment) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CreatePayment indicates an expected call of CreatePayment. -func (mr *MockServiceMockRecorder) CreatePayment(ctx, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePayment", reflect.TypeOf((*MockService)(nil).CreatePayment), ctx, req) -} - -// CreatePool mocks base method. -func (m *MockService) CreatePool(ctx context.Context, req *service.CreatePoolRequest) (*models.Pool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreatePool", ctx, req) - ret0, _ := ret[0].(*models.Pool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CreatePool indicates an expected call of CreatePool. -func (mr *MockServiceMockRecorder) CreatePool(ctx, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePool", reflect.TypeOf((*MockService)(nil).CreatePool), ctx, req) -} - -// DeletePool mocks base method. -func (m *MockService) DeletePool(ctx context.Context, poolID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeletePool", ctx, poolID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeletePool indicates an expected call of DeletePool. -func (mr *MockServiceMockRecorder) DeletePool(ctx, poolID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePool", reflect.TypeOf((*MockService)(nil).DeletePool), ctx, poolID) -} - -// GetAccount mocks base method. -func (m *MockService) GetAccount(ctx context.Context, id string) (*models.Account, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccount", ctx, id) - ret0, _ := ret[0].(*models.Account) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccount indicates an expected call of GetAccount. -func (mr *MockServiceMockRecorder) GetAccount(ctx, id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockService)(nil).GetAccount), ctx, id) -} - -// GetBankAccount mocks base method. -func (m *MockService) GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetBankAccount", ctx, id, expand) - ret0, _ := ret[0].(*models.BankAccount) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetBankAccount indicates an expected call of GetBankAccount. -func (mr *MockServiceMockRecorder) GetBankAccount(ctx, id, expand interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBankAccount", reflect.TypeOf((*MockService)(nil).GetBankAccount), ctx, id, expand) -} - -// GetPayment mocks base method. -func (m *MockService) GetPayment(ctx context.Context, id string) (*models.Payment, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPayment", ctx, id) - ret0, _ := ret[0].(*models.Payment) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPayment indicates an expected call of GetPayment. -func (mr *MockServiceMockRecorder) GetPayment(ctx, id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayment", reflect.TypeOf((*MockService)(nil).GetPayment), ctx, id) -} - -// GetPool mocks base method. -func (m *MockService) GetPool(ctx context.Context, poolID string) (*models.Pool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPool", ctx, poolID) - ret0, _ := ret[0].(*models.Pool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPool indicates an expected call of GetPool. -func (mr *MockServiceMockRecorder) GetPool(ctx, poolID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPool", reflect.TypeOf((*MockService)(nil).GetPool), ctx, poolID) -} - -// GetPoolBalance mocks base method. -func (m *MockService) GetPoolBalance(ctx context.Context, poolID, atTime string) (*service.GetPoolBalanceResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPoolBalance", ctx, poolID, atTime) - ret0, _ := ret[0].(*service.GetPoolBalanceResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPoolBalance indicates an expected call of GetPoolBalance. -func (mr *MockServiceMockRecorder) GetPoolBalance(ctx, poolID, atTime interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPoolBalance", reflect.TypeOf((*MockService)(nil).GetPoolBalance), ctx, poolID, atTime) -} - -// ListAccounts mocks base method. -func (m *MockService) ListAccounts(ctx context.Context, q storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAccounts", ctx, q) - ret0, _ := ret[0].(*bunpaginate.Cursor[models.Account]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListAccounts indicates an expected call of ListAccounts. -func (mr *MockServiceMockRecorder) ListAccounts(ctx, q interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAccounts", reflect.TypeOf((*MockService)(nil).ListAccounts), ctx, q) -} - -// ListBalances mocks base method. -func (m *MockService) ListBalances(ctx context.Context, q storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListBalances", ctx, q) - ret0, _ := ret[0].(*bunpaginate.Cursor[models.Balance]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListBalances indicates an expected call of ListBalances. -func (mr *MockServiceMockRecorder) ListBalances(ctx, q interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBalances", reflect.TypeOf((*MockService)(nil).ListBalances), ctx, q) -} - -// ListBankAccounts mocks base method. -func (m *MockService) ListBankAccounts(ctx context.Context, a storage.ListBankAccountQuery) (*bunpaginate.Cursor[models.BankAccount], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListBankAccounts", ctx, a) - ret0, _ := ret[0].(*bunpaginate.Cursor[models.BankAccount]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListBankAccounts indicates an expected call of ListBankAccounts. -func (mr *MockServiceMockRecorder) ListBankAccounts(ctx, a interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBankAccounts", reflect.TypeOf((*MockService)(nil).ListBankAccounts), ctx, a) -} - -// ListPayments mocks base method. -func (m *MockService) ListPayments(ctx context.Context, q storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListPayments", ctx, q) - ret0, _ := ret[0].(*bunpaginate.Cursor[models.Payment]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListPayments indicates an expected call of ListPayments. -func (mr *MockServiceMockRecorder) ListPayments(ctx, q interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPayments", reflect.TypeOf((*MockService)(nil).ListPayments), ctx, q) -} - -// ListPools mocks base method. -func (m *MockService) ListPools(ctx context.Context, q storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListPools", ctx, q) - ret0, _ := ret[0].(*bunpaginate.Cursor[models.Pool]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListPools indicates an expected call of ListPools. -func (mr *MockServiceMockRecorder) ListPools(ctx, q interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPools", reflect.TypeOf((*MockService)(nil).ListPools), ctx, q) -} - -// ListTransferInitiations mocks base method. -func (m *MockService) ListTransferInitiations(ctx context.Context, q storage.ListTransferInitiationsQuery) (*bunpaginate.Cursor[models.TransferInitiation], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListTransferInitiations", ctx, q) - ret0, _ := ret[0].(*bunpaginate.Cursor[models.TransferInitiation]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListTransferInitiations indicates an expected call of ListTransferInitiations. -func (mr *MockServiceMockRecorder) ListTransferInitiations(ctx, q interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTransferInitiations", reflect.TypeOf((*MockService)(nil).ListTransferInitiations), ctx, q) -} - -// Ping mocks base method. -func (m *MockService) Ping() error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Ping") - ret0, _ := ret[0].(error) - return ret0 -} - -// Ping indicates an expected call of Ping. -func (mr *MockServiceMockRecorder) Ping() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockService)(nil).Ping)) -} - -// ReadTransferInitiation mocks base method. -func (m *MockService) ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReadTransferInitiation", ctx, id) - ret0, _ := ret[0].(*models.TransferInitiation) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ReadTransferInitiation indicates an expected call of ReadTransferInitiation. -func (mr *MockServiceMockRecorder) ReadTransferInitiation(ctx, id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadTransferInitiation", reflect.TypeOf((*MockService)(nil).ReadTransferInitiation), ctx, id) -} - -// RemoveAccountFromPool mocks base method. -func (m *MockService) RemoveAccountFromPool(ctx context.Context, poolID, accountID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RemoveAccountFromPool", ctx, poolID, accountID) - ret0, _ := ret[0].(error) - return ret0 -} - -// RemoveAccountFromPool indicates an expected call of RemoveAccountFromPool. -func (mr *MockServiceMockRecorder) RemoveAccountFromPool(ctx, poolID, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAccountFromPool", reflect.TypeOf((*MockService)(nil).RemoveAccountFromPool), ctx, poolID, accountID) -} - -// UpdatePaymentMetadata mocks base method. -func (m *MockService) UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdatePaymentMetadata", ctx, paymentID, metadata) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdatePaymentMetadata indicates an expected call of UpdatePaymentMetadata. -func (mr *MockServiceMockRecorder) UpdatePaymentMetadata(ctx, paymentID, metadata interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePaymentMetadata", reflect.TypeOf((*MockService)(nil).UpdatePaymentMetadata), ctx, paymentID, metadata) -} - -// MockBackend is a mock of Backend interface. -type MockBackend struct { - ctrl *gomock.Controller - recorder *MockBackendMockRecorder -} - -// MockBackendMockRecorder is the mock recorder for MockBackend. -type MockBackendMockRecorder struct { - mock *MockBackend -} - -// NewMockBackend creates a new mock instance. -func NewMockBackend(ctrl *gomock.Controller) *MockBackend { - mock := &MockBackend{ctrl: ctrl} - mock.recorder = &MockBackendMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockBackend) EXPECT() *MockBackendMockRecorder { - return m.recorder -} - -// GetService mocks base method. -func (m *MockBackend) GetService() Service { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetService") - ret0, _ := ret[0].(Service) - return ret0 -} - -// GetService indicates an expected call of GetService. -func (mr *MockBackendMockRecorder) GetService() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetService", reflect.TypeOf((*MockBackend)(nil).GetService)) -} diff --git a/cmd/api/internal/api/balances.go b/cmd/api/internal/api/balances.go deleted file mode 100644 index 52d8c734..00000000 --- a/cmd/api/internal/api/balances.go +++ /dev/null @@ -1,163 +0,0 @@ -package api - -import ( - "encoding/json" - "math/big" - "net/http" - "strconv" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" -) - -type balancesResponse struct { - AccountID string `json:"accountId"` - CreatedAt time.Time `json:"createdAt"` - LastUpdatedAt time.Time `json:"lastUpdatedAt"` - Currency string `json:"currency"` // Deprecated: should be removed soon - Asset string `json:"asset"` - Balance *big.Int `json:"balance"` -} - -func listBalancesForAccount(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listBalancesForAccount") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - balanceQuery, err := populateBalanceQueryFromRequest(r) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - span.SetAttributes( - attribute.String("request.accountID", balanceQuery.AccountID.String()), - attribute.String("request.currency", balanceQuery.Currency), - attribute.String("request.from", balanceQuery.From.String()), - attribute.String("request.to", balanceQuery.To.String()), - ) - - query, err := bunpaginate.Extract[storage.ListBalancesQuery](r, func() (*storage.ListBalancesQuery, error) { - options, err := getPagination(r, balanceQuery) - if err != nil { - return nil, err - } - return pointer.For(storage.NewListBalancesQuery(*options)), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - // In order to support the legacy API, we need to check if the limit query parameter is set - // and if so, we need to override the pageSize pagination option - if r.URL.Query().Get("limit") != "" { - limit, err := strconv.ParseInt(r.URL.Query().Get("limit"), 10, 64) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - if limit > 0 { - query.PageSize = uint64(limit) - query.Options.PageSize = uint64(limit) - } - } - - cursor, err := b.GetService().ListBalances(ctx, *query) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - ret := cursor.Data - data := make([]*balancesResponse, len(ret)) - - for i := range ret { - data[i] = &balancesResponse{ - AccountID: ret[i].AccountID.String(), - CreatedAt: ret[i].CreatedAt, - Currency: ret[i].Asset.String(), - Asset: ret[i].Asset.String(), - Balance: ret[i].Balance, - LastUpdatedAt: ret[i].LastUpdatedAt, - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[*balancesResponse]{ - Cursor: &bunpaginate.Cursor[*balancesResponse]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func populateBalanceQueryFromRequest(r *http.Request) (storage.BalanceQuery, error) { - var balanceQuery storage.BalanceQuery - - balanceQuery = balanceQuery.WithCurrency(r.URL.Query().Get("asset")) - - accountID, err := models.AccountIDFromString(mux.Vars(r)["accountID"]) - if err != nil { - return balanceQuery, err - } - balanceQuery = balanceQuery.WithAccountID(accountID) - - var startTimeParsed, endTimeParsed time.Time - - from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") - if from != "" { - startTimeParsed, err = time.Parse(time.RFC3339Nano, from) - if err != nil { - return balanceQuery, err - } - } - if to != "" { - endTimeParsed, err = time.Parse(time.RFC3339Nano, to) - if err != nil { - return balanceQuery, err - } - } - - switch { - case startTimeParsed.IsZero() && endTimeParsed.IsZero(): - balanceQuery = balanceQuery. - WithTo(time.Now()) - case !startTimeParsed.IsZero() && endTimeParsed.IsZero(): - balanceQuery = balanceQuery. - WithFrom(startTimeParsed). - WithTo(time.Now()) - case startTimeParsed.IsZero() && !endTimeParsed.IsZero(): - balanceQuery = balanceQuery. - WithTo(endTimeParsed) - default: - balanceQuery = balanceQuery. - WithFrom(startTimeParsed). - WithTo(endTimeParsed) - } - - return balanceQuery, nil -} diff --git a/cmd/api/internal/api/balances_test.go b/cmd/api/internal/api/balances_test.go deleted file mode 100644 index ec351dfd..00000000 --- a/cmd/api/internal/api/balances_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "math/big" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestGetBalances(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - accountID string - queryParams url.Values - pageSize int - expectedQuery storage.ListBalancesQuery - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - accountIDString := accountID.String() - testCases := []testCase{ - { - name: "nomimal", - queryParams: url.Values{ - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - pageSize: 15, - accountID: accountIDString, - }, - { - name: "with invalid accountID", - accountID: "invalid", - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "with valid limit", - queryParams: url.Values{ - "limit": {"10"}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(10), - ), - pageSize: 15, - accountID: accountIDString, - }, - { - name: "with invalid limit", - queryParams: url.Values{ - "limit": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "with from and to", - queryParams: url.Values{ - "from": []string{time.Date(2023, 11, 20, 6, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithFrom(time.Date(2023, 11, 20, 6, 0, 0, 0, time.UTC)). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - accountID: accountIDString, - }, - { - name: "with invalid from", - queryParams: url.Values{ - "from": []string{"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "with invalid to", - queryParams: url.Values{ - "to": []string{"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "page size too low, should use the default value", - queryParams: url.Values{ - "pageSize": {"0"}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - pageSize: 15, - accountID: accountIDString, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(100), - ), - pageSize: 100, - accountID: accountIDString, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "invalid query builder json", - queryParams: url.Values{ - "query": {"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "valid query builder json", - queryParams: url.Values{ - "query": {"{\"$match\": {\"account_id\": \"acc1\"}}"}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15). - WithQueryBuilder(query.Match("account_id", "acc1")), - ), - pageSize: 15, - accountID: accountIDString, - }, - { - name: "valid sorter", - queryParams: url.Values{ - "sort": {"account_id:asc"}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15). - WithSorter(storage.Sorter{}.Add("account_id", storage.SortOrderAsc)), - ), - pageSize: 15, - accountID: accountIDString, - }, - { - name: "invalid sorter", - queryParams: url.Values{ - "sort": {"account_id:invalid"}, - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "err validation from backend", - queryParams: url.Values{ - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - accountID: accountIDString, - }, - { - name: "ErrNotFound from storage", - queryParams: url.Values{ - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - accountID: accountIDString, - }, - { - name: "ErrDuplicateKeyValue from storage", - queryParams: url.Values{ - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - accountID: accountIDString, - }, - { - name: "other storage errors from storage", - queryParams: url.Values{ - "to": []string{time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)}, - }, - expectedQuery: storage.NewListBalancesQuery( - storage.NewPaginatedQueryOptions( - storage.NewBalanceQuery(). - WithAccountID(&accountID). - WithTo(time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC)), - ).WithPageSize(15), - ), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - accountID: accountIDString, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - balances := []models.Balance{ - { - AccountID: accountID, - Asset: "EUR/2", - Balance: big.NewInt(100), - CreatedAt: time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC), - LastUpdatedAt: time.Date(2023, 11, 23, 9, 0, 0, 0, time.UTC), - ConnectorID: connectorID, - }, - } - - listBalancesResponse := &bunpaginate.Cursor[models.Balance]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: balances, - } - - if limit, ok := testCase.queryParams["limit"]; ok { - testCase.pageSize, _ = strconv.Atoi(limit[0]) - } - - expectedBalancessResponse := []*balancesResponse{ - { - AccountID: balances[0].AccountID.String(), - CreatedAt: balances[0].CreatedAt, - LastUpdatedAt: balances[0].LastUpdatedAt, - Currency: balances[0].Asset.String(), - Asset: balances[0].Asset.String(), - Balance: balances[0].Balance, - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListBalances(gomock.Any(), testCase.expectedQuery). - Return(listBalancesResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListBalances(gomock.Any(), testCase.expectedQuery). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/accounts/%s/balances", testCase.accountID), nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[*balancesResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedBalancessResponse, resp.Cursor.Data) - require.Equal(t, listBalancesResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listBalancesResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listBalancesResponse.Next, resp.Cursor.Next) - require.Equal(t, listBalancesResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/bank_accounts.go b/cmd/api/internal/api/bank_accounts.go deleted file mode 100644 index 526e59db..00000000 --- a/cmd/api/internal/api/bank_accounts.go +++ /dev/null @@ -1,186 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" -) - -type bankAccountRelatedAccountsResponse struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - AccountID string `json:"accountID"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` -} - -type bankAccountResponse struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt time.Time `json:"createdAt"` - Country string `json:"country"` - Iban string `json:"iban,omitempty"` - AccountNumber string `json:"accountNumber,omitempty"` - SwiftBicCode string `json:"swiftBicCode,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - RelatedAccounts []*bankAccountRelatedAccountsResponse `json:"relatedAccounts,omitempty"` - - // Deprecated fields, but clients still use them - // They correspond to the first bank account adjustment now. - ConnectorID string `json:"connectorID"` - Provider string `json:"provider,omitempty"` - AccountID string `json:"accountID,omitempty"` -} - -func listBankAccountsHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listBankAccountsHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - query, err := bunpaginate.Extract[storage.ListBankAccountQuery](r, func() (*storage.ListBankAccountQuery, error) { - options, err := getPagination(r, storage.BankAccountQuery{}) - if err != nil { - return nil, err - } - return pointer.For(storage.NewListBankAccountQuery(*options)), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - cursor, err := b.GetService().ListBankAccounts(ctx, *query) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - ret := cursor.Data - data := make([]*bankAccountResponse, len(ret)) - - for i := range ret { - data[i] = &bankAccountResponse{ - ID: ret[i].ID.String(), - Name: ret[i].Name, - CreatedAt: ret[i].CreatedAt, - Country: ret[i].Country, - Metadata: ret[i].Metadata, - } - - // Deprecated fields, but clients still use them - if len(ret[i].RelatedAccounts) > 0 { - data[i].ConnectorID = ret[i].RelatedAccounts[0].ConnectorID.String() - data[i].AccountID = ret[i].RelatedAccounts[0].AccountID.String() - data[i].Provider = ret[i].RelatedAccounts[0].ConnectorID.Provider.String() - } - - for _, adjustment := range ret[i].RelatedAccounts { - data[i].RelatedAccounts = append(data[i].RelatedAccounts, &bankAccountRelatedAccountsResponse{ - ID: adjustment.ID.String(), - CreatedAt: adjustment.CreatedAt, - AccountID: adjustment.AccountID.String(), - ConnectorID: adjustment.ConnectorID.String(), - Provider: adjustment.ConnectorID.Provider.String(), - }) - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[*bankAccountResponse]{ - Cursor: &bunpaginate.Cursor[*bankAccountResponse]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func readBankAccountHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readBankAccountHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - bankAccountID, err := uuid.Parse(mux.Vars(r)["bankAccountID"]) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.bankAccountID", bankAccountID.String())) - - account, err := b.GetService().GetBankAccount(ctx, bankAccountID, true) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - if err := account.Offuscate(); err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - data := &bankAccountResponse{ - ID: account.ID.String(), - Name: account.Name, - CreatedAt: account.CreatedAt, - Country: account.Country, - Iban: account.IBAN, - AccountNumber: account.AccountNumber, - SwiftBicCode: account.SwiftBicCode, - Metadata: account.Metadata, - } - - // Deprecated fields, but clients still use them - if len(account.RelatedAccounts) > 0 { - data.ConnectorID = account.RelatedAccounts[0].ConnectorID.String() - data.AccountID = account.RelatedAccounts[0].AccountID.String() - data.Provider = account.RelatedAccounts[0].ConnectorID.Provider.String() - } - - for _, adjustment := range account.RelatedAccounts { - data.RelatedAccounts = append(data.RelatedAccounts, &bankAccountRelatedAccountsResponse{ - ID: adjustment.ID.String(), - CreatedAt: adjustment.CreatedAt, - AccountID: adjustment.AccountID.String(), - ConnectorID: adjustment.ConnectorID.String(), - Provider: adjustment.ConnectorID.Provider.String(), - }) - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[bankAccountResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - } -} diff --git a/cmd/api/internal/api/bank_accounts_test.go b/cmd/api/internal/api/bank_accounts_test.go deleted file mode 100644 index e1387555..00000000 --- a/cmd/api/internal/api/bank_accounts_test.go +++ /dev/null @@ -1,451 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestListBankAccounts(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - queryParams url.Values - pageSize int - expectedQuery storage.ListBankAccountQuery - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nomimal", - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too low", - queryParams: url.Values{ - "pageSize": {"0"}, - }, - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - }, - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(100), - ), - pageSize: 100, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid query builder json", - queryParams: url.Values{ - "query": {"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "valid query builder json", - queryParams: url.Values{ - "query": {"{\"$match\": {\"source_account_id\": \"acc1\"}}"}, - }, - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15). - WithQueryBuilder(query.Match("source_account_id", "acc1")), - ), - pageSize: 15, - }, - { - name: "valid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:asc"}, - }, - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15). - WithSorter(storage.Sorter{}.Add("source_account_id", storage.SortOrderAsc)), - ), - pageSize: 15, - }, - { - name: "invalid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15), - ), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - expectedQuery: storage.NewListBankAccountQuery( - storage.NewPaginatedQueryOptions(storage.BankAccountQuery{}). - WithPageSize(15), - ), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - b1ID := uuid.New() - b2ID := uuid.New() - - bankAccounts := []models.BankAccount{ - { - ID: b1ID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Name: "ba1", - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - RelatedAccounts: []*models.BankAccountRelatedAccount{ - { - ID: uuid.New(), - BankAccountID: b1ID, - ConnectorID: connectorID, - AccountID: models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - }, - }, - }, - { - ID: b2ID, - CreatedAt: time.Date(2023, 11, 23, 8, 0, 0, 0, time.UTC), - Name: "ba2", - AccountNumber: "0112345679", - IBAN: "FR7630006000011234567890188", - SwiftBicCode: "ABCDGB4B", - Country: "DE", - RelatedAccounts: []*models.BankAccountRelatedAccount{ - { - ID: uuid.New(), - BankAccountID: b2ID, - ConnectorID: connectorID, - AccountID: models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - }, - }, - }, - }, - } - listBankAccountsResponse := &bunpaginate.Cursor[models.BankAccount]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: bankAccounts, - } - - expectedBankAccountsResponse := []*bankAccountResponse{ - { - ID: bankAccounts[0].ID.String(), - Name: bankAccounts[0].Name, - CreatedAt: bankAccounts[0].CreatedAt, - Country: bankAccounts[0].Country, - ConnectorID: bankAccounts[0].RelatedAccounts[0].ConnectorID.String(), - AccountID: bankAccounts[0].RelatedAccounts[0].AccountID.String(), - Provider: bankAccounts[0].RelatedAccounts[0].ConnectorID.Provider.String(), - RelatedAccounts: []*bankAccountRelatedAccountsResponse{ - { - ID: bankAccounts[0].RelatedAccounts[0].ID.String(), - AccountID: bankAccounts[0].RelatedAccounts[0].AccountID.String(), - ConnectorID: bankAccounts[0].RelatedAccounts[0].ConnectorID.String(), - Provider: bankAccounts[0].RelatedAccounts[0].ConnectorID.Provider.String(), - }, - }, - }, - { - ID: bankAccounts[1].ID.String(), - Name: bankAccounts[1].Name, - CreatedAt: bankAccounts[1].CreatedAt, - Country: bankAccounts[1].Country, - ConnectorID: bankAccounts[1].RelatedAccounts[0].ConnectorID.String(), - AccountID: bankAccounts[1].RelatedAccounts[0].AccountID.String(), - Provider: bankAccounts[1].RelatedAccounts[0].ConnectorID.Provider.String(), - RelatedAccounts: []*bankAccountRelatedAccountsResponse{ - { - ID: bankAccounts[1].RelatedAccounts[0].ID.String(), - AccountID: bankAccounts[1].RelatedAccounts[0].AccountID.String(), - ConnectorID: bankAccounts[1].RelatedAccounts[0].ConnectorID.String(), - Provider: bankAccounts[1].RelatedAccounts[0].ConnectorID.Provider.String(), - }, - }, - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListBankAccounts(gomock.Any(), testCase.expectedQuery). - Return(listBankAccountsResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListBankAccounts(gomock.Any(), testCase.expectedQuery). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, "/bank-accounts", nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[*bankAccountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedBankAccountsResponse, resp.Cursor.Data) - require.Equal(t, listBankAccountsResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listBankAccountsResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listBankAccountsResponse.Next, resp.Cursor.Next) - require.Equal(t, listBankAccountsResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestGetBankAccount(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - bankAccountUUID string - expectedBankAccountUUID uuid.UUID - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - uuid1 := uuid.New() - testCases := []testCase{ - { - name: "nomimal", - bankAccountUUID: uuid1.String(), - expectedBankAccountUUID: uuid1, - }, - { - name: "invalid uuid", - bankAccountUUID: "invalid", - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "err validation from backend", - bankAccountUUID: uuid1.String(), - expectedBankAccountUUID: uuid1, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - bankAccountUUID: uuid1.String(), - expectedBankAccountUUID: uuid1, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - bankAccountUUID: uuid1.String(), - expectedBankAccountUUID: uuid1, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - bankAccountUUID: uuid1.String(), - expectedBankAccountUUID: uuid1, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - getBankAccountResponse := &models.BankAccount{ - ID: uuid1, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Name: "ba1", - AccountNumber: "13719713158835300", - IBAN: "FR7630006000011234567890188", - SwiftBicCode: "ABCDGB4B", - Country: "FR", - RelatedAccounts: []*models.BankAccountRelatedAccount{ - { - ID: uuid.New(), - BankAccountID: uuid1, - ConnectorID: connectorID, - AccountID: models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - }, - }, - } - - expectedBankAccountResponse := &bankAccountResponse{ - ID: getBankAccountResponse.ID.String(), - Name: getBankAccountResponse.Name, - CreatedAt: getBankAccountResponse.CreatedAt, - Country: getBankAccountResponse.Country, - ConnectorID: getBankAccountResponse.RelatedAccounts[0].ConnectorID.String(), - Provider: getBankAccountResponse.RelatedAccounts[0].ConnectorID.Provider.String(), - AccountID: getBankAccountResponse.RelatedAccounts[0].AccountID.String(), - Iban: "FR76*******************0188", - AccountNumber: "13************300", - SwiftBicCode: "ABCDGB4B", - RelatedAccounts: []*bankAccountRelatedAccountsResponse{ - { - ID: getBankAccountResponse.RelatedAccounts[0].ID.String(), - AccountID: getBankAccountResponse.RelatedAccounts[0].AccountID.String(), - ConnectorID: getBankAccountResponse.RelatedAccounts[0].ConnectorID.String(), - Provider: getBankAccountResponse.RelatedAccounts[0].ConnectorID.Provider.String(), - }, - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - GetBankAccount(gomock.Any(), testCase.expectedBankAccountUUID, true). - Return(getBankAccountResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - GetBankAccount(gomock.Any(), testCase.expectedBankAccountUUID, true). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/bank-accounts/%s", testCase.bankAccountUUID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[bankAccountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedBankAccountResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/health.go b/cmd/api/internal/api/health.go deleted file mode 100644 index 3ba6427d..00000000 --- a/cmd/api/internal/api/health.go +++ /dev/null @@ -1,26 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/api/internal/api/backend" -) - -func healthHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if err := b.GetService().Ping(); err != nil { - api.InternalServerError(w, r, err) - - return - } - - w.WriteHeader(http.StatusOK) - } -} - -func liveHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - } -} diff --git a/cmd/api/internal/api/metadata.go b/cmd/api/internal/api/metadata.go deleted file mode 100644 index 9067a65b..00000000 --- a/cmd/api/internal/api/metadata.go +++ /dev/null @@ -1,60 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" - - "github.com/gorilla/mux" -) - -func updateMetadataHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "updateMetadataHandler") - defer span.End() - - paymentID, err := models.PaymentIDFromString(mux.Vars(r)["paymentID"]) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.paymentID", paymentID.String())) - - var metadata service.UpdateMetadataRequest - if r.ContentLength == 0 { - var err = errors.New("body is required") - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - err = json.NewDecoder(r.Body).Decode(&metadata) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - for k, v := range metadata { - span.SetAttributes(attribute.String("request.metadata."+k, v)) - } - - err = b.GetService().UpdatePaymentMetadata(ctx, *paymentID, metadata) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/cmd/api/internal/api/metadata_test.go b/cmd/api/internal/api/metadata_test.go deleted file mode 100644 index df8dfbd9..00000000 --- a/cmd/api/internal/api/metadata_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestMetadata(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - paymentID string - body string - expectedPaymentID models.PaymentID - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - paymentID: paymentID.String(), - body: "{\"foo\":\"bar\"}", - expectedPaymentID: paymentID, - }, - { - name: "missing body", - paymentID: paymentID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "invalid body", - paymentID: paymentID.String(), - body: "invalid", - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "invalid paymentID", - paymentID: "invalid", - body: "{\"foo\":\"bar\"}", - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "nominal", - paymentID: paymentID.String(), - body: "{\"foo\":\"bar\"}", - expectedPaymentID: paymentID, - serviceError: service.ErrValidation, - expectedErrorCode: ErrValidation, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "nominal", - paymentID: paymentID.String(), - body: "{\"foo\":\"bar\"}", - expectedPaymentID: paymentID, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "nominal", - paymentID: paymentID.String(), - body: "{\"foo\":\"bar\"}", - expectedPaymentID: paymentID, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "nominal", - paymentID: paymentID.String(), - body: "{\"foo\":\"bar\"}", - expectedPaymentID: paymentID, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - UpdatePaymentMetadata(gomock.Any(), testCase.expectedPaymentID, map[string]string{"foo": "bar"}). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - UpdatePaymentMetadata(gomock.Any(), testCase.expectedPaymentID, map[string]string{"foo": "bar"}). - Return(testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/payments/%s/metadata", testCase.paymentID), strings.NewReader(testCase.body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/module.go b/cmd/api/internal/api/module.go deleted file mode 100644 index 3cd1e54b..00000000 --- a/cmd/api/internal/api/module.go +++ /dev/null @@ -1,98 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "runtime/debug" - - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/httpserver" - "github.com/formancehq/go-libs/otlp" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/messages" - "github.com/gorilla/mux" - "github.com/rs/cors" - "github.com/sirupsen/logrus" - "go.uber.org/fx" -) - -const ( - otelTracesFlag = "otel-traces" - serviceName = "Payments" - - ErrUniqueReference = "CONFLICT" - ErrNotFound = "NOT_FOUND" - ErrInvalidID = "INVALID_ID" - ErrMissingOrInvalidBody = "MISSING_OR_INVALID_BODY" - ErrValidation = "VALIDATION" -) - -func HTTPModule(serviceInfo api.ServiceInfo, bind string, stackURL string, otelTraces bool) fx.Option { - return fx.Options( - fx.Invoke(func(m *mux.Router, lc fx.Lifecycle) { - lc.Append(httpserver.NewHook(m, httpserver.WithAddress(bind))) - }), - fx.Provide(func(store *storage.Storage) service.Store { - return store - }), - fx.Provide(func() *messages.Messages { - return messages.NewMessages(stackURL) - }), - fx.Provide(fx.Annotate(service.New, fx.As(new(backend.Service)))), - fx.Provide(backend.NewDefaultBackend), - fx.Supply(serviceInfo), - fx.Provide(func(b backend.Backend, - logger logging.Logger, - serviceInfo api.ServiceInfo, - a auth.Authenticator) *mux.Router { - return httpRouter(b, logger, serviceInfo, a, otelTraces) - }), - ) -} - -func httpRecoveryFunc(otelTraces bool) func(context.Context, interface{}) { - return func(ctx context.Context, e interface{}) { - if otelTraces { - otlp.RecordAsError(ctx, e) - } else { - logrus.Errorln(e) - debug.PrintStack() - } - } -} - -func httpCorsHandler() func(http.Handler) http.Handler { - return cors.New(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut}, - AllowCredentials: true, - }).Handler -} - -func httpServeFunc(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - handler.ServeHTTP(w, r) - }) -} - -func handleServiceErrors(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, storage.ErrDuplicateKeyValue): - api.BadRequest(w, ErrUniqueReference, err) - case errors.Is(err, storage.ErrNotFound): - api.NotFound(w, err) - case errors.Is(err, storage.ErrValidation): - api.BadRequest(w, ErrValidation, err) - case errors.Is(err, service.ErrValidation): - api.BadRequest(w, ErrValidation, err) - default: - api.InternalServerError(w, r, err) - } -} diff --git a/cmd/api/internal/api/payments.go b/cmd/api/internal/api/payments.go deleted file mode 100644 index e353edbb..00000000 --- a/cmd/api/internal/api/payments.go +++ /dev/null @@ -1,291 +0,0 @@ -package api - -import ( - "encoding/json" - "math/big" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" -) - -type paymentResponse struct { - ID string `json:"id"` - Reference string `json:"reference"` - SourceAccountID string `json:"sourceAccountID"` - DestinationAccountID string `json:"destinationAccountID"` - Type string `json:"type"` - Provider models.ConnectorProvider `json:"provider"` - ConnectorID string `json:"connectorID"` - Status models.PaymentStatus `json:"status"` - Amount *big.Int `json:"amount"` - InitialAmount *big.Int `json:"initialAmount"` - Scheme models.PaymentScheme `json:"scheme"` - Asset string `json:"asset"` - CreatedAt time.Time `json:"createdAt"` - Raw interface{} `json:"raw"` - Adjustments []paymentAdjustment `json:"adjustments"` - Metadata map[string]string `json:"metadata"` -} - -type paymentAdjustment struct { - Reference string `json:"reference" bson:"reference"` - CreatedAt time.Time `json:"createdAt" bson:"createdAt"` - Status models.PaymentStatus `json:"status" bson:"status"` - Amount *big.Int `json:"amount" bson:"amount"` - Raw interface{} `json:"raw" bson:"raw"` -} - -func createPaymentHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "createPaymentHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - var req service.CreatePaymentRequest - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - span.SetAttributes( - attribute.String("request.reference", req.Reference), - attribute.String("request.sourceAccountID", req.SourceAccountID), - attribute.String("request.destinationAccountID", req.DestinationAccountID), - attribute.String("request.type", req.Type), - attribute.String("request.connectorID", req.ConnectorID), - attribute.String("request.scheme", req.Scheme), - attribute.String("request.status", req.Status), - attribute.String("request.asset", req.Asset), - attribute.String("request.amount", req.Amount.String()), - attribute.String("request.createdAt", req.CreatedAt.String()), - ) - - if err := req.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - payment, err := b.GetService().CreatePayment(ctx, &req) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - data := paymentResponse{ - ID: payment.ID.String(), - Reference: payment.Reference, - Type: payment.Type.String(), - ConnectorID: payment.ConnectorID.String(), - Provider: payment.ConnectorID.Provider, - Status: payment.Status, - Amount: payment.Amount, - InitialAmount: payment.InitialAmount, - Scheme: payment.Scheme, - Asset: payment.Asset.String(), - CreatedAt: payment.CreatedAt, - Raw: payment.RawData, - Adjustments: make([]paymentAdjustment, len(payment.Adjustments)), - } - - if payment.SourceAccountID != nil { - data.SourceAccountID = payment.SourceAccountID.String() - } - - if payment.DestinationAccountID != nil { - data.DestinationAccountID = payment.DestinationAccountID.String() - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[paymentResponse]{ - Data: &data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func listPaymentsHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listPaymentsHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - query, err := bunpaginate.Extract[storage.ListPaymentsQuery](r, func() (*storage.ListPaymentsQuery, error) { - options, err := getPagination(r, storage.PaymentQuery{}) - if err != nil { - return nil, err - } - return pointer.For(storage.NewListPaymentsQuery(*options)), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - cursor, err := b.GetService().ListPayments(ctx, *query) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - ret := cursor.Data - data := make([]*paymentResponse, len(ret)) - - for i := range ret { - data[i] = &paymentResponse{ - ID: ret[i].ID.String(), - Reference: ret[i].Reference, - Type: ret[i].Type.String(), - ConnectorID: ret[i].ConnectorID.String(), - Provider: ret[i].Connector.Provider, - Status: ret[i].Status, - Amount: ret[i].Amount, - InitialAmount: ret[i].InitialAmount, - Scheme: ret[i].Scheme, - Asset: ret[i].Asset.String(), - CreatedAt: ret[i].CreatedAt, - Raw: ret[i].RawData, - Adjustments: make([]paymentAdjustment, len(ret[i].Adjustments)), - } - - if ret[i].Connector != nil { - data[i].Provider = ret[i].Connector.Provider - } - - if ret[i].SourceAccountID != nil { - data[i].SourceAccountID = ret[i].SourceAccountID.String() - } - - if ret[i].DestinationAccountID != nil { - data[i].DestinationAccountID = ret[i].DestinationAccountID.String() - } - - for adjustmentIdx := range ret[i].Adjustments { - data[i].Adjustments[adjustmentIdx] = paymentAdjustment{ - Reference: ret[i].Adjustments[adjustmentIdx].Reference, - Status: ret[i].Adjustments[adjustmentIdx].Status, - Amount: ret[i].Adjustments[adjustmentIdx].Amount, - CreatedAt: ret[i].Adjustments[adjustmentIdx].CreatedAt, - Raw: ret[i].Adjustments[adjustmentIdx].RawData, - } - } - - if ret[i].Metadata != nil { - data[i].Metadata = make(map[string]string) - - for metadataIDx := range ret[i].Metadata { - data[i].Metadata[ret[i].Metadata[metadataIDx].Key] = ret[i].Metadata[metadataIDx].Value - } - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[*paymentResponse]{ - Cursor: &bunpaginate.Cursor[*paymentResponse]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func readPaymentHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readPaymentHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - paymentID := mux.Vars(r)["paymentID"] - - span.SetAttributes(attribute.String("request.paymentID", paymentID)) - - payment, err := b.GetService().GetPayment(ctx, paymentID) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - data := paymentResponse{ - ID: payment.ID.String(), - Reference: payment.Reference, - Type: payment.Type.String(), - ConnectorID: payment.ConnectorID.String(), - Status: payment.Status, - Amount: payment.Amount, - InitialAmount: payment.InitialAmount, - Scheme: payment.Scheme, - Asset: payment.Asset.String(), - CreatedAt: payment.CreatedAt, - Raw: payment.RawData, - Adjustments: make([]paymentAdjustment, len(payment.Adjustments)), - } - - if payment.SourceAccountID != nil { - data.SourceAccountID = payment.SourceAccountID.String() - } - - if payment.DestinationAccountID != nil { - data.DestinationAccountID = payment.DestinationAccountID.String() - } - - if payment.Connector != nil { - data.Provider = payment.Connector.Provider - } - - for i := range payment.Adjustments { - data.Adjustments[i] = paymentAdjustment{ - Reference: payment.Adjustments[i].Reference, - Status: payment.Adjustments[i].Status, - Amount: payment.Adjustments[i].Amount, - CreatedAt: payment.Adjustments[i].CreatedAt, - Raw: payment.Adjustments[i].RawData, - } - } - - if payment.Metadata != nil { - data.Metadata = make(map[string]string) - - for metadataIDx := range payment.Metadata { - data.Metadata[payment.Metadata[metadataIDx].Key] = payment.Metadata[metadataIDx].Value - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[paymentResponse]{ - Data: &data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} diff --git a/cmd/api/internal/api/payments_test.go b/cmd/api/internal/api/payments_test.go deleted file mode 100644 index fc4c513b..00000000 --- a/cmd/api/internal/api/payments_test.go +++ /dev/null @@ -1,971 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math/big" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestCreatePayments(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.CreatePaymentRequest - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - sourceAccountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - destinationAccountID := models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - } - - testCases := []testCase{ - { - name: "nomimal", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - }, - { - name: "no source account id, but should still pass", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - DestinationAccountID: destinationAccountID.String(), - }, - }, - { - name: "no destination account id, but should still pass", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - }, - }, - { - name: "missing reference", - req: &service.CreatePaymentRequest{ - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing createdAt", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "created at to zero", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Time{}, - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing connectorID", - req: &service.CreatePaymentRequest{ - Reference: "test", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing amount", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing type", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid type", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: "invalid", - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing status", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid status", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: "invalid", - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing scheme", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid scheme", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: "invalid", - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing asset", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid asset", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "invalid", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - req: &service.CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - createPaymentResponse := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: testCase.req.Reference, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: testCase.req.CreatedAt, - Reference: testCase.req.Reference, - Amount: testCase.req.Amount, - InitialAmount: testCase.req.Amount, - Type: models.PaymentTypeTransfer, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeOther, - Asset: models.Asset("EUR/2"), - SourceAccountID: &sourceAccountID, - DestinationAccountID: &destinationAccountID, - } - - expectedCreatePaymentResponse := &paymentResponse{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "test", - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }.String(), - Reference: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - Type: string(createPaymentResponse.Type), - Provider: createPaymentResponse.ConnectorID.Provider, - ConnectorID: createPaymentResponse.ConnectorID.String(), - Status: createPaymentResponse.Status, - InitialAmount: createPaymentResponse.Amount, - Amount: createPaymentResponse.Amount, - Scheme: createPaymentResponse.Scheme, - Asset: createPaymentResponse.Asset.String(), - CreatedAt: createPaymentResponse.CreatedAt, - Adjustments: make([]paymentAdjustment, len(createPaymentResponse.Adjustments)), - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - CreatePayment(gomock.Any(), testCase.req). - Return(createPaymentResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - CreatePayment(gomock.Any(), testCase.req). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, "/payments", bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[paymentResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedCreatePaymentResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestPayments(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - queryParams url.Values - pageSize int - expectedQuery storage.ListPaymentsQuery - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nomimal", - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too low", - queryParams: url.Values{ - "pageSize": {"0"}, - }, - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - }, - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(100), - ), - pageSize: 100, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid query builder json", - queryParams: url.Values{ - "query": {"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "valid query builder json", - queryParams: url.Values{ - "query": {"{\"$match\": {\"source_account_id\": \"acc1\"}}"}, - }, - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15). - WithQueryBuilder(query.Match("source_account_id", "acc1")), - ), - pageSize: 15, - }, - { - name: "valid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:asc"}, - }, - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15). - WithSorter(storage.Sorter{}.Add("source_account_id", storage.SortOrderAsc)), - ), - pageSize: 15, - }, - { - name: "invalid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15), - ), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - expectedQuery: storage.NewListPaymentsQuery( - storage.NewPaginatedQueryOptions(storage.PaymentQuery{}). - WithPageSize(15), - ), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - payments := []models.Payment{ - { - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Reference: "p1", - Amount: big.NewInt(100), - InitialAmount: big.NewInt(1000), - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusPending, - Scheme: models.PaymentSchemeCardMasterCard, - Asset: models.Asset("USD/2"), - SourceAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - Connector: &models.Connector{ - Provider: models.ConnectorProviderDummyPay, - }, - }, - { - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p2", - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 22, 9, 0, 0, 0, time.UTC), - Reference: "p2", - Amount: big.NewInt(1000), - InitialAmount: big.NewInt(10000), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeCardVisa, - Asset: models.Asset("EUR/2"), - DestinationAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - Connector: &models.Connector{ - Provider: models.ConnectorProviderDummyPay, - }, - }, - } - listPaymentsResponse := &bunpaginate.Cursor[models.Payment]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: payments, - } - - expectedPaymentsResponse := []*paymentResponse{ - { - ID: payments[0].ID.String(), - Reference: payments[0].Reference, - SourceAccountID: payments[0].SourceAccountID.String(), - DestinationAccountID: payments[0].DestinationAccountID.String(), - Type: payments[0].Type.String(), - Provider: payments[0].Connector.Provider, - ConnectorID: payments[0].ConnectorID.String(), - Status: payments[0].Status, - InitialAmount: payments[0].InitialAmount, - Amount: payments[0].Amount, - Scheme: payments[0].Scheme, - Asset: payments[0].Asset.String(), - CreatedAt: payments[0].CreatedAt, - Adjustments: make([]paymentAdjustment, len(payments[0].Adjustments)), - }, - { - ID: payments[1].ID.String(), - Reference: payments[1].Reference, - SourceAccountID: payments[1].SourceAccountID.String(), - DestinationAccountID: payments[1].DestinationAccountID.String(), - Type: payments[1].Type.String(), - Provider: payments[1].Connector.Provider, - ConnectorID: payments[1].ConnectorID.String(), - Status: payments[1].Status, - InitialAmount: payments[1].InitialAmount, - Amount: payments[1].Amount, - Scheme: payments[1].Scheme, - Asset: payments[1].Asset.String(), - CreatedAt: payments[1].CreatedAt, - Adjustments: make([]paymentAdjustment, len(payments[0].Adjustments)), - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListPayments(gomock.Any(), testCase.expectedQuery). - Return(listPaymentsResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListPayments(gomock.Any(), testCase.expectedQuery). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, "/payments", nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[*paymentResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedPaymentsResponse, resp.Cursor.Data) - require.Equal(t, listPaymentsResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listPaymentsResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listPaymentsResponse.Next, resp.Cursor.Next) - require.Equal(t, listPaymentsResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestGetPayment(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - paymentID1 := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - paymentID2 := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p2", - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - - type testCase struct { - name string - paymentID string - expectedPaymentID models.PaymentID - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nomimal p1", - paymentID: paymentID1.String(), - expectedPaymentID: paymentID1, - }, - { - name: "nomimal p2", - paymentID: paymentID2.String(), - expectedPaymentID: paymentID2, - }, - { - name: "err validation from backend", - paymentID: paymentID1.String(), - expectedPaymentID: paymentID1, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - paymentID: paymentID1.String(), - expectedPaymentID: paymentID1, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - paymentID: paymentID1.String(), - expectedPaymentID: paymentID1, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - paymentID: paymentID1.String(), - expectedPaymentID: paymentID1, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - var getPaymentResponse *models.Payment - var expectedPaymentResponse *paymentResponse - if testCase.expectedPaymentID == paymentID1 { - getPaymentResponse = &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Reference: "p1", - Amount: big.NewInt(100), - InitialAmount: big.NewInt(1000), - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusPending, - Scheme: models.PaymentSchemeCardMasterCard, - Asset: models.Asset("USD/2"), - SourceAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - Connector: &models.Connector{ - Provider: models.ConnectorProviderDummyPay, - }, - } - - expectedPaymentResponse = &paymentResponse{ - ID: getPaymentResponse.ID.String(), - Reference: getPaymentResponse.Reference, - SourceAccountID: getPaymentResponse.SourceAccountID.String(), - DestinationAccountID: getPaymentResponse.DestinationAccountID.String(), - Type: getPaymentResponse.Type.String(), - Provider: getPaymentResponse.Connector.Provider, - ConnectorID: getPaymentResponse.ConnectorID.String(), - Status: getPaymentResponse.Status, - InitialAmount: getPaymentResponse.InitialAmount, - Amount: getPaymentResponse.Amount, - Scheme: getPaymentResponse.Scheme, - Asset: getPaymentResponse.Asset.String(), - CreatedAt: getPaymentResponse.CreatedAt, - Adjustments: make([]paymentAdjustment, len(getPaymentResponse.Adjustments)), - } - } else { - getPaymentResponse = &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p2", - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 22, 9, 0, 0, 0, time.UTC), - Reference: "p2", - Amount: big.NewInt(1000), - InitialAmount: big.NewInt(10000), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeCardVisa, - Asset: models.Asset("EUR/2"), - DestinationAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - Connector: &models.Connector{ - Provider: models.ConnectorProviderDummyPay, - }, - } - expectedPaymentResponse = &paymentResponse{ - ID: getPaymentResponse.ID.String(), - Reference: getPaymentResponse.Reference, - SourceAccountID: getPaymentResponse.SourceAccountID.String(), - DestinationAccountID: getPaymentResponse.DestinationAccountID.String(), - Type: getPaymentResponse.Type.String(), - Provider: getPaymentResponse.Connector.Provider, - ConnectorID: getPaymentResponse.ConnectorID.String(), - Status: getPaymentResponse.Status, - InitialAmount: getPaymentResponse.InitialAmount, - Amount: getPaymentResponse.Amount, - Scheme: getPaymentResponse.Scheme, - Asset: getPaymentResponse.Asset.String(), - CreatedAt: getPaymentResponse.CreatedAt, - Adjustments: make([]paymentAdjustment, len(getPaymentResponse.Adjustments)), - } - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - GetPayment(gomock.Any(), testCase.expectedPaymentID.String()). - Return(getPaymentResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - GetPayment(gomock.Any(), testCase.expectedPaymentID.String()). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/payments/%s", testCase.paymentID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[paymentResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedPaymentResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/pools.go b/cmd/api/internal/api/pools.go deleted file mode 100644 index 52858deb..00000000 --- a/cmd/api/internal/api/pools.go +++ /dev/null @@ -1,353 +0,0 @@ -package api - -import ( - "encoding/json" - "math/big" - "net/http" - "strings" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type poolResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Accounts []string `json:"accounts"` -} - -func createPoolHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "createPoolHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - var createPoolRequest service.CreatePoolRequest - err := json.NewDecoder(r.Body).Decode(&createPoolRequest) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - span.SetAttributes( - attribute.String("request.name", createPoolRequest.Name), - attribute.String("request.accounts", strings.Join(createPoolRequest.AccountIDs, ",")), - ) - - if err := createPoolRequest.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - pool, err := b.GetService().CreatePool(ctx, &createPoolRequest) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - accounts := make([]string, len(pool.PoolAccounts)) - for i := range pool.PoolAccounts { - accounts[i] = pool.PoolAccounts[i].AccountID.String() - } - - data := &poolResponse{ - ID: pool.ID.String(), - Name: pool.Name, - Accounts: accounts, - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[poolResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func addAccountToPoolHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "addAccountToPoolHandler") - defer span.End() - - poolID, ok := mux.Vars(r)["poolID"] - if !ok { - var err = errors.New("missing poolID") - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.poolID", poolID)) - - var addAccountToPoolRequest service.AddAccountToPoolRequest - err := json.NewDecoder(r.Body).Decode(&addAccountToPoolRequest) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - span.SetAttributes( - attribute.String("request.accountID", addAccountToPoolRequest.AccountID), - ) - - if err := addAccountToPoolRequest.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - err = b.GetService().AddAccountToPool(ctx, poolID, &addAccountToPoolRequest) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -func removeAccountFromPoolHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "removeAccountFromPoolHandler") - defer span.End() - - poolID, ok := mux.Vars(r)["poolID"] - if !ok { - var err = errors.New("missing poolID") - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.poolID", poolID)) - - accountID, ok := mux.Vars(r)["accountID"] - if !ok { - var err = errors.New("missing accountID") - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.accountID", accountID)) - - err := b.GetService().RemoveAccountFromPool(ctx, poolID, accountID) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -func listPoolHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listPoolHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - query, err := bunpaginate.Extract[storage.ListPoolsQuery](r, func() (*storage.ListPoolsQuery, error) { - options, err := getPagination(r, storage.PoolQuery{}) - if err != nil { - return nil, err - } - return pointer.For(storage.NewListPoolsQuery(*options)), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - cursor, err := b.GetService().ListPools(ctx, *query) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - ret := cursor.Data - data := make([]*poolResponse, len(ret)) - - for i := range ret { - accounts := make([]string, len(ret[i].PoolAccounts)) - for j := range ret[i].PoolAccounts { - accounts[j] = ret[i].PoolAccounts[j].AccountID.String() - } - - data[i] = &poolResponse{ - ID: ret[i].ID.String(), - Name: ret[i].Name, - Accounts: accounts, - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[*poolResponse]{ - Cursor: &bunpaginate.Cursor[*poolResponse]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func getPoolHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "getPoolHandler") - defer span.End() - - poolID, ok := mux.Vars(r)["poolID"] - if !ok { - err := errors.New("missing poolID") - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.poolID", poolID)) - - pool, err := b.GetService().GetPool(ctx, poolID) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - accounts := make([]string, len(pool.PoolAccounts)) - for i := range pool.PoolAccounts { - accounts[i] = pool.PoolAccounts[i].AccountID.String() - } - - data := &poolResponse{ - ID: pool.ID.String(), - Name: pool.Name, - Accounts: accounts, - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[poolResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -type poolBalancesResponse struct { - Balances []*poolBalanceResponse `json:"balances"` -} - -type poolBalanceResponse struct { - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` -} - -func getPoolBalances(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "getPoolBalances") - defer span.End() - - poolID, ok := mux.Vars(r)["poolID"] - if !ok { - var err = errors.New("missing poolID") - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.poolID", poolID)) - - atTime := r.URL.Query().Get("at") - if atTime == "" { - var err = errors.New("missing atTime") - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - span.SetAttributes(attribute.String("request.atTime", atTime)) - - balance, err := b.GetService().GetPoolBalance(ctx, poolID, atTime) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - data := &poolBalancesResponse{ - Balances: make([]*poolBalanceResponse, len(balance.Balances)), - } - - for i := range balance.Balances { - data.Balances[i] = &poolBalanceResponse{ - Amount: balance.Balances[i].Amount, - Asset: balance.Balances[i].Asset, - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[poolBalancesResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func deletePoolHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "deletePoolHandler") - defer span.End() - - poolID, ok := mux.Vars(r)["poolID"] - if !ok { - var err = errors.New("missing poolID") - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.poolID", poolID)) - - err := b.GetService().DeletePool(ctx, poolID) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/cmd/api/internal/api/pools_test.go b/cmd/api/internal/api/pools_test.go deleted file mode 100644 index db1db5d2..00000000 --- a/cmd/api/internal/api/pools_test.go +++ /dev/null @@ -1,1043 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math/big" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestCreatePool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.CreatePoolRequest - expectedStatusCode int - serviceError error - expectedErrorCode string - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - uuid1 := uuid.New() - - testCases := []testCase{ - { - name: "nominal", - req: &service.CreatePoolRequest{ - Name: "test", - AccountIDs: []string{accountID.String()}, - }, - }, - { - name: "no accounts", - req: &service.CreatePoolRequest{ - Name: "test", - AccountIDs: []string{}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing name", - req: &service.CreatePoolRequest{ - Name: "", - AccountIDs: []string{accountID.String()}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - req: &service.CreatePoolRequest{ - Name: "test", - AccountIDs: []string{accountID.String()}, - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - req: &service.CreatePoolRequest{ - Name: "test", - AccountIDs: []string{accountID.String()}, - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - req: &service.CreatePoolRequest{ - Name: "test", - AccountIDs: []string{accountID.String()}, - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - req: &service.CreatePoolRequest{ - Name: "test", - AccountIDs: []string{accountID.String()}, - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - createPoolResponse := &models.Pool{ - ID: uuid1, - Name: testCase.req.Name, - PoolAccounts: []*models.PoolAccounts{ - { - PoolID: uuid1, - AccountID: accountID, - }, - }, - } - - accounts := make([]string, len(createPoolResponse.PoolAccounts)) - for i := range createPoolResponse.PoolAccounts { - accounts[i] = createPoolResponse.PoolAccounts[i].AccountID.String() - } - expectedCreatePoolResponse := &poolResponse{ - ID: createPoolResponse.ID.String(), - Name: createPoolResponse.Name, - Accounts: accounts, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - CreatePool(gomock.Any(), testCase.req). - Return(createPoolResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - CreatePool(gomock.Any(), testCase.req). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, "/pools", bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[poolResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedCreatePoolResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestAddAccountToPool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.AddAccountToPoolRequest - poolID string - expectedStatusCode int - serviceError error - expectedErrorCode string - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - uuid1 := uuid.New() - - testCases := []testCase{ - { - name: "nominal", - req: &service.AddAccountToPoolRequest{ - AccountID: accountID.String(), - }, - poolID: uuid1.String(), - }, - { - name: "missing accountID", - req: &service.AddAccountToPoolRequest{ - AccountID: "", - }, - poolID: uuid1.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing body", - poolID: uuid1.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "err validation from backend", - poolID: uuid1.String(), - req: &service.AddAccountToPoolRequest{ - AccountID: accountID.String(), - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - poolID: uuid1.String(), - req: &service.AddAccountToPoolRequest{ - AccountID: accountID.String(), - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - poolID: uuid1.String(), - req: &service.AddAccountToPoolRequest{ - AccountID: accountID.String(), - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - poolID: uuid1.String(), - req: &service.AddAccountToPoolRequest{ - AccountID: accountID.String(), - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - AddAccountToPool(gomock.Any(), testCase.poolID, testCase.req). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - AddAccountToPool(gomock.Any(), testCase.poolID, testCase.req). - Return(testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/pools/%s/accounts", testCase.poolID), bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } - -} - -func TestRemoveAccountFromPool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - accountID string - serviceError error - expectedStatusCode int - expectedErrorCode string - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - uuid1 := uuid.New() - - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - accountID: accountID.String(), - }, - { - name: "err validation from backend", - poolID: uuid1.String(), - accountID: accountID.String(), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - poolID: uuid1.String(), - accountID: accountID.String(), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - poolID: uuid1.String(), - accountID: accountID.String(), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - poolID: uuid1.String(), - accountID: accountID.String(), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - RemoveAccountFromPool(gomock.Any(), testCase.poolID, testCase.accountID). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - RemoveAccountFromPool(gomock.Any(), testCase.poolID, testCase.accountID). - Return(testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/pools/%s/accounts/%s", testCase.poolID, testCase.accountID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestListPools(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - queryParams url.Values - pageSize int - expectedQuery storage.ListPoolsQuery - expectedStatusCode int - serviceError error - expectedErrorCode string - } - - testCases := []testCase{ - { - name: "nomimal", - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too low", - queryParams: url.Values{ - "pageSize": {"0"}, - }, - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - }, - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(100), - ), - pageSize: 100, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid query builder json", - queryParams: url.Values{ - "query": {"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "valid query builder json", - queryParams: url.Values{ - "query": {"{\"$match\": {\"source_account_id\": \"acc1\"}}"}, - }, - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15). - WithQueryBuilder(query.Match("source_account_id", "acc1")), - ), - pageSize: 15, - }, - { - name: "valid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:asc"}, - }, - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15). - WithSorter(storage.Sorter{}.Add("source_account_id", storage.SortOrderAsc)), - ), - pageSize: 15, - }, - { - name: "invalid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - - { - name: "err validation from backend", - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15), - ), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - expectedQuery: storage.NewListPoolsQuery( - storage.NewPaginatedQueryOptions(storage.PoolQuery{}). - WithPageSize(15), - ), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - poolID1 := uuid.New() - poolID2 := uuid.New() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - accountID1 := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - accountID2 := models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - pools := []models.Pool{ - { - ID: poolID1, - Name: "test1", - PoolAccounts: []*models.PoolAccounts{ - { - PoolID: poolID1, - AccountID: accountID1, - }, - }, - }, - { - ID: poolID2, - Name: "test2", - PoolAccounts: []*models.PoolAccounts{ - { - PoolID: poolID2, - AccountID: accountID1, - }, - { - PoolID: poolID2, - AccountID: accountID2, - }, - }, - }, - } - listPoolsResponse := &bunpaginate.Cursor[models.Pool]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: pools, - } - - accounts1 := make([]string, len(pools[0].PoolAccounts)) - for i := range pools[0].PoolAccounts { - accounts1[i] = pools[0].PoolAccounts[i].AccountID.String() - } - - accounts2 := make([]string, len(pools[1].PoolAccounts)) - for i := range pools[1].PoolAccounts { - accounts2[i] = pools[1].PoolAccounts[i].AccountID.String() - } - expectedListPoolsResponse := []*poolResponse{ - { - ID: pools[0].ID.String(), - Name: pools[0].Name, - Accounts: accounts1, - }, - { - ID: pools[1].ID.String(), - Name: pools[1].Name, - Accounts: accounts2, - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListPools(gomock.Any(), testCase.expectedQuery). - Return(listPoolsResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListPools(gomock.Any(), testCase.expectedQuery). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, "/pools", nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[*poolResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedListPoolsResponse, resp.Cursor.Data) - require.Equal(t, listPoolsResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listPoolsResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listPoolsResponse.Next, resp.Cursor.Next) - require.Equal(t, listPoolsResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestGetPool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - serviceError error - expectedPoolID uuid.UUID - expectedStatusCode int - expectedErrorCode string - } - - uuid1 := uuid.New() - testCases := []testCase{ - { - name: "nomimal", - poolID: uuid1.String(), - expectedPoolID: uuid1, - }, - { - name: "err validation from backend", - poolID: uuid1.String(), - expectedPoolID: uuid1, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - poolID: uuid1.String(), - expectedPoolID: uuid1, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - poolID: uuid1.String(), - expectedPoolID: uuid1, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - poolID: uuid1.String(), - expectedPoolID: uuid1, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - accountID1 := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - getPoolResponse := &models.Pool{ - ID: uuid1, - Name: "test1", - PoolAccounts: []*models.PoolAccounts{ - { - PoolID: uuid1, - AccountID: accountID1, - }, - }, - } - - accounts := make([]string, len(getPoolResponse.PoolAccounts)) - for i := range getPoolResponse.PoolAccounts { - accounts[i] = getPoolResponse.PoolAccounts[i].AccountID.String() - } - expectedPoolResponse := &poolResponse{ - ID: uuid1.String(), - Name: getPoolResponse.Name, - Accounts: accounts, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - GetPool(gomock.Any(), testCase.poolID). - Return(getPoolResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - GetPool(gomock.Any(), testCase.poolID). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/pools/%s", testCase.poolID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[poolResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedPoolResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestGetPoolBalance(t *testing.T) { - t.Parallel() - - uuid1 := uuid.New() - type testCase struct { - name string - queryParams url.Values - poolID string - serviceError error - expectedStatusCode int - expectedErrorCode string - } - - atTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UTC() - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - queryParams: url.Values{ - "at": {atTime.Format(time.RFC3339)}, - }, - }, - { - name: "missing at", - poolID: uuid1.String(), - - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - poolID: uuid1.String(), - queryParams: url.Values{ - "at": {atTime.Format(time.RFC3339)}, - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - poolID: uuid1.String(), - queryParams: url.Values{ - "at": {atTime.Format(time.RFC3339)}, - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - poolID: uuid1.String(), - queryParams: url.Values{ - "at": {atTime.Format(time.RFC3339)}, - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - poolID: uuid1.String(), - queryParams: url.Values{ - "at": {atTime.Format(time.RFC3339)}, - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - getPoolBalanceResponse := &service.GetPoolBalanceResponse{ - Balances: []*service.Balance{ - { - Amount: big.NewInt(100), - Asset: "EUR/2", - }, - { - Amount: big.NewInt(12000), - Asset: "USD/2", - }, - }, - } - - expectedPoolBalancesResponse := &poolBalancesResponse{ - Balances: []*poolBalanceResponse{ - { - Amount: getPoolBalanceResponse.Balances[0].Amount, - Asset: getPoolBalanceResponse.Balances[0].Asset, - }, - { - Amount: getPoolBalanceResponse.Balances[1].Amount, - Asset: getPoolBalanceResponse.Balances[1].Asset, - }, - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - GetPoolBalance(gomock.Any(), testCase.poolID, atTime.Format(time.RFC3339)). - Return(getPoolBalanceResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - GetPoolBalance(gomock.Any(), testCase.poolID, atTime.Format(time.RFC3339)). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/pools/%s/balances", testCase.poolID), nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[poolBalancesResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedPoolBalancesResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } - -} - -func TestDeletePool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - serviceError error - expectedStatusCode int - expectedErrorCode string - } - - uuid1 := uuid.New() - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - }, - { - name: "err validation from backend", - poolID: uuid1.String(), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - poolID: uuid1.String(), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - poolID: uuid1.String(), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - poolID: uuid1.String(), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - DeletePool(gomock.Any(), testCase.poolID). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - DeletePool(gomock.Any(), testCase.poolID). - Return(testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/pools/%s", testCase.poolID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/recovery.go b/cmd/api/internal/api/recovery.go deleted file mode 100644 index c856fd0f..00000000 --- a/cmd/api/internal/api/recovery.go +++ /dev/null @@ -1,23 +0,0 @@ -package api - -import ( - "context" - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/pkg/errors" -) - -func recoveryHandler(reporter func(ctx context.Context, e interface{})) func(h http.Handler) http.Handler { - return func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if e := recover(); e != nil { - api.InternalServerError(w, r, errors.New("Internal Server Error")) - reporter(r.Context(), e) - } - }() - h.ServeHTTP(w, r) - }) - } -} diff --git a/cmd/api/internal/api/router.go b/cmd/api/internal/api/router.go deleted file mode 100644 index b55c237b..00000000 --- a/cmd/api/internal/api/router.go +++ /dev/null @@ -1,74 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/gorilla/mux" - "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux" -) - -func httpRouter( - b backend.Backend, - logger logging.Logger, - serviceInfo api.ServiceInfo, - a auth.Authenticator, - otelTraces bool, -) *mux.Router { - rootMux := mux.NewRouter() - - // We have to keep this recovery handler here to ensure that the health - // endpoint is not panicking - rootMux.Use(recoveryHandler(httpRecoveryFunc(otelTraces))) - rootMux.Use(httpCorsHandler()) - rootMux.Use(httpServeFunc) - rootMux.Use(func(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler.ServeHTTP(w, r.WithContext(logging.ContextWithLogger(r.Context(), logger))) - }) - }) - - rootMux.Path("/_health").Handler(healthHandler(b)) - - subRouter := rootMux.NewRoute().Subrouter() - if otelTraces { - subRouter.Use(otelmux.Middleware(serviceName)) - // Add a second recovery handler to ensure that the otel middleware - // is catching the error in the trace - rootMux.Use(recoveryHandler(httpRecoveryFunc(otelTraces))) - } - subRouter.Path("/_live").Handler(liveHandler()) - subRouter.Path("/_info").Handler(api.InfoHandler(serviceInfo)) - - authGroup := subRouter.Name("authenticated").Subrouter() - authGroup.Use(auth.Middleware(a)) - - authGroup.Path("/payments").Methods(http.MethodPost).Handler(createPaymentHandler(b)) - authGroup.Path("/payments").Methods(http.MethodGet).Handler(listPaymentsHandler(b)) - authGroup.Path("/payments/{paymentID}").Methods(http.MethodGet).Handler(readPaymentHandler(b)) - authGroup.Path("/payments/{paymentID}/metadata").Methods(http.MethodPatch).Handler(updateMetadataHandler(b)) - - authGroup.Path("/accounts").Methods(http.MethodPost).Handler(createAccountHandler(b)) - authGroup.Path("/accounts").Methods(http.MethodGet).Handler(listAccountsHandler(b)) - authGroup.Path("/accounts/{accountID}").Methods(http.MethodGet).Handler(readAccountHandler(b)) - authGroup.Path("/accounts/{accountID}/balances").Methods(http.MethodGet).Handler(listBalancesForAccount(b)) - - authGroup.Path("/bank-accounts").Methods(http.MethodGet).Handler(listBankAccountsHandler(b)) - authGroup.Path("/bank-accounts/{bankAccountID}").Methods(http.MethodGet).Handler(readBankAccountHandler(b)) - - authGroup.Path("/transfer-initiations").Methods(http.MethodGet).Handler(listTransferInitiationsHandler(b)) - authGroup.Path("/transfer-initiations/{transferID}").Methods(http.MethodGet).Handler(readTransferInitiationHandler(b)) - - authGroup.Path("/pools").Methods(http.MethodPost).Handler(createPoolHandler(b)) - authGroup.Path("/pools").Methods(http.MethodGet).Handler(listPoolHandler(b)) - authGroup.Path("/pools/{poolID}").Methods(http.MethodGet).Handler(getPoolHandler(b)) - authGroup.Path("/pools/{poolID}").Methods(http.MethodDelete).Handler(deletePoolHandler(b)) - authGroup.Path("/pools/{poolID}/accounts").Methods(http.MethodPost).Handler(addAccountToPoolHandler(b)) - authGroup.Path("/pools/{poolID}/accounts/{accountID}").Methods(http.MethodDelete).Handler(removeAccountFromPoolHandler(b)) - authGroup.Path("/pools/{poolID}/balances").Methods(http.MethodGet).Handler(getPoolBalances(b)) - - return rootMux -} diff --git a/cmd/api/internal/api/service/accounts.go b/cmd/api/internal/api/service/accounts.go deleted file mode 100644 index ee061a9e..00000000 --- a/cmd/api/internal/api/service/accounts.go +++ /dev/null @@ -1,126 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/pkg/errors" -) - -type CreateAccountRequest struct { - Reference string `json:"reference"` - ConnectorID string `json:"connectorID"` - CreatedAt time.Time `json:"createdAt"` - DefaultAsset string `json:"defaultAsset"` - AccountName string `json:"accountName"` - Type string `json:"type"` - Metadata map[string]string `json:"metadata"` -} - -func (r *CreateAccountRequest) Validate() error { - if r.Reference == "" { - return errors.New("reference is required") - } - - if r.ConnectorID == "" { - return errors.New("connectorID is required") - } - - if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { - return errors.New("createdAt is empty or in the future") - } - - if r.AccountName == "" { - return errors.New("accountName is required") - } - - if r.Type == "" { - return errors.New("type is required") - } - - _, err := models.AccountTypeFromString(r.Type) - if err != nil { - return err - } - - return nil -} - -func (s *Service) CreateAccount(ctx context.Context, req *CreateAccountRequest) (*models.Account, error) { - connectorID, err := models.ConnectorIDFromString(req.ConnectorID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - isInstalled, err := s.store.IsConnectorInstalledByConnectorID(ctx, connectorID) - if err != nil { - return nil, newStorageError(err, "checking if connector is installed") - } - - if !isInstalled { - return nil, errors.Wrap(ErrValidation, "connector is not installed") - } - - accountType, err := models.AccountTypeFromString(req.Type) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - raw, err := json.Marshal(req) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - account := &models.Account{ - ID: models.AccountID{ - Reference: req.Reference, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: req.CreatedAt, - Reference: req.Reference, - DefaultAsset: models.Asset(req.DefaultAsset), - AccountName: req.AccountName, - Type: accountType, - Metadata: req.Metadata, - RawData: raw, - } - - err = s.store.UpsertAccounts(ctx, []*models.Account{account}) - if err != nil { - return nil, newStorageError(err, "creating account") - } - - err = s.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, s.messages.NewEventSavedAccounts(connectorID.Provider, account))) - if err != nil { - return nil, errors.Wrap(err, "publishing message") - } - - return account, nil -} - -func (s *Service) ListAccounts(ctx context.Context, q storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { - cursor, err := s.store.ListAccounts(ctx, q) - return cursor, newStorageError(err, "listing accounts") -} - -func (s *Service) GetAccount( - ctx context.Context, - accountID string, -) (*models.Account, error) { - _, err := models.AccountIDFromString(accountID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - account, err := s.store.GetAccount(ctx, accountID) - return account, newStorageError(err, "getting account") -} diff --git a/cmd/api/internal/api/service/accounts_test.go b/cmd/api/internal/api/service/accounts_test.go deleted file mode 100644 index 8ee2de2e..00000000 --- a/cmd/api/internal/api/service/accounts_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestCreateAccout(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - request *CreateAccountRequest - isConnectorInstalled bool - expectedError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - testCases := []testCase{ - { - name: "nominal", - request: &CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - isConnectorInstalled: true, - }, - { - name: "nominal without default asset", - request: &CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - isConnectorInstalled: true, - }, - { - name: "connector not installed", - request: &CreateAccountRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - isConnectorInstalled: false, - expectedError: ErrValidation, - }, - { - name: "invalid connectorID", - request: &CreateAccountRequest{ - Reference: "test", - ConnectorID: "invalid", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - DefaultAsset: "USD/2", - AccountName: "test", - Type: "INTERNAL", - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedError: ErrValidation, - isConnectorInstalled: true, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - store := &MockStore{} - service := New(store.WithIsConnectorInstalled(tc.isConnectorInstalled), &MockPublisher{}, messages.NewMessages("")) - p, err := service.CreateAccount(context.Background(), tc.request) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - require.NotNil(t, p) - } - }) - } -} - -func TestGetAccount(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - accountID string - expectedError error - } - - accountID := models.AccountID{ - Reference: "a1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - accountID: accountID.String(), - expectedError: nil, - }, - { - name: "invalid accountID", - accountID: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - _, err := service.GetAccount(context.Background(), tc.accountID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/cmd/api/internal/api/service/balance.go b/cmd/api/internal/api/service/balance.go deleted file mode 100644 index 75970346..00000000 --- a/cmd/api/internal/api/service/balance.go +++ /dev/null @@ -1,15 +0,0 @@ -package service - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" -) - -func (s *Service) ListBalances(ctx context.Context, q storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { - cursor, err := s.store.ListBalances(ctx, q) - return cursor, newStorageError(err, "listing balances") -} diff --git a/cmd/api/internal/api/service/bank_accounts.go b/cmd/api/internal/api/service/bank_accounts.go deleted file mode 100644 index c23612dc..00000000 --- a/cmd/api/internal/api/service/bank_accounts.go +++ /dev/null @@ -1,21 +0,0 @@ -package service - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -func (s *Service) ListBankAccounts(ctx context.Context, q storage.ListBankAccountQuery) (*bunpaginate.Cursor[models.BankAccount], error) { - cursor, err := s.store.ListBankAccounts(ctx, q) - return cursor, newStorageError(err, "listing bank accounts") -} - -func (s *Service) GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { - account, err := s.store.GetBankAccount(ctx, id, expand) - return account, newStorageError(err, "getting bank account") -} diff --git a/cmd/api/internal/api/service/errors.go b/cmd/api/internal/api/service/errors.go deleted file mode 100644 index 7c1fe161..00000000 --- a/cmd/api/internal/api/service/errors.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "errors" - "fmt" -) - -var ( - ErrValidation = errors.New("validation error") -) - -type storageError struct { - err error - msg string -} - -func (e *storageError) Error() string { - return fmt.Sprintf("%s: %s", e.msg, e.err) -} - -func (e *storageError) Is(err error) bool { - _, ok := err.(*storageError) - return ok -} - -func (e *storageError) Unwrap() error { - return e.err -} - -func newStorageError(err error, msg string) error { - if err == nil { - return nil - } - return &storageError{ - err: err, - msg: msg, - } -} diff --git a/cmd/api/internal/api/service/payments.go b/cmd/api/internal/api/service/payments.go deleted file mode 100644 index 64d9b199..00000000 --- a/cmd/api/internal/api/service/payments.go +++ /dev/null @@ -1,176 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "math/big" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/pkg/errors" -) - -type CreatePaymentRequest struct { - Reference string `json:"reference"` - ConnectorID string `json:"connectorID"` - CreatedAt time.Time `json:"createdAt"` - Amount *big.Int `json:"amount"` - Type string `json:"type"` - Status string `json:"status"` - Scheme string `json:"scheme"` - Asset string `json:"asset"` - SourceAccountID string `json:"sourceAccountID"` - DestinationAccountID string `json:"destinationAccountID"` -} - -func (r *CreatePaymentRequest) Validate() error { - if r.Reference == "" { - return errors.New("reference is required") - } - - if r.ConnectorID == "" { - return errors.New("connectorID is required") - } - - if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { - return errors.New("createdAt is empty or in the future") - } - - if r.Amount == nil { - return errors.New("amount is required") - } - - if r.Type == "" { - return errors.New("type is required") - } - - if _, err := models.PaymentTypeFromString(r.Type); err != nil { - return errors.Wrap(err, "invalid type") - } - - if r.Status == "" { - return errors.New("status is required") - } - - if _, err := models.PaymentStatusFromString(r.Status); err != nil { - return errors.Wrap(err, "invalid status") - } - - if r.Scheme == "" { - return errors.New("scheme is required") - } - - if _, err := models.PaymentSchemeFromString(r.Scheme); err != nil { - return errors.Wrap(err, "invalid scheme") - } - - if r.Asset == "" { - return errors.New("asset is required") - } - - _, _, err := models.GetCurrencyAndPrecisionFromAsset(models.Asset(r.Asset)) - if err != nil { - return errors.Wrap(err, "invalid asset") - } - - return nil -} - -func (s *Service) CreatePayment(ctx context.Context, req *CreatePaymentRequest) (*models.Payment, error) { - connectorID, err := models.ConnectorIDFromString(req.ConnectorID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - isInstalled, err := s.store.IsConnectorInstalledByConnectorID(ctx, connectorID) - if err != nil { - return nil, newStorageError(err, "checking if connector is installed") - } - - if !isInstalled { - return nil, errors.Wrap(ErrValidation, "connector is not installed") - } - - var sourceAccountID *models.AccountID - if req.SourceAccountID != "" { - sourceAccountID, err = models.AccountIDFromString(req.SourceAccountID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - } - - var destinationAccountID *models.AccountID - if req.DestinationAccountID != "" { - destinationAccountID, err = models.AccountIDFromString(req.DestinationAccountID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - } - - raw, err := json.Marshal(req) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: req.Reference, - Type: models.PaymentType(req.Type), - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: req.CreatedAt, - Reference: req.Reference, - Amount: req.Amount, - InitialAmount: req.Amount, - Type: models.PaymentType(req.Type), - Status: models.PaymentStatus(req.Status), - Scheme: models.PaymentScheme(req.Scheme), - Asset: models.Asset(req.Asset), - SourceAccountID: sourceAccountID, - DestinationAccountID: destinationAccountID, - RawData: raw, - } - - err = s.store.UpsertPayments(ctx, []*models.Payment{payment}) - if err != nil { - return nil, newStorageError(err, "creating payment") - } - - err = s.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, s.messages.NewEventSavedPayments(connectorID.Provider, payment))) - if err != nil { - return nil, errors.Wrap(err, "publishing message") - } - - return payment, nil -} - -func (s *Service) ListPayments(ctx context.Context, q storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { - cursor, err := s.store.ListPayments(ctx, q) - return cursor, newStorageError(err, "listing payments") -} - -func (s *Service) GetPayment(ctx context.Context, id string) (*models.Payment, error) { - _, err := models.PaymentIDFromString(id) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - payment, err := s.store.GetPayment(ctx, id) - return payment, newStorageError(err, "getting payment") -} - -type UpdateMetadataRequest map[string]string - -func (s *Service) UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error { - err := s.store.UpdatePaymentMetadata(ctx, paymentID, metadata) - return newStorageError(err, "updating payment metadata") -} diff --git a/cmd/api/internal/api/service/payments_test.go b/cmd/api/internal/api/service/payments_test.go deleted file mode 100644 index df51f87d..00000000 --- a/cmd/api/internal/api/service/payments_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "context" - "errors" - "math/big" - "testing" - "time" - - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestCreatePayment(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - request *CreatePaymentRequest - isConnectorInstalled bool - expectedError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - sourceAccountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - destinationAccountID := models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - } - - testCases := []testCase{ - { - name: "nominal", - request: &CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - isConnectorInstalled: true, - }, - { - name: "connector not installed", - request: &CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - isConnectorInstalled: false, - expectedError: ErrValidation, - }, - { - name: "nominal without source or destination account ids", - request: &CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - }, - isConnectorInstalled: true, - }, - { - name: "invalid connectorID", - request: &CreatePaymentRequest{ - Reference: "test", - ConnectorID: "invalid", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - }, - expectedError: ErrValidation, - isConnectorInstalled: true, - }, - { - name: "invalid source account id", - request: &CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: "invalid", - DestinationAccountID: destinationAccountID.String(), - }, - expectedError: ErrValidation, - isConnectorInstalled: true, - }, - { - name: "invalid destination account id", - request: &CreatePaymentRequest{ - Reference: "test", - ConnectorID: connectorID.String(), - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Amount: big.NewInt(100), - Type: string(models.PaymentTypeTransfer), - Status: string(models.PaymentStatusSucceeded), - Scheme: string(models.PaymentSchemeOther), - Asset: "EUR/2", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: "invalid", - }, - expectedError: ErrValidation, - isConnectorInstalled: true, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - store := &MockStore{} - service := New(store.WithIsConnectorInstalled(tc.isConnectorInstalled), &MockPublisher{}, messages.NewMessages("")) - p, err := service.CreatePayment(context.Background(), tc.request) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - require.NotNil(t, p) - } - }) - } -} - -func TestGetPayment(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - paymentID string - expectedError error - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - paymentID: paymentID.String(), - expectedError: nil, - }, - { - name: "invalid paymentID", - paymentID: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - _, err := service.GetPayment(context.Background(), tc.paymentID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/cmd/api/internal/api/service/ping.go b/cmd/api/internal/api/service/ping.go deleted file mode 100644 index e33dd7ba..00000000 --- a/cmd/api/internal/api/service/ping.go +++ /dev/null @@ -1,5 +0,0 @@ -package service - -func (s *Service) Ping() error { - return s.store.Ping() -} diff --git a/cmd/api/internal/api/service/pools.go b/cmd/api/internal/api/service/pools.go deleted file mode 100644 index a0e82a7a..00000000 --- a/cmd/api/internal/api/service/pools.go +++ /dev/null @@ -1,259 +0,0 @@ -package service - -import ( - "context" - "math/big" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" - "github.com/pkg/errors" -) - -type CreatePoolRequest struct { - Name string `json:"name"` - AccountIDs []string `json:"accountIDs"` -} - -func (c *CreatePoolRequest) Validate() error { - if c.Name == "" { - return errors.New("name is required") - } - - if len(c.AccountIDs) == 0 { - return errors.New("accountIDs is required") - } - - return nil -} - -func (s *Service) CreatePool( - ctx context.Context, - req *CreatePoolRequest, -) (*models.Pool, error) { - pool := &models.Pool{ - Name: req.Name, - CreatedAt: time.Now().UTC(), - } - - err := s.store.CreatePool(ctx, pool) - if err != nil { - return nil, newStorageError(err, "creating pool") - } - - poolAccounts := make([]*models.PoolAccounts, len(req.AccountIDs)) - for i, accountID := range req.AccountIDs { - aID, err := models.AccountIDFromString(accountID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - poolAccounts[i] = &models.PoolAccounts{ - PoolID: pool.ID, - AccountID: *aID, - } - } - - err = s.store.AddAccountsToPool(ctx, poolAccounts) - if err != nil { - return nil, newStorageError(err, "adding accounts to pool") - } - pool.PoolAccounts = poolAccounts - - err = s.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, s.messages.NewEventSavedPool(pool))) - if err != nil { - return nil, errors.Wrap(err, "publishing message") - } - - return pool, nil -} - -type AddAccountToPoolRequest struct { - AccountID string `json:"accountID"` -} - -func (c *AddAccountToPoolRequest) Validate() error { - if c.AccountID == "" { - return errors.New("accountID is required") - } - - return nil -} - -func (s *Service) AddAccountToPool( - ctx context.Context, - poolID string, - req *AddAccountToPoolRequest, -) error { - id, err := uuid.Parse(poolID) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - aID, err := models.AccountIDFromString(req.AccountID) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - if err := s.store.AddAccountToPool(ctx, &models.PoolAccounts{ - PoolID: id, - AccountID: *aID, - }); err != nil { - return newStorageError(err, "adding account to pool") - } - - pool, err := s.store.GetPool(ctx, id) - if err != nil { - return newStorageError(err, "getting pool") - } - - err = s.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, s.messages.NewEventSavedPool(pool))) - if err != nil { - return errors.Wrap(err, "publishing message") - } - - return nil -} - -func (s *Service) RemoveAccountFromPool( - ctx context.Context, - poolID string, - accountID string, -) error { - id, err := uuid.Parse(poolID) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - aID, err := models.AccountIDFromString(accountID) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - if err := s.store.RemoveAccountFromPool(ctx, &models.PoolAccounts{ - PoolID: id, - AccountID: *aID, - }); err != nil { - return newStorageError(err, "removing account from pool") - } - - pool, err := s.store.GetPool(ctx, id) - if err != nil { - return newStorageError(err, "getting pool") - } - - err = s.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, s.messages.NewEventSavedPool(pool))) - if err != nil { - return errors.Wrap(err, "publishing message") - } - - return nil -} - -func (s *Service) ListPools(ctx context.Context, q storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { - cursor, err := s.store.ListPools(ctx, q) - return cursor, newStorageError(err, "listing pools") -} - -func (s *Service) GetPool( - ctx context.Context, - poolID string, -) (*models.Pool, error) { - id, err := uuid.Parse(poolID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - pool, err := s.store.GetPool(ctx, id) - return pool, newStorageError(err, "getting pool") -} - -type GetPoolBalanceResponse struct { - Balances []*Balance -} - -type Balance struct { - Amount *big.Int - Asset string -} - -func (s *Service) GetPoolBalance( - ctx context.Context, - poolID string, - atTime string, -) (*GetPoolBalanceResponse, error) { - id, err := uuid.Parse(poolID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - at, err := time.Parse(time.RFC3339, atTime) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - pool, err := s.store.GetPool(ctx, id) - if err != nil { - return nil, newStorageError(err, "getting pool") - } - - res := make(map[string]*big.Int) - for _, poolAccount := range pool.PoolAccounts { - balances, err := s.store.GetBalancesAt(ctx, poolAccount.AccountID, at) - if err != nil { - return nil, newStorageError(err, "getting balances") - } - - for _, balance := range balances { - amount, ok := res[balance.Asset.String()] - if !ok { - amount = big.NewInt(0) - } - - amount.Add(amount, balance.Balance) - res[balance.Asset.String()] = amount - } - } - - balances := make([]*Balance, 0, len(res)) - for asset, amount := range res { - balances = append(balances, &Balance{ - Asset: asset, - Amount: amount, - }) - } - - return &GetPoolBalanceResponse{ - Balances: balances, - }, nil -} - -func (s *Service) DeletePool( - ctx context.Context, - poolID string, -) error { - id, err := uuid.Parse(poolID) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - if err := s.store.DeletePool(ctx, id); err != nil { - return newStorageError(err, "deleting pool") - } - - err = s.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, s.messages.NewEventDeletePool(id))) - if err != nil { - return errors.Wrap(err, "publishing message") - } - - return nil -} diff --git a/cmd/api/internal/api/service/pools_test.go b/cmd/api/internal/api/service/pools_test.go deleted file mode 100644 index e7b3a228..00000000 --- a/cmd/api/internal/api/service/pools_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "math/big" - "testing" - "time" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestCreatePool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - request *CreatePoolRequest - expectedError error - } - - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - request: &CreatePoolRequest{ - Name: "pool1", - AccountIDs: []string{accountID.String()}, - }, - expectedError: nil, - }, - { - name: "invalid accountID", - request: &CreatePoolRequest{ - Name: "pool1", - AccountIDs: []string{"invalid"}, - }, - expectedError: ErrValidation, - }, - } - - m := &MockPublisher{} - messageChan := make(chan *message.Message, 1) - service := New(&MockStore{}, m.WithMessagesChan(messageChan), messages.NewMessages("")) - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - pool, err := service.CreatePool(context.Background(), tc.request) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - require.NotNil(t, pool) - - require.Eventually(t, func() bool { - select { - case msg := <-messageChan: - type poolPayload struct { - Payload struct { - ID string `json:"id"` - Name string `json:"name"` - AccountIDS []string `json:"accountIDs"` - } `json:"payload"` - } - - var p poolPayload - require.NoError(t, json.Unmarshal(msg.Payload, &p)) - require.Equal(t, pool.ID.String(), p.Payload.ID) - require.Equal(t, tc.request.Name, p.Payload.Name) - require.Equal(t, tc.request.AccountIDs, p.Payload.AccountIDS) - return true - } - }, 10*time.Second, 100*time.Millisecond) - } - }) - } -} - -func TestGetPool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - expectedError error - } - - uuid1 := uuid.New() - - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - expectedError: nil, - }, - { - name: "invalid poolID", - poolID: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - _, err := service.GetPool(context.Background(), tc.poolID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestAddAccountToPool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - accountID string - expectedError error - } - - uuid1 := uuid.New() - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - accountID: accountID.String(), - expectedError: nil, - }, - { - name: "invalid poolID", - poolID: "invalid", - accountID: accountID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid accountID", - poolID: uuid1.String(), - accountID: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := service.AddAccountToPool(context.Background(), tc.poolID, &AddAccountToPoolRequest{ - AccountID: tc.accountID, - }) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } - -} - -func TestRemoveAccountFromPool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - accountID string - expectedError error - } - - uuid1 := uuid.New() - accountID := models.AccountID{ - Reference: "acc1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - accountID: accountID.String(), - expectedError: nil, - }, - { - name: "invalid poolID", - poolID: "invalid", - accountID: accountID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid accountID", - poolID: uuid1.String(), - accountID: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := service.RemoveAccountFromPool(context.Background(), tc.poolID, tc.accountID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestDeletePool(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - expectedError error - } - - uuid1 := uuid.New() - - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - expectedError: nil, - }, - { - name: "invalid poolID", - poolID: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := service.DeletePool(context.Background(), tc.poolID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } - -} - -func TestGetPoolBalance(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - poolID string - atTime string - expectedError error - } - - uuid1 := uuid.New() - - testCases := []testCase{ - { - name: "nominal", - poolID: uuid1.String(), - atTime: "2021-01-01T00:00:00Z", - }, - { - name: "invalid poolID", - poolID: "invalid", - atTime: "2021-01-01T00:00:00Z", - expectedError: ErrValidation, - }, - { - name: "invalid atTime", - poolID: uuid1.String(), - atTime: "invalid", - expectedError: ErrValidation, - }, - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages("")) - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - expectedResponseMap := map[string]*big.Int{ - "EUR/2": big.NewInt(200), - "USD/2": big.NewInt(300), - } - - balances, err := service.GetPoolBalance(context.Background(), tc.poolID, tc.atTime) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - - require.Equal(t, 2, len(balances.Balances)) - for _, balance := range balances.Balances { - expectedAmount, ok := expectedResponseMap[balance.Asset] - require.True(t, ok) - require.Equal(t, expectedAmount, balance.Amount) - } - } - }) - } -} diff --git a/cmd/api/internal/api/service/service.go b/cmd/api/internal/api/service/service.go deleted file mode 100644 index 387e594f..00000000 --- a/cmd/api/internal/api/service/service.go +++ /dev/null @@ -1,53 +0,0 @@ -package service - -import ( - "context" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -type Store interface { - Ping() error - IsConnectorInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) - UpsertAccounts(ctx context.Context, accounts []*models.Account) error - ListAccounts(ctx context.Context, q storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) - GetAccount(ctx context.Context, id string) (*models.Account, error) - ListBalances(ctx context.Context, q storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) - GetBalancesAt(ctx context.Context, accountID models.AccountID, at time.Time) ([]*models.Balance, error) - ListBankAccounts(ctx context.Context, q storage.ListBankAccountQuery) (*bunpaginate.Cursor[models.BankAccount], error) - GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) - UpsertPayments(ctx context.Context, payments []*models.Payment) error - ListPayments(ctx context.Context, q storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) - GetPayment(ctx context.Context, id string) (*models.Payment, error) - UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error - ListTransferInitiations(ctx context.Context, q storage.ListTransferInitiationsQuery) (*bunpaginate.Cursor[models.TransferInitiation], error) - GetTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) - CreatePool(ctx context.Context, pool *models.Pool) error - AddAccountToPool(ctx context.Context, poolAccount *models.PoolAccounts) error - AddAccountsToPool(ctx context.Context, poolAccounts []*models.PoolAccounts) error - RemoveAccountFromPool(ctx context.Context, poolAccount *models.PoolAccounts) error - ListPools(ctx context.Context, q storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) - GetPool(ctx context.Context, poolID uuid.UUID) (*models.Pool, error) - DeletePool(ctx context.Context, poolID uuid.UUID) error -} - -type Service struct { - store Store - publisher message.Publisher - messages *messages.Messages -} - -func New(store Store, publisher message.Publisher, messages *messages.Messages) *Service { - return &Service{ - store: store, - publisher: publisher, - messages: messages, - } -} diff --git a/cmd/api/internal/api/service/service_test.go b/cmd/api/internal/api/service/service_test.go deleted file mode 100644 index bf31a113..00000000 --- a/cmd/api/internal/api/service/service_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package service - -import ( - "context" - "math/big" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -type MockStore struct { - isConnectorInstalled bool -} - -func (m *MockStore) WithIsConnectorInstalled(isConnectorInstalled bool) *MockStore { - m.isConnectorInstalled = isConnectorInstalled - return m -} - -func (m *MockStore) Ping() error { - return nil -} - -func (m *MockStore) IsConnectorInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - return m.isConnectorInstalled, nil -} - -func (m *MockStore) ListBalances(ctx context.Context, q storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { - return nil, nil -} - -func (m *MockStore) GetBalancesAt(ctx context.Context, accountID models.AccountID, atTime time.Time) ([]*models.Balance, error) { - return []*models.Balance{ - { - AccountID: accountID, - Asset: "EUR/2", - Balance: big.NewInt(100), - }, - { - AccountID: accountID, - Asset: "USD/2", - Balance: big.NewInt(150), - }, - }, nil -} - -func (m *MockStore) UpsertAccounts(ctx context.Context, accounts []*models.Account) error { - return nil -} - -func (m *MockStore) ListAccounts(ctx context.Context, q storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { - return nil, nil -} - -func (m *MockStore) GetAccount(ctx context.Context, id string) (*models.Account, error) { - return nil, nil -} - -func (m *MockStore) ListBankAccounts(ctx context.Context, q storage.ListBankAccountQuery) (*bunpaginate.Cursor[models.BankAccount], error) { - return nil, nil -} - -func (m *MockStore) GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { - return nil, nil -} - -func (m *MockStore) UpsertPayments(ctx context.Context, payments []*models.Payment) error { - return nil -} - -func (m *MockStore) ListPayments(ctx context.Context, q storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { - return nil, nil -} - -func (m *MockStore) GetPayment(ctx context.Context, id string) (*models.Payment, error) { - return nil, nil -} - -func (m *MockStore) UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error { - return nil -} - -func (m *MockStore) ListTransferInitiations(ctx context.Context, q storage.ListTransferInitiationsQuery) (*bunpaginate.Cursor[models.TransferInitiation], error) { - return nil, nil -} - -func (m *MockStore) GetTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) { - return nil, nil -} - -func (m *MockStore) CreatePool(ctx context.Context, pool *models.Pool) error { - return nil -} - -func (m *MockStore) AddAccountsToPool(ctx context.Context, poolAccounts []*models.PoolAccounts) error { - return nil -} - -func (m *MockStore) AddAccountToPool(ctx context.Context, poolAccount *models.PoolAccounts) error { - return nil -} - -func (m *MockStore) RemoveAccountFromPool(ctx context.Context, poolAccount *models.PoolAccounts) error { - return nil -} - -func (m *MockStore) ListPools(ctx context.Context, q storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { - return nil, nil -} - -func (m *MockStore) GetPool(ctx context.Context, poolID uuid.UUID) (*models.Pool, error) { - return &models.Pool{ - ID: poolID, - Name: "test", - PoolAccounts: []*models.PoolAccounts{ - { - PoolID: poolID, - AccountID: models.AccountID{ - Reference: "acc1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - }, - }, - { - PoolID: poolID, - AccountID: models.AccountID{ - Reference: "acc2", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - }, - }, - }, - }, nil -} - -func (m *MockStore) DeletePool(ctx context.Context, poolID uuid.UUID) error { - return nil -} - -type MockPublisher struct { - errorToSend error - messagesChan chan *message.Message -} - -func (m *MockPublisher) WithError(err error) *MockPublisher { - m.errorToSend = err - return m -} - -func (m *MockPublisher) WithMessagesChan(messagesChan chan *message.Message) *MockPublisher { - m.messagesChan = messagesChan - return m -} - -func (m *MockPublisher) Publish(topic string, messages ...*message.Message) error { - if m.errorToSend != nil { - return m.errorToSend - } - - if m.messagesChan != nil { - for _, msg := range messages { - m.messagesChan <- msg - } - } - - return nil -} - -func (m *MockPublisher) Close() error { - return nil -} diff --git a/cmd/api/internal/api/service/transfer_initiations.go b/cmd/api/internal/api/service/transfer_initiations.go deleted file mode 100644 index 4b8ea641..00000000 --- a/cmd/api/internal/api/service/transfer_initiations.go +++ /dev/null @@ -1,20 +0,0 @@ -package service - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" -) - -func (s *Service) ListTransferInitiations(ctx context.Context, q storage.ListTransferInitiationsQuery) (*bunpaginate.Cursor[models.TransferInitiation], error) { - cursor, err := s.store.ListTransferInitiations(ctx, q) - return cursor, newStorageError(err, "listing transfer initiations") -} - -func (s *Service) ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) { - transferInitiation, err := s.store.GetTransferInitiation(ctx, id) - return transferInitiation, newStorageError(err, "reading transfer initiation") -} diff --git a/cmd/api/internal/api/transfer_initiation.go b/cmd/api/internal/api/transfer_initiation.go deleted file mode 100644 index ea1a0670..00000000 --- a/cmd/api/internal/api/transfer_initiation.go +++ /dev/null @@ -1,208 +0,0 @@ -package api - -import ( - "encoding/json" - "math/big" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/api/internal/api/backend" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" -) - -type transferInitiationResponse struct { - ID string `json:"id"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - ScheduledAt time.Time `json:"scheduledAt"` - Description string `json:"description"` - SourceAccountID string `json:"sourceAccountID"` - DestinationAccountID string `json:"destinationAccountID"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` - Type string `json:"type"` - Amount *big.Int `json:"amount"` - InitialAmount *big.Int `json:"initialAmount"` - Asset string `json:"asset"` - Status string `json:"status"` - Error string `json:"error"` - Metadata map[string]string `json:"metadata"` -} - -type transferInitiationPaymentsResponse struct { - PaymentID string `json:"paymentID"` - CreatedAt time.Time `json:"createdAt"` - Status string `json:"status"` - Error string `json:"error"` -} - -type transferInitiationAdjustmentsResponse struct { - AdjustmentID string `json:"adjustmentID"` - CreatedAt time.Time `json:"createdAt"` - Status string `json:"status"` - Error string `json:"error"` - Metadata map[string]string `json:"metadata"` -} - -type readTransferInitiationResponse struct { - transferInitiationResponse - RelatedPayments []*transferInitiationPaymentsResponse `json:"relatedPayments"` - RelatedAdjustments []*transferInitiationAdjustmentsResponse `json:"relatedAdjustments"` -} - -func readTransferInitiationHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readTransferInitiationHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - transferID, err := models.TransferInitiationIDFromString(mux.Vars(r)["transferID"]) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("request.transferID", transferID.String())) - - ret, err := b.GetService().ReadTransferInitiation(ctx, transferID) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - data := &readTransferInitiationResponse{ - transferInitiationResponse: transferInitiationResponse{ - ID: ret.ID.String(), - Reference: ret.ID.Reference, - CreatedAt: ret.CreatedAt, - ScheduledAt: ret.ScheduledAt, - Description: ret.Description, - SourceAccountID: ret.SourceAccountID.String(), - DestinationAccountID: ret.DestinationAccountID.String(), - ConnectorID: ret.ConnectorID.String(), - Provider: ret.Provider.String(), - Type: ret.Type.String(), - Amount: ret.Amount, - InitialAmount: ret.InitialAmount, - Asset: ret.Asset.String(), - Metadata: ret.Metadata, - }, - } - - if len(ret.RelatedAdjustments) > 0 { - // Take the status and error from the last adjustment - data.Status = ret.RelatedAdjustments[0].Status.String() - data.Error = ret.RelatedAdjustments[0].Error - } - - for _, adjustments := range ret.RelatedAdjustments { - data.RelatedAdjustments = append(data.RelatedAdjustments, &transferInitiationAdjustmentsResponse{ - AdjustmentID: adjustments.ID.String(), - CreatedAt: adjustments.CreatedAt, - Status: adjustments.Status.String(), - Error: adjustments.Error, - Metadata: adjustments.Metadata, - }) - } - - for _, payments := range ret.RelatedPayments { - data.RelatedPayments = append(data.RelatedPayments, &transferInitiationPaymentsResponse{ - PaymentID: payments.PaymentID.String(), - CreatedAt: payments.CreatedAt, - Status: payments.Status.String(), - Error: payments.Error, - }) - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[readTransferInitiationResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func listTransferInitiationsHandler(b backend.Backend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listTransferInitiationsHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - query, err := bunpaginate.Extract[storage.ListTransferInitiationsQuery](r, func() (*storage.ListTransferInitiationsQuery, error) { - options, err := getPagination(r, storage.TransferInitiationQuery{}) - if err != nil { - return nil, err - } - return pointer.For(storage.NewListTransferInitiationsQuery(*options)), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - cursor, err := b.GetService().ListTransferInitiations(ctx, *query) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - ret := cursor.Data - data := make([]*transferInitiationResponse, len(ret)) - for i := range ret { - ret[i].SortRelatedAdjustments() - data[i] = &transferInitiationResponse{ - ID: ret[i].ID.String(), - Reference: ret[i].ID.Reference, - CreatedAt: ret[i].CreatedAt, - ScheduledAt: ret[i].ScheduledAt, - Description: ret[i].Description, - SourceAccountID: ret[i].SourceAccountID.String(), - DestinationAccountID: ret[i].DestinationAccountID.String(), - Provider: ret[i].Provider.String(), - ConnectorID: ret[i].ConnectorID.String(), - Type: ret[i].Type.String(), - Amount: ret[i].Amount, - InitialAmount: ret[i].InitialAmount, - Asset: ret[i].Asset.String(), - Metadata: ret[i].Metadata, - } - - if len(ret[i].RelatedAdjustments) > 0 { - // Take the status and error from the last adjustment - data[i].Status = ret[i].RelatedAdjustments[0].Status.String() - data[i].Error = ret[i].RelatedAdjustments[0].Error - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[*transferInitiationResponse]{ - Cursor: &bunpaginate.Cursor[*transferInitiationResponse]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} diff --git a/cmd/api/internal/api/transfer_initiation_test.go b/cmd/api/internal/api/transfer_initiation_test.go deleted file mode 100644 index 93fdf5bf..00000000 --- a/cmd/api/internal/api/transfer_initiation_test.go +++ /dev/null @@ -1,677 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "math/big" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/api/service" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestListTransferInitiations(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - queryParams url.Values - pageSize int - expectedQuery storage.ListTransferInitiationsQuery - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nomimal", - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too low", - queryParams: url.Values{ - "pageSize": {"0"}, - }, - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15), - ), - pageSize: 15, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - }, - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(100), - ), - pageSize: 100, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "invalid query builder json", - queryParams: url.Values{ - "query": {"invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "valid query builder json", - queryParams: url.Values{ - "query": {"{\"$match\": {\"source_account_id\": \"acc1\"}}"}, - }, - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15). - WithQueryBuilder(query.Match("source_account_id", "acc1")), - ), - pageSize: 15, - }, - { - name: "valid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:asc"}, - }, - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15). - WithSorter(storage.Sorter{}.Add("source_account_id", storage.SortOrderAsc)), - ), - pageSize: 15, - }, - { - name: "invalid sorter", - queryParams: url.Values{ - "sort": {"source_account_id:invalid"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "err validation from backend", - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15), - ), - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15), - ), - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - expectedQuery: storage.NewListTransferInitiationsQuery( - storage.NewPaginatedQueryOptions(storage.TransferInitiationQuery{}). - WithPageSize(15), - ), - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - tfs := []models.TransferInitiation{ - { - ID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 8, 30, 0, 0, time.UTC), - Description: "test1", - Type: models.TransferInitiationTypePayout, - SourceAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - DestinationAccountID: models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(100), - Asset: models.Asset("EUR/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 45, 0, 0, time.UTC), - Status: models.TransferInitiationStatusProcessed, - }, - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 40, 0, 0, time.UTC), - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - RelatedPayments: []*models.TransferInitiationPayment{ - { - TransferInitiationID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 30, 0, 0, time.UTC), - Status: models.TransferInitiationStatusProcessed, - Error: "", - }, - }, - Metadata: map[string]string{ - "foo": "bar", - }, - }, - { - ID: models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 9, 30, 0, 0, time.UTC), - Description: "test2", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &models.AccountID{ - Reference: "acc3", - ConnectorID: connectorID, - }, - DestinationAccountID: models.AccountID{ - Reference: "acc4", - ConnectorID: connectorID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(2000), - Asset: models.Asset("USD/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 45, 0, 0, time.UTC), - Status: models.TransferInitiationStatusFailed, - Error: "error", - }, - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 40, 0, 0, time.UTC), - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - }, - } - listTFsResponse := &bunpaginate.Cursor[models.TransferInitiation]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: tfs, - } - - expectedTFsResponse := []*transferInitiationResponse{ - { - ID: tfs[0].ID.String(), - Reference: tfs[0].ID.Reference, - CreatedAt: tfs[0].CreatedAt, - ScheduledAt: tfs[0].ScheduledAt, - Description: tfs[0].Description, - SourceAccountID: tfs[0].SourceAccountID.String(), - DestinationAccountID: tfs[0].DestinationAccountID.String(), - Provider: tfs[0].Provider.String(), - Type: tfs[0].Type.String(), - Amount: tfs[0].Amount, - Asset: tfs[0].Asset.String(), - Status: models.TransferInitiationStatusProcessed.String(), - ConnectorID: tfs[0].ConnectorID.String(), - Error: "", - Metadata: tfs[0].Metadata, - }, - { - ID: tfs[1].ID.String(), - Reference: tfs[1].ID.Reference, - CreatedAt: tfs[1].CreatedAt, - ScheduledAt: tfs[1].ScheduledAt, - Description: tfs[1].Description, - SourceAccountID: tfs[1].SourceAccountID.String(), - DestinationAccountID: tfs[1].DestinationAccountID.String(), - Provider: tfs[1].Provider.String(), - Type: tfs[1].Type.String(), - Amount: tfs[1].Amount, - Asset: tfs[1].Asset.String(), - ConnectorID: tfs[1].ConnectorID.String(), - Status: models.TransferInitiationStatusFailed.String(), - Error: "error", - Metadata: tfs[1].Metadata, - }, - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListTransferInitiations(gomock.Any(), testCase.expectedQuery). - Return(listTFsResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListTransferInitiations(gomock.Any(), testCase.expectedQuery). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, "/transfer-initiations", nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[*transferInitiationResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedTFsResponse, resp.Cursor.Data) - require.Equal(t, listTFsResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listTFsResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listTFsResponse.Next, resp.Cursor.Next) - require.Equal(t, listTFsResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestGetTransferInitiation(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - tfID1 := models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - } - tfID2 := models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - } - - type testCase struct { - name string - tfID string - expectedTFID models.TransferInitiationID - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nomimal acc1", - tfID: tfID1.String(), - expectedTFID: tfID1, - }, - { - name: "nomimal acc2", - tfID: tfID2.String(), - expectedTFID: tfID2, - }, - { - name: "invalid tf ID", - tfID: "invalid", - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "err validation from backend", - tfID: tfID1.String(), - expectedTFID: tfID1, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "ErrNotFound from storage", - tfID: tfID1.String(), - expectedTFID: tfID1, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "ErrDuplicateKeyValue from storage", - tfID: tfID1.String(), - expectedTFID: tfID1, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "other storage errors from storage", - tfID: tfID1.String(), - expectedTFID: tfID1, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - var getTransferInitiationResponse *models.TransferInitiation - var expectedTransferInitiationResponse *readTransferInitiationResponse - if testCase.expectedTFID == tfID1 { - getTransferInitiationResponse = &models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 8, 30, 0, 0, time.UTC), - Description: "test1", - Type: models.TransferInitiationTypePayout, - SourceAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - }, - DestinationAccountID: models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(100), - Asset: models.Asset("EUR/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 45, 0, 0, time.UTC), - Status: models.TransferInitiationStatusProcessed, - }, - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 40, 0, 0, time.UTC), - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - RelatedPayments: []*models.TransferInitiationPayment{ - { - TransferInitiationID: models.TransferInitiationID{ - Reference: "t1", - ConnectorID: connectorID, - }, - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 30, 0, 0, time.UTC), - Status: models.TransferInitiationStatusProcessed, - Error: "", - }, - }, - Metadata: map[string]string{ - "foo": "bar", - }, - } - - expectedTransferInitiationResponse = &readTransferInitiationResponse{ - transferInitiationResponse: transferInitiationResponse{ - ID: getTransferInitiationResponse.ID.String(), - Reference: getTransferInitiationResponse.ID.Reference, - CreatedAt: getTransferInitiationResponse.CreatedAt, - ScheduledAt: getTransferInitiationResponse.ScheduledAt, - Description: getTransferInitiationResponse.Description, - SourceAccountID: getTransferInitiationResponse.SourceAccountID.String(), - DestinationAccountID: getTransferInitiationResponse.DestinationAccountID.String(), - Provider: getTransferInitiationResponse.Provider.String(), - Type: getTransferInitiationResponse.Type.String(), - Amount: getTransferInitiationResponse.Amount, - ConnectorID: getTransferInitiationResponse.ConnectorID.String(), - Asset: getTransferInitiationResponse.Asset.String(), - Status: models.TransferInitiationStatusProcessed.String(), - Error: "", - Metadata: getTransferInitiationResponse.Metadata, - }, - RelatedPayments: []*transferInitiationPaymentsResponse{ - { - PaymentID: getTransferInitiationResponse.RelatedPayments[0].PaymentID.String(), - CreatedAt: getTransferInitiationResponse.RelatedPayments[0].CreatedAt, - Status: getTransferInitiationResponse.RelatedPayments[0].Status.String(), - Error: getTransferInitiationResponse.RelatedPayments[0].Error, - }, - }, - RelatedAdjustments: []*transferInitiationAdjustmentsResponse{ - { - AdjustmentID: getTransferInitiationResponse.RelatedAdjustments[0].ID.String(), - CreatedAt: getTransferInitiationResponse.RelatedAdjustments[0].CreatedAt, - Status: getTransferInitiationResponse.RelatedAdjustments[0].Status.String(), - Error: getTransferInitiationResponse.RelatedAdjustments[0].Error, - Metadata: getTransferInitiationResponse.RelatedAdjustments[0].Metadata, - }, - { - AdjustmentID: getTransferInitiationResponse.RelatedAdjustments[1].ID.String(), - CreatedAt: getTransferInitiationResponse.RelatedAdjustments[1].CreatedAt, - Status: getTransferInitiationResponse.RelatedAdjustments[1].Status.String(), - Error: getTransferInitiationResponse.RelatedAdjustments[1].Error, - Metadata: getTransferInitiationResponse.RelatedAdjustments[1].Metadata, - }, - }, - } - } else { - getTransferInitiationResponse = &models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 9, 30, 0, 0, time.UTC), - Description: "test2", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &models.AccountID{ - Reference: "acc3", - ConnectorID: connectorID, - }, - DestinationAccountID: models.AccountID{ - Reference: "acc4", - ConnectorID: connectorID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(2000), - Asset: models.Asset("USD/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 45, 0, 0, time.UTC), - Status: models.TransferInitiationStatusFailed, - Error: "error", - }, - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "t2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 40, 0, 0, time.UTC), - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - } - expectedTransferInitiationResponse = &readTransferInitiationResponse{ - transferInitiationResponse: transferInitiationResponse{ - ID: getTransferInitiationResponse.ID.String(), - Reference: getTransferInitiationResponse.ID.Reference, - CreatedAt: getTransferInitiationResponse.CreatedAt, - ScheduledAt: getTransferInitiationResponse.ScheduledAt, - Description: getTransferInitiationResponse.Description, - SourceAccountID: getTransferInitiationResponse.SourceAccountID.String(), - DestinationAccountID: getTransferInitiationResponse.DestinationAccountID.String(), - Provider: getTransferInitiationResponse.Provider.String(), - Type: getTransferInitiationResponse.Type.String(), - Amount: getTransferInitiationResponse.Amount, - ConnectorID: getTransferInitiationResponse.ConnectorID.String(), - Asset: getTransferInitiationResponse.Asset.String(), - Status: models.TransferInitiationStatusFailed.String(), - Error: "error", - Metadata: getTransferInitiationResponse.Metadata, - }, - RelatedAdjustments: []*transferInitiationAdjustmentsResponse{ - { - AdjustmentID: getTransferInitiationResponse.RelatedAdjustments[0].ID.String(), - CreatedAt: getTransferInitiationResponse.RelatedAdjustments[0].CreatedAt, - Status: getTransferInitiationResponse.RelatedAdjustments[0].Status.String(), - Error: getTransferInitiationResponse.RelatedAdjustments[0].Error, - Metadata: getTransferInitiationResponse.RelatedAdjustments[0].Metadata, - }, - { - AdjustmentID: getTransferInitiationResponse.RelatedAdjustments[1].ID.String(), - CreatedAt: getTransferInitiationResponse.RelatedAdjustments[1].CreatedAt, - Status: getTransferInitiationResponse.RelatedAdjustments[1].Status.String(), - Error: getTransferInitiationResponse.RelatedAdjustments[1].Error, - Metadata: getTransferInitiationResponse.RelatedAdjustments[1].Metadata, - }, - }, - } - } - - backend, mockService := newTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ReadTransferInitiation(gomock.Any(), testCase.expectedTFID). - Return(getTransferInitiationResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ReadTransferInitiation(gomock.Any(), testCase.expectedTFID). - Return(nil, testCase.serviceError) - } - - router := httpRouter(backend, logging.Testing(), sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), false) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/transfer-initiations/%s", testCase.tfID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[readTransferInitiationResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedTransferInitiationResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/api/internal/api/utils.go b/cmd/api/internal/api/utils.go deleted file mode 100644 index 86d06917..00000000 --- a/cmd/api/internal/api/utils.go +++ /dev/null @@ -1,76 +0,0 @@ -package api - -import ( - "io" - "net/http" - "strings" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/pkg/errors" -) - -func getQueryBuilder(r *http.Request) (query.Builder, error) { - data, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - - if len(data) > 0 { - return query.ParseJSON(string(data)) - } else { - // In order to be backward compatible - return query.ParseJSON(r.URL.Query().Get("query")) - } -} - -func getSorter(r *http.Request) (storage.Sorter, error) { - var sorter storage.Sorter - - if sortParams := r.URL.Query()["sort"]; sortParams != nil { - for _, s := range sortParams { - parts := strings.SplitN(s, ":", 2) - - var order storage.SortOrder - - if len(parts) > 1 { - //nolint:goconst // allow duplicate string - switch parts[1] { - case "asc", "ASC": - order = storage.SortOrderAsc - case "dsc", "desc", "DSC", "DESC": - order = storage.SortOrderDesc - default: - return sorter, errors.New("sort order not well specified, got " + parts[1]) - } - } - - column := parts[0] - - sorter = sorter.Add(column, order) - } - } - - return sorter, nil -} - -func getPagination[T any](r *http.Request, options T) (*storage.PaginatedQueryOptions[T], error) { - qb, err := getQueryBuilder(r) - if err != nil { - return nil, err - } - - sorter, err := getSorter(r) - if err != nil { - return nil, err - } - - pageSize, err := bunpaginate.GetPageSize(r) - if err != nil { - return nil, err - } - - return pointer.For(storage.NewPaginatedQueryOptions(options).WithQueryBuilder(qb).WithSorter(sorter).WithPageSize(pageSize)), nil -} diff --git a/cmd/api/internal/storage/accounts.go b/cmd/api/internal/storage/accounts.go deleted file mode 100644 index 8a99efbc..00000000 --- a/cmd/api/internal/storage/accounts.go +++ /dev/null @@ -1,114 +0,0 @@ -package storage - -import ( - "context" - "fmt" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" - "github.com/uptrace/bun" -) - -type AccountQuery struct{} - -type ListAccountsQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[AccountQuery]] - -func NewListAccountsQuery(opts PaginatedQueryOptions[AccountQuery]) ListAccountsQuery { - return ListAccountsQuery{ - PageSize: opts.PageSize, - Order: bunpaginate.OrderAsc, - Options: opts, - } -} - -func (s *Storage) accountQueryContext(qb query.Builder) (string, []any, error) { - return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { - switch { - case key == "reference": - return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil - case metadataRegex.Match([]byte(key)): - if operator != "$match" { - return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") - } - match := metadataRegex.FindAllStringSubmatch(key, 3) - - key := "metadata" - return key + " @> ?", []any{map[string]any{ - match[0][1]: value, - }}, nil - default: - return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) - } - })) -} - -func (s *Storage) ListAccounts(ctx context.Context, q ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { - var ( - where string - args []any - err error - ) - if q.Options.QueryBuilder != nil { - where, args, err = s.accountQueryContext(q.Options.QueryBuilder) - if err != nil { - return nil, err - } - } - - return PaginateWithOffset[PaginatedQueryOptions[AccountQuery], models.Account](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[AccountQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = query.Relation("PoolAccounts") - - if where != "" { - query = query.Where(where, args...) - } - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } else { - query = query.Order("created_at DESC") - } - - return query - }, - ) -} - -func (s *Storage) GetAccount(ctx context.Context, id string) (*models.Account, error) { - var account models.Account - - err := s.db.NewSelect(). - Model(&account). - Relation("PoolAccounts"). - Where("account.id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("failed to get account", err) - } - - return &account, nil -} - -func (s *Storage) UpsertAccounts(ctx context.Context, accounts []*models.Account) error { - if len(accounts) == 0 { - return nil - } - - _, err := s.db.NewInsert(). - Model(&accounts). - On("CONFLICT (id) DO UPDATE"). - Set("connector_id = EXCLUDED.connector_id"). - Set("raw_data = EXCLUDED.raw_data"). - Set("default_currency = EXCLUDED.default_currency"). - Set("account_name = EXCLUDED.account_name"). - Set("metadata = EXCLUDED.metadata"). - Exec(ctx) - if err != nil { - return e("failed to create accounts", err) - } - - return nil -} diff --git a/cmd/api/internal/storage/accounts_test.go b/cmd/api/internal/storage/accounts_test.go deleted file mode 100644 index 3b8bb25b..00000000 --- a/cmd/api/internal/storage/accounts_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package storage - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/internal/models" - "github.com/stretchr/testify/require" -) - -func insertAccounts(t *testing.T, store *Storage, connectorID models.ConnectorID) []models.AccountID { - id1 := models.AccountID{ - Reference: "test_account", - ConnectorID: connectorID, - } - acc1 := models.Account{ - ID: id1, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - Reference: "test_account", - AccountName: "test", - Type: models.AccountTypeInternal, - Metadata: map[string]string{"foo": "bar"}, - } - - _, err := store.DB().NewInsert(). - Model(&acc1). - Exec(context.Background()) - require.NoError(t, err) - - id2 := models.AccountID{ - Reference: "test_account2", - ConnectorID: connectorID, - } - acc2 := models.Account{ - ID: id2, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - Reference: "test_account2", - AccountName: "test2", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo": "bar", - "foo2": "bar2", - }, - } - - _, err = store.DB().NewInsert(). - Model(&acc2). - Exec(context.Background()) - require.NoError(t, err) - - return []models.AccountID{id1, id2} -} - -func TestListAccounts(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - insertAccounts(t, store, connectorID) - - acc1 := models.Account{ - ID: models.AccountID{ - Reference: "test_account", - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - Reference: "test_account", - AccountName: "test", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo": "bar", - }, - } - - acc2 := models.Account{ - ID: models.AccountID{ - Reference: "test_account2", - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - Reference: "test_account2", - AccountName: "test2", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo": "bar", - "foo2": "bar2", - }, - } - - t.Run("list all accounts with page size 1", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}).WithPageSize(1)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, acc2, cursor.Data[0]) - - var query ListAccountsQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListAccounts( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, acc1, cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListAccounts( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, acc2, cursor.Data[0]) - }) - - t.Run("list all accounts with page size 2", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}).WithPageSize(2)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - require.Equal(t, acc2, cursor.Data[0]) - require.Equal(t, acc1, cursor.Data[1]) - }) - - t.Run("list all accounts with page size > number of accounts", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}).WithPageSize(10)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - require.Equal(t, acc2, cursor.Data[0]) - require.Equal(t, acc1, cursor.Data[1]) - }) - - t.Run("list all accounts with reference", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}). - WithPageSize(10). - WithQueryBuilder(query.Match("reference", "test_account")), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, acc1, cursor.Data[0]) - }) - - t.Run("list all accounts with unknown reference", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}). - WithPageSize(10). - WithQueryBuilder(query.Match("reference", "unknown")), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 0) - require.False(t, cursor.HasMore) - }) - - t.Run("list all accounts with metadata (1)", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}). - WithPageSize(10). - WithQueryBuilder(query.Match("metadata[foo]", "bar")), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - require.Equal(t, acc2, cursor.Data[0]) - require.Equal(t, acc1, cursor.Data[1]) - }) - - t.Run("list all accounts with metadata (2)", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}). - WithPageSize(10). - WithQueryBuilder(query.Match("metadata[foo2]", "bar2")), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, acc2, cursor.Data[0]) - }) - - t.Run("list all accounts with unknown metadata key", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}). - WithPageSize(10). - WithQueryBuilder(query.Match("metadata[unknown]", "bar")), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 0) - require.False(t, cursor.HasMore) - }) - - t.Run("list all accounts with unknown metadata value", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListAccounts( - context.Background(), - NewListAccountsQuery(NewPaginatedQueryOptions(AccountQuery{}). - WithPageSize(10). - WithQueryBuilder(query.Match("metadata[foo]", "unknown")), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 0) - require.False(t, cursor.HasMore) - }) -} diff --git a/cmd/api/internal/storage/balances.go b/cmd/api/internal/storage/balances.go deleted file mode 100644 index 501851ef..00000000 --- a/cmd/api/internal/storage/balances.go +++ /dev/null @@ -1,150 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" - "github.com/uptrace/bun" -) - -type BalanceQuery struct { - AccountID *models.AccountID - Currency string - From time.Time - To time.Time -} - -func NewBalanceQuery() BalanceQuery { - return BalanceQuery{} -} - -func (b BalanceQuery) WithAccountID(accountID *models.AccountID) BalanceQuery { - b.AccountID = accountID - - return b -} - -func (b BalanceQuery) WithCurrency(currency string) BalanceQuery { - b.Currency = currency - - return b -} - -func (b BalanceQuery) WithFrom(from time.Time) BalanceQuery { - b.From = from - - return b -} - -func (b BalanceQuery) WithTo(to time.Time) BalanceQuery { - b.To = to - - return b -} - -func applyBalanceQuery(query *bun.SelectQuery, balanceQuery BalanceQuery) *bun.SelectQuery { - if balanceQuery.AccountID != nil { - query = query.Where("balance.account_id = ?", balanceQuery.AccountID) - } - - if balanceQuery.Currency != "" { - query = query.Where("balance.currency = ?", balanceQuery.Currency) - } - - if !balanceQuery.From.IsZero() { - query = query.Where("balance.last_updated_at >= ?", balanceQuery.From) - } - - if !balanceQuery.To.IsZero() { - query = query.Where("(balance.created_at <= ?)", balanceQuery.To) - } - - return query -} - -type ListBalancesQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[BalanceQuery]] - -func NewListBalancesQuery(opts PaginatedQueryOptions[BalanceQuery]) ListBalancesQuery { - return ListBalancesQuery{ - Order: bunpaginate.OrderAsc, - PageSize: opts.PageSize, - Options: opts, - } -} - -func (s *Storage) ListBalances(ctx context.Context, q ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { - return PaginateWithOffset[PaginatedQueryOptions[BalanceQuery], models.Balance](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[BalanceQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = applyBalanceQuery(query, q.Options.Options) - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } else { - query = query.Order("created_at DESC") - } - - return query - }, - ) -} - -func (s *Storage) ListBalanceCurrencies(ctx context.Context, accountID models.AccountID) ([]string, error) { - var currencies []string - - err := s.db.NewSelect(). - ColumnExpr("DISTINCT currency"). - Model(&models.Balance{}). - Where("account_id = ?", accountID). - Scan(ctx, ¤cies) - if err != nil { - return nil, e("failed to list balance currencies", err) - } - - return currencies, nil -} - -func (s *Storage) GetBalanceAtByCurrency(ctx context.Context, accountID models.AccountID, currency string, at time.Time) (*models.Balance, error) { - var balance models.Balance - - err := s.db.NewSelect(). - Model(&balance). - Where("account_id = ?", accountID). - Where("currency = ?", currency). - Where("created_at <= ?", at). - Where("last_updated_at >= ?", at). - Order("last_updated_at DESC"). - Limit(1). - Scan(ctx) - if err != nil { - return nil, e("failed to get balance", err) - } - - return &balance, nil -} - -func (s *Storage) GetBalancesAt(ctx context.Context, accountID models.AccountID, at time.Time) ([]*models.Balance, error) { - currencies, err := s.ListBalanceCurrencies(ctx, accountID) - if err != nil { - return nil, fmt.Errorf("failed to list balance currencies: %w", err) - } - - var balances []*models.Balance - for _, currency := range currencies { - balance, err := s.GetBalanceAtByCurrency(ctx, accountID, currency, at) - if err != nil { - if errors.Is(err, ErrNotFound) { - continue - } - return nil, fmt.Errorf("failed to get balance: %w", err) - } - - balances = append(balances, balance) - } - - return balances, nil -} diff --git a/cmd/api/internal/storage/balances_test.go b/cmd/api/internal/storage/balances_test.go deleted file mode 100644 index 6930901a..00000000 --- a/cmd/api/internal/storage/balances_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package storage - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/stretchr/testify/require" -) - -func insertBalances(t *testing.T, store *Storage, accountID models.AccountID) []models.Balance { - b1 := models.Balance{ - AccountID: accountID, - Asset: "EUR/2", - Balance: big.NewInt(100), - CreatedAt: time.Date(2023, 11, 14, 10, 0, 0, 0, time.UTC), - LastUpdatedAt: time.Date(2023, 11, 14, 11, 0, 0, 0, time.UTC), - } - - b2 := models.Balance{ - AccountID: accountID, - Asset: "EUR/2", - Balance: big.NewInt(200), - CreatedAt: time.Date(2023, 11, 14, 11, 0, 0, 0, time.UTC), - LastUpdatedAt: time.Date(2023, 11, 14, 11, 30, 0, 0, time.UTC), - } - - b3 := models.Balance{ - AccountID: accountID, - Asset: "EUR/2", - Balance: big.NewInt(150), - CreatedAt: time.Date(2023, 11, 14, 11, 30, 0, 0, time.UTC), - LastUpdatedAt: time.Date(2023, 11, 14, 11, 45, 0, 0, time.UTC), - } - - b4 := models.Balance{ - AccountID: accountID, - Asset: "USD/2", - Balance: big.NewInt(1000), - CreatedAt: time.Date(2023, 11, 14, 10, 30, 0, 0, time.UTC), - LastUpdatedAt: time.Date(2023, 11, 14, 12, 0, 0, 0, time.UTC), - } - - balances := []models.Balance{b1, b2, b3, b4} - _, err := store.DB().NewInsert(). - Model(&balances). - Exec(context.Background()) - require.NoError(t, err) - - return balances -} - -func TestListBalances(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - accounts := insertAccounts(t, store, connectorID) - balancesPerAccountAndAssets := make(map[string]map[string][]models.Balance) - for _, account := range accounts { - if balancesPerAccountAndAssets[account.String()] == nil { - balancesPerAccountAndAssets[account.String()] = make(map[string][]models.Balance) - } - - balances := insertBalances(t, store, account) - for _, balance := range balances { - balancesPerAccountAndAssets[account.String()][balance.Asset.String()] = append(balancesPerAccountAndAssets[account.String()][balance.Asset.String()], balance) - } - } - - t.Run("list all balances with page size 1", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBalances( - context.Background(), - NewListBalancesQuery(NewPaginatedQueryOptions(NewBalanceQuery().WithAccountID(&accounts[0])).WithPageSize(1)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][2], cursor.Data[0]) - - var query ListBalancesQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListBalances( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][1], cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListBalances( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListBalances( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][0], cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListBalances( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], cursor.Data[0]) - }) - - t.Run("list all balances with page size 2", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBalances( - context.Background(), - NewListBalancesQuery(NewPaginatedQueryOptions(NewBalanceQuery().WithAccountID(&accounts[0])).WithPageSize(2)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - cursor.Data[1].LastUpdatedAt = cursor.Data[1].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][2], cursor.Data[0]) - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][1], cursor.Data[1]) - - var query ListBalancesQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListBalances( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - cursor.Data[1].LastUpdatedAt = cursor.Data[1].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], cursor.Data[0]) - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][0], cursor.Data[1]) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListBalances( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - cursor.Data[1].LastUpdatedAt = cursor.Data[1].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][2], cursor.Data[0]) - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][1], cursor.Data[1]) - }) - - t.Run("list balances for asset", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBalances( - context.Background(), - NewListBalancesQuery(NewPaginatedQueryOptions(NewBalanceQuery().WithAccountID(&accounts[0]).WithCurrency("USD/2")).WithPageSize(15)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], cursor.Data[0]) - }) - - t.Run("list balances for asset and limit", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBalances( - context.Background(), - NewListBalancesQuery( - NewPaginatedQueryOptions( - NewBalanceQuery(). - WithAccountID(&accounts[0]). - WithCurrency("EUR/2"), - ). - WithPageSize(1), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][2], cursor.Data[0]) - }) - - t.Run("list balances for asset and time range", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBalances( - context.Background(), - NewListBalancesQuery( - NewPaginatedQueryOptions( - NewBalanceQuery(). - WithAccountID(&accounts[0]). - WithFrom(time.Date(2023, 11, 14, 10, 15, 0, 0, time.UTC)). - WithTo(time.Date(2023, 11, 14, 11, 15, 0, 0, time.UTC)), - ). - WithPageSize(15), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 3) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[1].LastUpdatedAt = cursor.Data[1].LastUpdatedAt.UTC() - cursor.Data[2].CreatedAt = cursor.Data[2].CreatedAt.UTC() - cursor.Data[2].LastUpdatedAt = cursor.Data[2].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][1], cursor.Data[0]) - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], cursor.Data[1]) - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][0], cursor.Data[2]) - }) - - t.Run("get balances at a precise time", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBalances( - context.Background(), - NewListBalancesQuery( - NewPaginatedQueryOptions( - NewBalanceQuery(). - WithAccountID(&accounts[0]). - WithCurrency("EUR/2"). - WithTo(time.Date(2023, 11, 14, 11, 15, 0, 0, time.UTC)), - ).WithPageSize(1), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].LastUpdatedAt = cursor.Data[0].LastUpdatedAt.UTC() - require.Equal(t, balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][1], cursor.Data[0]) - - cursor, err = store.ListBalances( - context.Background(), - NewListBalancesQuery( - NewPaginatedQueryOptions( - NewBalanceQuery(). - WithAccountID(&accounts[0]). - WithCurrency("EUR/2"). - WithTo(time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC)), - ).WithPageSize(1), - ), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 0) - require.False(t, cursor.HasMore) - }) -} - -func TestGetBalanceAt(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - accounts := insertAccounts(t, store, connectorID) - balancesPerAccountAndAssets := make(map[string]map[string][]models.Balance) - for _, account := range accounts { - if balancesPerAccountAndAssets[account.String()] == nil { - balancesPerAccountAndAssets[account.String()] = make(map[string][]models.Balance) - } - - balances := insertBalances(t, store, account) - for _, balance := range balances { - balancesPerAccountAndAssets[account.String()][balance.Asset.String()] = append(balancesPerAccountAndAssets[account.String()][balance.Asset.String()], balance) - } - } - - // Should have only one EUR/2 balance of 100 - t.Run("get balance at 10:15", func(t *testing.T) { - balances, err := store.GetBalancesAt(context.Background(), accounts[0], time.Date(2023, 11, 14, 10, 15, 0, 0, time.UTC)) - require.NoError(t, err) - require.Len(t, balances, 1) - balances[0].CreatedAt = balances[0].CreatedAt.UTC() - balances[0].LastUpdatedAt = balances[0].LastUpdatedAt.UTC() - require.Equal(t, &balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][0], balances[0]) - require.Equal(t, big.NewInt(100), balances[0].Balance) - }) - - t.Run("get balance at 11:15", func(t *testing.T) { - balances, err := store.GetBalancesAt(context.Background(), accounts[0], time.Date(2023, 11, 14, 11, 15, 0, 0, time.UTC)) - require.NoError(t, err) - require.Len(t, balances, 2) - balances[0].CreatedAt = balances[0].CreatedAt.UTC() - balances[0].LastUpdatedAt = balances[0].LastUpdatedAt.UTC() - require.Equal(t, &balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][1], balances[0]) - require.Equal(t, big.NewInt(200), balances[0].Balance) - balances[1].CreatedAt = balances[1].CreatedAt.UTC() - balances[1].LastUpdatedAt = balances[1].LastUpdatedAt.UTC() - require.Equal(t, &balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], balances[1]) - require.Equal(t, big.NewInt(1000), balances[1].Balance) - }) - - t.Run("get balance at 11:45", func(t *testing.T) { - balances, err := store.GetBalancesAt(context.Background(), accounts[0], time.Date(2023, 11, 14, 11, 45, 0, 0, time.UTC)) - require.NoError(t, err) - require.Len(t, balances, 2) - balances[0].CreatedAt = balances[0].CreatedAt.UTC() - balances[0].LastUpdatedAt = balances[0].LastUpdatedAt.UTC() - require.Equal(t, &balancesPerAccountAndAssets[accounts[0].String()]["EUR/2"][2], balances[0]) - require.Equal(t, big.NewInt(150), balances[0].Balance) - balances[1].CreatedAt = balances[1].CreatedAt.UTC() - balances[1].LastUpdatedAt = balances[1].LastUpdatedAt.UTC() - require.Equal(t, &balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], balances[1]) - require.Equal(t, big.NewInt(1000), balances[1].Balance) - }) - - t.Run("get balance at 12:00", func(t *testing.T) { - balances, err := store.GetBalancesAt(context.Background(), accounts[0], time.Date(2023, 11, 14, 12, 0, 0, 0, time.UTC)) - require.NoError(t, err) - require.Len(t, balances, 1) - balances[0].CreatedAt = balances[0].CreatedAt.UTC() - balances[0].LastUpdatedAt = balances[0].LastUpdatedAt.UTC() - require.Equal(t, &balancesPerAccountAndAssets[accounts[0].String()]["USD/2"][0], balances[0]) - require.Equal(t, big.NewInt(1000), balances[0].Balance) - }) -} diff --git a/cmd/api/internal/storage/bank_accounts.go b/cmd/api/internal/storage/bank_accounts.go deleted file mode 100644 index a523ee86..00000000 --- a/cmd/api/internal/storage/bank_accounts.go +++ /dev/null @@ -1,63 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -type BankAccountQuery struct{} - -type ListBankAccountQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[BankAccountQuery]] - -func NewListBankAccountQuery(opts PaginatedQueryOptions[BankAccountQuery]) ListBankAccountQuery { - return ListBankAccountQuery{ - PageSize: opts.PageSize, - Order: bunpaginate.OrderAsc, - Options: opts, - } -} - -func (s *Storage) ListBankAccounts(ctx context.Context, q ListBankAccountQuery) (*bunpaginate.Cursor[models.BankAccount], error) { - return PaginateWithOffset[PaginatedQueryOptions[BankAccountQuery], models.BankAccount](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[BankAccountQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = query. - Relation("RelatedAccounts") - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } else { - query = query.Order("created_at DESC") - } - - return query - }, - ) -} - -func (s *Storage) GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { - var account models.BankAccount - query := s.db.NewSelect(). - Model(&account). - Column("id", "created_at", "name", "created_at", "country", "metadata"). - Relation("RelatedAccounts") - - if expand { - query = query.ColumnExpr("pgp_sym_decrypt(account_number, ?, ?) AS decrypted_account_number", s.configEncryptionKey, encryptionOptions). - ColumnExpr("pgp_sym_decrypt(iban, ?, ?) AS decrypted_iban", s.configEncryptionKey, encryptionOptions). - ColumnExpr("pgp_sym_decrypt(swift_bic_code, ?, ?) AS decrypted_swift_bic_code", s.configEncryptionKey, encryptionOptions) - } - - err := query. - Where("id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("get bank account", err) - } - - return &account, nil -} diff --git a/cmd/api/internal/storage/bank_accounts_test.go b/cmd/api/internal/storage/bank_accounts_test.go deleted file mode 100644 index c2e1b2ac..00000000 --- a/cmd/api/internal/storage/bank_accounts_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package storage - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func insertBankAccounts(t *testing.T, store *Storage, connectorID models.ConnectorID) []models.BankAccount { - acc1 := models.BankAccount{ - ID: uuid.New(), - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - Name: "test_1", - IBAN: "FR7630006000011234567890189", - Country: "FR", - Metadata: map[string]string{ - "foo": "bar", - }, - } - _, err := store.DB().NewInsert(). - Model(&acc1). - Exec(context.Background()) - require.NoError(t, err) - - acc2 := models.BankAccount{ - ID: uuid.New(), - CreatedAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - Name: "test_2", - IBAN: "FR7630006000011234567891234", - Country: "GB", - Metadata: map[string]string{ - "foo2": "bar2", - }, - } - _, err = store.DB().NewInsert(). - Model(&acc2). - Exec(context.Background()) - require.NoError(t, err) - - return []models.BankAccount{acc1, acc2} -} - -func TestListBankAccounts(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - bankAccounts := insertBankAccounts(t, store, connectorID) - - for i := range bankAccounts { - bankAccounts[i].CreatedAt = bankAccounts[i].CreatedAt.UTC() - // The listing of bank accounts does not sent the IBAN info - bankAccounts[i].IBAN = "" - } - - t.Run("list all bank accounts with page size 1", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBankAccounts( - context.Background(), - NewListBankAccountQuery(NewPaginatedQueryOptions(BankAccountQuery{}).WithPageSize(1)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, bankAccounts[1], cursor.Data[0]) - - var query ListBankAccountQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListBankAccounts( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, bankAccounts[0], cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListBankAccounts( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - require.Equal(t, bankAccounts[1], cursor.Data[0]) - }) - - t.Run("list all bank accounts with page size 2", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBankAccounts( - context.Background(), - NewListBankAccountQuery(NewPaginatedQueryOptions(BankAccountQuery{}).WithPageSize(2)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - require.Equal(t, bankAccounts[1], cursor.Data[0]) - require.Equal(t, bankAccounts[0], cursor.Data[1]) - }) - - t.Run("list all bank accounts with page size > number of bank accounts", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListBankAccounts( - context.Background(), - NewListBankAccountQuery(NewPaginatedQueryOptions(BankAccountQuery{}).WithPageSize(10)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - require.Equal(t, bankAccounts[1], cursor.Data[0]) - require.Equal(t, bankAccounts[0], cursor.Data[1]) - }) -} diff --git a/cmd/api/internal/storage/connectors.go b/cmd/api/internal/storage/connectors.go deleted file mode 100644 index bc38ea4a..00000000 --- a/cmd/api/internal/storage/connectors.go +++ /dev/null @@ -1,19 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) IsConnectorInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - exists, err := s.db.NewSelect(). - Model(&models.Connector{}). - Where("id = ?", connectorID). - Exists(ctx) - if err != nil { - return false, e("find connector", err) - } - - return exists, nil -} diff --git a/cmd/api/internal/storage/connectors_test.go b/cmd/api/internal/storage/connectors_test.go deleted file mode 100644 index f2e9a7ef..00000000 --- a/cmd/api/internal/storage/connectors_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package storage - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -const testEncryptionOptions = "compress-algo=1, cipher-algo=aes256" -const encryptionKey = "test" - -// Helpers to add test data -func installConnector(t *testing.T, store *Storage) models.ConnectorID { - db := store.DB() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - connector := &models.Connector{ - ID: connectorID, - Name: "test_connector", - CreatedAt: time.Date(2023, 11, 13, 0, 0, 0, 0, time.UTC), - Provider: models.ConnectorProviderDummyPay, - } - - _, err := db.NewInsert().Model(connector).Exec(context.Background()) - require.NoError(t, err) - - _, err = db.NewUpdate(). - Model(&models.Connector{}). - Set("config = pgp_sym_encrypt(?::TEXT, ?, ?)", json.RawMessage(`{}`), encryptionKey, testEncryptionOptions). - Where("id = ?", connectorID). // Connector name is unique - Exec(context.Background()) - require.NoError(t, err) - - return connectorID -} diff --git a/cmd/api/internal/storage/main_test.go b/cmd/api/internal/storage/main_test.go deleted file mode 100644 index 29828d31..00000000 --- a/cmd/api/internal/storage/main_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package storage - -import ( - "context" - "crypto/rand" - "testing" - - "github.com/formancehq/go-libs/testing/docker" - "github.com/formancehq/go-libs/testing/utils" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/testing/platform/pgtesting" - migrationstorage "github.com/formancehq/payments/internal/storage" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/stdlib" - "github.com/stretchr/testify/require" - "github.com/uptrace/bun" - "github.com/uptrace/bun/dialect/pgdialect" -) - -var ( - srv *pgtesting.PostgresServer -) - -func TestMain(m *testing.M) { - utils.WithTestMain(func(t *utils.TestingTForMain) int { - srv = pgtesting.CreatePostgresServer(t, docker.NewPool(t, logging.Testing())) - - return m.Run() - }) -} - -func newStore(t *testing.T) *Storage { - t.Helper() - - pgServer := srv.NewDatabase(t) - - config, err := pgx.ParseConfig(pgServer.ConnString()) - require.NoError(t, err) - - key := make([]byte, 64) - _, err = rand.Read(key) - require.NoError(t, err) - - db := bun.NewDB(stdlib.OpenDB(*config), pgdialect.New()) - t.Cleanup(func() { - _ = db.Close() - }) - - err = migrationstorage.Migrate(context.Background(), db) - require.NoError(t, err) - - store := NewStorage( - db, - string(key), - ) - - return store -} diff --git a/cmd/api/internal/storage/metadata.go b/cmd/api/internal/storage/metadata.go deleted file mode 100644 index 76256558..00000000 --- a/cmd/api/internal/storage/metadata.go +++ /dev/null @@ -1,39 +0,0 @@ -package storage - -import ( - "context" - "time" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error { - var metadataToInsert []models.PaymentMetadata // nolint:prealloc // it's against a map - - for key, value := range metadata { - metadataToInsert = append(metadataToInsert, models.PaymentMetadata{ - PaymentID: paymentID, - Key: key, - Value: value, - Changelog: []models.MetadataChangelog{ - { - CreatedAt: time.Now(), - Value: value, - }, - }, - }) - } - - _, err := s.db.NewInsert(). - Model(&metadataToInsert). - On("CONFLICT (payment_id, key) DO UPDATE"). - Set("value = EXCLUDED.value"). - Set("changelog = payment_metadata.changelog || EXCLUDED.changelog"). - Where("payment_metadata.value != EXCLUDED.value"). - Exec(ctx) - if err != nil { - return e("failed to update payment metadata", err) - } - - return nil -} diff --git a/cmd/api/internal/storage/module.go b/cmd/api/internal/storage/module.go deleted file mode 100644 index e000fdff..00000000 --- a/cmd/api/internal/storage/module.go +++ /dev/null @@ -1,30 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunconnect" - "github.com/formancehq/go-libs/logging" - "github.com/uptrace/bun" - "go.uber.org/fx" -) - -func Module(connectionOptions bunconnect.ConnectionOptions, configEncryptionKey string, debug bool) fx.Option { - return fx.Options( - bunconnect.Module(connectionOptions, debug), - fx.Provide(func(db *bun.DB) *Storage { - return NewStorage(db, configEncryptionKey) - }), - fx.Invoke(func(lc fx.Lifecycle, repo *Storage) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - logging.FromContext(ctx).Debug("Ping database...") - - // TODO: Check migrations state and panic if migrations are not applied - - return nil - }, - }) - }), - ) -} diff --git a/cmd/api/internal/storage/paginate.go b/cmd/api/internal/storage/paginate.go deleted file mode 100644 index d8853fc5..00000000 --- a/cmd/api/internal/storage/paginate.go +++ /dev/null @@ -1,77 +0,0 @@ -package storage - -import ( - "context" - "encoding/json" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/query" - "github.com/uptrace/bun" -) - -type PaginatedQueryOptions[T any] struct { - QueryBuilder query.Builder `json:"qb"` - Sorter Sorter - PageSize uint64 `json:"pageSize"` - Options T `json:"options"` -} - -func (v *PaginatedQueryOptions[T]) UnmarshalJSON(data []byte) error { - type aux struct { - QueryBuilder json.RawMessage `json:"qb"` - Sorter Sorter `json:"Sorter"` - PageSize uint64 `json:"pageSize"` - Options T `json:"options"` - } - x := &aux{} - if err := json.Unmarshal(data, x); err != nil { - return err - } - - *v = PaginatedQueryOptions[T]{ - PageSize: x.PageSize, - Options: x.Options, - Sorter: x.Sorter, - } - - var err error - if x.QueryBuilder != nil { - v.QueryBuilder, err = query.ParseJSON(string(x.QueryBuilder)) - if err != nil { - return err - } - } - - return nil -} - -func (opts PaginatedQueryOptions[T]) WithQueryBuilder(qb query.Builder) PaginatedQueryOptions[T] { - opts.QueryBuilder = qb - - return opts -} - -func (opts PaginatedQueryOptions[T]) WithSorter(sorter Sorter) PaginatedQueryOptions[T] { - opts.Sorter = sorter - - return opts -} - -func (opts PaginatedQueryOptions[T]) WithPageSize(pageSize uint64) PaginatedQueryOptions[T] { - opts.PageSize = pageSize - - return opts -} - -func NewPaginatedQueryOptions[T any](options T) PaginatedQueryOptions[T] { - return PaginatedQueryOptions[T]{ - Options: options, - PageSize: bunpaginate.QueryDefaultPageSize, - } -} - -func PaginateWithOffset[FILTERS any, RETURN any](s *Storage, ctx context.Context, - q *bunpaginate.OffsetPaginatedQuery[FILTERS], builders ...func(query *bun.SelectQuery) *bun.SelectQuery) (*bunpaginate.Cursor[RETURN], error) { - query := s.db.NewSelect() - return bunpaginate.UsingOffset[FILTERS, RETURN](ctx, query, *q, builders...) -} diff --git a/cmd/api/internal/storage/payments.go b/cmd/api/internal/storage/payments.go deleted file mode 100644 index 4a3b0df9..00000000 --- a/cmd/api/internal/storage/payments.go +++ /dev/null @@ -1,213 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" - "github.com/uptrace/bun" -) - -type PaymentQuery struct{} - -type ListPaymentsQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[PaymentQuery]] - -func NewListPaymentsQuery(opts PaginatedQueryOptions[PaymentQuery]) ListPaymentsQuery { - return ListPaymentsQuery{ - PageSize: opts.PageSize, - Order: bunpaginate.OrderAsc, - Options: opts, - } -} - -func (s *Storage) paymentsQueryContext(qb query.Builder) (map[string]string, string, []any, error) { - metadata := make(map[string]string) - - where, args, err := qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { - switch { - case key == "reference": - return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil - - case key == "type", - key == "status", - key == "asset": - if operator != "$match" { - return "", nil, errors.Wrap(ErrValidation, "'type' column can only be used with $match") - } - return fmt.Sprintf("%s = ?", key), []any{value}, nil - - case key == "connectorID": - if operator != "$match" { - return "", nil, errors.Wrap(ErrValidation, "'type' column can only be used with $match") - } - return "connector_id = ?", []any{value}, nil - - case key == "amount": - return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil - - case key == "initialAmount": - return fmt.Sprintf("initial_amount %s ?", query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil - - case metadataRegex.Match([]byte(key)): - if operator != "$match" { - return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") - } - match := metadataRegex.FindAllStringSubmatch(key, 3) - - valueString, ok := value.(string) - if !ok { - return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("metadata value must be a string, got %T", value)) - } - - metadata[match[0][1]] = valueString - - // Do nothing here, as we don't want to add this to the query - return "", nil, nil - - default: - return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) - } - })) - - return metadata, where, args, err -} - -func (s *Storage) ListPayments(ctx context.Context, q ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { - var ( - metadata map[string]string - where string - args []any - err error - ) - if q.Options.QueryBuilder != nil { - metadata, where, args, err = s.paymentsQueryContext(q.Options.QueryBuilder) - if err != nil { - return nil, err - } - } - - return PaginateWithOffset[PaginatedQueryOptions[PaymentQuery], models.Payment](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[PaymentQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = query. - Relation("Metadata"). - Relation("Connector"). - Relation("Adjustments") - - if where != "" { - query = query.Where(where, args...) - } - - if len(metadata) > 0 { - metadataQuery := s.db.NewSelect().Model((*models.PaymentMetadata)(nil)) - for key, value := range metadata { - metadataQuery = metadataQuery.Where("payment_metadata.key = ? AND payment_metadata.value = ?", key, value) - } - query = query.With("_metadata", metadataQuery) - query = query.Where("payment.id IN (SELECT payment_id FROM _metadata)") - } - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } else { - query = query.Order("created_at DESC") - } - - return query - }, - ) -} - -func (s *Storage) GetPayment(ctx context.Context, id string) (*models.Payment, error) { - var payment models.Payment - - err := s.db.NewSelect(). - Model(&payment). - Relation("Connector"). - Relation("Metadata"). - Relation("Adjustments"). - Where("payment.id = ?", id). - Scan(ctx) - if err != nil { - return nil, e(fmt.Sprintf("failed to get payment %s", id), err) - } - - return &payment, nil -} - -func (s *Storage) UpsertPayments(ctx context.Context, payments []*models.Payment) error { - if len(payments) == 0 { - return nil - } - - _, err := s.db.NewInsert(). - Model(&payments). - On("CONFLICT (reference) DO UPDATE"). - Set("amount = EXCLUDED.amount"). - Set("type = EXCLUDED.type"). - Set("status = EXCLUDED.status"). - Set("raw_data = EXCLUDED.raw_data"). - Set("scheme = EXCLUDED.scheme"). - Set("asset = EXCLUDED.asset"). - Set("source_account_id = EXCLUDED.source_account_id"). - Set("destination_account_id = EXCLUDED.destination_account_id"). - Exec(ctx) - if err != nil { - return e("failed to create payments", err) - } - - var adjustments []*models.PaymentAdjustment - var metadata []*models.PaymentMetadata - - for i := range payments { - for _, adjustment := range payments[i].Adjustments { - if adjustment.Reference == "" { - continue - } - - adjustment.PaymentID = payments[i].ID - - adjustments = append(adjustments, adjustment) - } - - for _, data := range payments[i].Metadata { - data.PaymentID = payments[i].ID - data.Changelog = append(data.Changelog, - models.MetadataChangelog{ - CreatedAt: time.Now(), - Value: data.Value, - }) - - metadata = append(metadata, data) - } - } - - if len(adjustments) > 0 { - _, err = s.db.NewInsert(). - Model(&adjustments). - On("CONFLICT (reference) DO NOTHING"). - Exec(ctx) - if err != nil { - return e("failed to create adjustments", err) - } - } - - if len(metadata) > 0 { - _, err = s.db.NewInsert(). - Model(&metadata). - On("CONFLICT (payment_id, key) DO UPDATE"). - Set("value = EXCLUDED.value"). - Set("changelog = metadata.changelog || EXCLUDED.changelog"). - Where("metadata.value != EXCLUDED.value"). - Exec(ctx) - if err != nil { - return e("failed to create metadata", err) - } - } - - return nil -} diff --git a/cmd/api/internal/storage/payments_test.go b/cmd/api/internal/storage/payments_test.go deleted file mode 100644 index 997094c0..00000000 --- a/cmd/api/internal/storage/payments_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package storage - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/stretchr/testify/require" -) - -func insertPayments(t *testing.T, store *Storage, connectorID models.ConnectorID) []models.Payment { - p1 := models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "test_1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - Reference: "test_1", - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusPending, - Scheme: models.PaymentSchemeA2A, - Asset: models.Asset("USD/2"), - SourceAccountID: &models.AccountID{ - Reference: "test_account", - ConnectorID: connectorID, - }, - DestinationAccountID: &models.AccountID{ - Reference: "test_account2", - ConnectorID: connectorID, - }, - } - _, err := store.DB().NewInsert(). - Model(&p1). - Exec(context.Background()) - require.NoError(t, err) - - p2 := models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "test_2", - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - Reference: "test_2", - Amount: big.NewInt(200), - InitialAmount: big.NewInt(100), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusPending, - Scheme: models.PaymentSchemeA2A, - Asset: models.Asset("EUR/2"), - SourceAccountID: &models.AccountID{ - Reference: "test_account", - ConnectorID: connectorID, - }, - DestinationAccountID: &models.AccountID{ - Reference: "test_account2", - ConnectorID: connectorID, - }, - } - _, err = store.DB().NewInsert(). - Model(&p2). - Exec(context.Background()) - require.NoError(t, err) - - return []models.Payment{p1, p2} -} - -func TestListPayments(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - insertAccounts(t, store, connectorID) - payments := insertPayments(t, store, connectorID) - - t.Run("list all payments with page size 1", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListPayments( - context.Background(), - NewListPaymentsQuery(NewPaginatedQueryOptions(PaymentQuery{}).WithPageSize(1)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].Connector = nil - require.Equal(t, payments[1], cursor.Data[0]) - - var query ListPaymentsQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListPayments( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].Connector = nil - require.Equal(t, payments[0], cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListPayments( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].Connector = nil - require.Equal(t, payments[1], cursor.Data[0]) - }) - - t.Run("list all payments with page size 2", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListPayments( - context.Background(), - NewListPaymentsQuery(NewPaginatedQueryOptions(PaymentQuery{}).WithPageSize(2)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].Connector = nil - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[1].Connector = nil - require.Equal(t, payments[1], cursor.Data[0]) - require.Equal(t, payments[0], cursor.Data[1]) - }) - - t.Run("list all payments with page size > number of payments", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListPayments( - context.Background(), - NewListPaymentsQuery(NewPaginatedQueryOptions(PaymentQuery{}).WithPageSize(10)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].Connector = nil - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[1].Connector = nil - require.Equal(t, payments[1], cursor.Data[0]) - require.Equal(t, payments[0], cursor.Data[1]) - }) -} diff --git a/cmd/api/internal/storage/ping.go b/cmd/api/internal/storage/ping.go deleted file mode 100644 index 2832abb0..00000000 --- a/cmd/api/internal/storage/ping.go +++ /dev/null @@ -1,5 +0,0 @@ -package storage - -func (s *Storage) Ping() error { - return s.db.Ping() -} diff --git a/cmd/api/internal/storage/pools.go b/cmd/api/internal/storage/pools.go deleted file mode 100644 index d9827f9c..00000000 --- a/cmd/api/internal/storage/pools.go +++ /dev/null @@ -1,117 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -func (s *Storage) CreatePool(ctx context.Context, pool *models.Pool) error { - var id uuid.UUID - err := s.db.NewInsert(). - Model(pool). - Returning("id"). - Scan(ctx, &id) - if err != nil { - return e("failed to create pool", err) - } - pool.ID = id - - return nil -} - -func (s *Storage) AddAccountsToPool(ctx context.Context, poolAccounts []*models.PoolAccounts) error { - _, err := s.db.NewInsert(). - Model(&poolAccounts). - Exec(ctx) - if err != nil { - return e("failed to add accounts to pool", err) - } - - return nil -} - -func (s *Storage) AddAccountToPool(ctx context.Context, poolAccount *models.PoolAccounts) error { - _, err := s.db.NewInsert(). - Model(poolAccount). - Exec(ctx) - if err != nil { - return e("failed to add account to pool", err) - } - - return nil -} - -func (s *Storage) RemoveAccountFromPool(ctx context.Context, poolAccount *models.PoolAccounts) error { - _, err := s.db.NewDelete(). - Model(poolAccount). - Where("pool_id = ?", poolAccount.PoolID). - Where("account_id = ?", poolAccount.AccountID). - Exec(ctx) - if err != nil { - return e("failed to remove account from pool", err) - } - - return nil -} - -type PoolQuery struct{} - -type ListPoolsQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[PoolQuery]] - -func NewListPoolsQuery(opts PaginatedQueryOptions[PoolQuery]) ListPoolsQuery { - return ListPoolsQuery{ - PageSize: opts.PageSize, - Order: bunpaginate.OrderAsc, - Options: opts, - } -} - -func (s *Storage) ListPools(ctx context.Context, q ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { - cursor, err := PaginateWithOffset[PaginatedQueryOptions[PoolQuery], models.Pool](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[PoolQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = query. - Relation("PoolAccounts") - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } else { - query = query.Order("created_at DESC") - } - - return query - }, - ) - return cursor, err -} - -func (s *Storage) GetPool(ctx context.Context, poolID uuid.UUID) (*models.Pool, error) { - var pool models.Pool - - err := s.db.NewSelect(). - Model(&pool). - Where("id = ?", poolID). - Relation("PoolAccounts"). - Scan(ctx) - if err != nil { - return nil, e("failed to get pool", err) - } - - return &pool, nil -} - -func (s *Storage) DeletePool(ctx context.Context, poolID uuid.UUID) error { - _, err := s.db.NewDelete(). - Model(&models.Pool{}). - Where("id = ?", poolID). - Exec(ctx) - if err != nil { - return e("failed to delete pool", err) - } - - return nil -} diff --git a/cmd/api/internal/storage/pools_test.go b/cmd/api/internal/storage/pools_test.go deleted file mode 100644 index 5c6d78fe..00000000 --- a/cmd/api/internal/storage/pools_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package storage - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func insertPools(t *testing.T, store *Storage, accountIDs []models.AccountID) []uuid.UUID { - pool1 := models.Pool{ - Name: "test", - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - } - var uuid1 uuid.UUID - err := store.DB().NewInsert(). - Model(&pool1). - Returning("id"). - Scan(context.Background(), &uuid1) - require.NoError(t, err) - - poolAccounts1 := models.PoolAccounts{ - PoolID: uuid1, - AccountID: accountIDs[0], - } - _, err = store.DB().NewInsert(). - Model(&poolAccounts1). - Exec(context.Background()) - require.NoError(t, err) - - var uuid2 uuid.UUID - pool2 := models.Pool{ - Name: "test2", - CreatedAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - } - err = store.DB().NewInsert(). - Model(&pool2). - Returning("id"). - Scan(context.Background(), &uuid2) - require.NoError(t, err) - - poolAccounts2 := []*models.PoolAccounts{ - { - PoolID: uuid2, - AccountID: accountIDs[0], - }, - { - PoolID: uuid2, - AccountID: accountIDs[1], - }, - } - _, err = store.DB().NewInsert(). - Model(&poolAccounts2). - Exec(context.Background()) - require.NoError(t, err) - - return []uuid.UUID{uuid1, uuid2} -} - -func TestCreatePools(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - accounts := insertAccounts(t, store, connectorID) - - pool := &models.Pool{ - Name: "test", - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - PoolAccounts: []*models.PoolAccounts{}, - } - for _, account := range accounts { - pool.PoolAccounts = append(pool.PoolAccounts, &models.PoolAccounts{ - AccountID: account, - }) - } - - err := store.CreatePool(context.Background(), pool) - require.NoError(t, err) - require.NotEqual(t, uuid.Nil, pool.ID) -} - -func TestAddAccountsToPool(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - accounts := insertAccounts(t, store, connectorID) - poolIDs := insertPools(t, store, accounts) - - poolAccounts := []*models.PoolAccounts{ - { - PoolID: poolIDs[0], - AccountID: accounts[1], - }, - } - - err := store.AddAccountsToPool(context.Background(), poolAccounts) - require.NoError(t, err) - - pool, err := store.GetPool(context.Background(), poolIDs[0]) - require.NoError(t, err) - require.Equal(t, 2, len(pool.PoolAccounts)) - require.Equal(t, accounts[0], pool.PoolAccounts[0].AccountID) - require.Equal(t, accounts[1], pool.PoolAccounts[1].AccountID) -} - -func TestRemoveAccoutsToPool(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - accounts := insertAccounts(t, store, connectorID) - poolIDs := insertPools(t, store, accounts) - - poolAccounts := []*models.PoolAccounts{ - { - PoolID: poolIDs[0], - AccountID: accounts[0], - }, - } - - err := store.RemoveAccountFromPool(context.Background(), poolAccounts[0]) - require.NoError(t, err) - - pool, err := store.GetPool(context.Background(), poolIDs[0]) - require.NoError(t, err) - require.Equal(t, 0, len(pool.PoolAccounts)) -} - -func TestListPools(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - accounts := insertAccounts(t, store, connectorID) - insertedPools := insertPools(t, store, accounts) - - t.Run("list all pools", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListPools( - context.Background(), - NewListPoolsQuery(NewPaginatedQueryOptions(PoolQuery{}).WithPageSize(15)), - ) - require.NoError(t, err) - require.Equal(t, 2, len(cursor.Data)) - require.Equal(t, 15, cursor.PageSize) - require.Equal(t, false, cursor.HasMore) - require.Equal(t, "", cursor.Previous) - require.Equal(t, "", cursor.Next) - require.Equal(t, insertedPools[1], cursor.Data[0].ID) - require.Equal(t, 2, len(cursor.Data[0].PoolAccounts)) - require.Equal(t, insertedPools[0], cursor.Data[1].ID) - require.Equal(t, 1, len(cursor.Data[1].PoolAccounts)) - }) - - t.Run("list all pools with page size 1", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListPools( - context.Background(), - NewListPoolsQuery(NewPaginatedQueryOptions(PoolQuery{}).WithPageSize(1)), - ) - require.NoError(t, err) - require.Equal(t, 1, len(cursor.Data)) - require.Equal(t, 1, cursor.PageSize) - require.Equal(t, true, cursor.HasMore) - require.Equal(t, "", cursor.Previous) - require.Equal(t, insertedPools[1], cursor.Data[0].ID) - require.Equal(t, 2, len(cursor.Data[0].PoolAccounts)) - - var query ListPoolsQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListPools(context.Background(), query) - require.NoError(t, err) - require.Equal(t, 1, len(cursor.Data)) - require.Equal(t, 1, cursor.PageSize) - require.Equal(t, false, cursor.HasMore) - require.Equal(t, insertedPools[0], cursor.Data[0].ID) - require.Equal(t, 1, len(cursor.Data[0].PoolAccounts)) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListPools(context.Background(), query) - require.NoError(t, err) - require.Equal(t, 1, len(cursor.Data)) - require.Equal(t, 1, cursor.PageSize) - require.Equal(t, true, cursor.HasMore) - require.Equal(t, insertedPools[1], cursor.Data[0].ID) - require.Equal(t, 2, len(cursor.Data[0].PoolAccounts)) - }) -} diff --git a/cmd/api/internal/storage/repository.go b/cmd/api/internal/storage/repository.go deleted file mode 100644 index 6b203bf6..00000000 --- a/cmd/api/internal/storage/repository.go +++ /dev/null @@ -1,20 +0,0 @@ -package storage - -import ( - "github.com/uptrace/bun" -) - -type Storage struct { - db *bun.DB - configEncryptionKey string -} - -const encryptionOptions = "compress-algo=1, cipher-algo=aes256" - -func NewStorage(db *bun.DB, configEncryptionKey string) *Storage { - return &Storage{db: db, configEncryptionKey: configEncryptionKey} -} - -func (s *Storage) DB() *bun.DB { - return s.db -} diff --git a/cmd/api/internal/storage/sort.go b/cmd/api/internal/storage/sort.go deleted file mode 100644 index 2ec3d5c0..00000000 --- a/cmd/api/internal/storage/sort.go +++ /dev/null @@ -1,33 +0,0 @@ -package storage - -import ( - "fmt" - - "github.com/uptrace/bun" -) - -type SortOrder string - -const ( - SortOrderAsc SortOrder = "asc" - SortOrderDesc SortOrder = "desc" -) - -type sortExpression struct { - Column string `json:"column"` - Order SortOrder `json:"order"` -} - -type Sorter []sortExpression - -func (s Sorter) Add(column string, order SortOrder) Sorter { - return append(s, sortExpression{column, order}) -} - -func (s Sorter) Apply(query *bun.SelectQuery) *bun.SelectQuery { - for _, expr := range s { - query = query.Order(fmt.Sprintf("%s %s", expr.Column, expr.Order)) - } - - return query -} diff --git a/cmd/api/internal/storage/transfer_initiation.go b/cmd/api/internal/storage/transfer_initiation.go deleted file mode 100644 index 313639e8..00000000 --- a/cmd/api/internal/storage/transfer_initiation.go +++ /dev/null @@ -1,112 +0,0 @@ -package storage - -import ( - "context" - "fmt" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/query" - "github.com/formancehq/payments/internal/models" - "github.com/uptrace/bun" -) - -func (s *Storage) GetTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) { - var transferInitiation models.TransferInitiation - - query := s.db.NewSelect(). - Column("id", "connector_id", "created_at", "scheduled_at", "description", "type", "source_account_id", "destination_account_id", "provider", "initial_amount", "amount", "asset", "metadata"). - Model(&transferInitiation). - Relation("RelatedAdjustments"). - Where("id = ?", id) - - err := query.Scan(ctx) - if err != nil { - return nil, e("failed to get transfer initiation", err) - } - - transferInitiation.SortRelatedAdjustments() - - transferInitiation.RelatedPayments, err = s.ReadTransferInitiationPayments(ctx, id) - if err != nil { - return nil, e("failed to get transfer initiation payments", err) - } - - return &transferInitiation, nil -} - -func (s *Storage) ReadTransferInitiationPayments(ctx context.Context, id models.TransferInitiationID) ([]*models.TransferInitiationPayment, error) { - var payments []*models.TransferInitiationPayment - - query := s.db.NewSelect(). - Column("transfer_initiation_id", "payment_id", "created_at", "status", "error"). - Model(&payments). - Where("transfer_initiation_id = ?", id). - Order("created_at DESC") - - err := query.Scan(ctx) - if err != nil { - return nil, e("failed to get transfer initiation payments", err) - } - - return payments, nil -} - -type TransferInitiationQuery struct{} - -type ListTransferInitiationsQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[TransferInitiationQuery]] - -func NewListTransferInitiationsQuery(opts PaginatedQueryOptions[TransferInitiationQuery]) ListTransferInitiationsQuery { - return ListTransferInitiationsQuery{ - PageSize: opts.PageSize, - Order: bunpaginate.OrderAsc, - Options: opts, - } -} - -func (s *Storage) ListTransferInitiations(ctx context.Context, q ListTransferInitiationsQuery) (*bunpaginate.Cursor[models.TransferInitiation], error) { - return PaginateWithOffset[PaginatedQueryOptions[TransferInitiationQuery], models.TransferInitiation](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[TransferInitiationQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = query. - Column("id", "connector_id", "created_at", "scheduled_at", "description", "type", "source_account_id", "destination_account_id", "provider", "initial_amount", "amount", "asset", "metadata"). - Relation("RelatedAdjustments") - - if q.Options.QueryBuilder != nil { - where, args, err := s.transferInitiationQueryContext(q.Options.QueryBuilder) - if err != nil { - // TODO: handle error - panic(err) - } - query = query.Where(where, args...) - } - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } else { - query = query.Order("created_at DESC") - } - - return query - }, - ) -} - -func (s *Storage) transferInitiationQueryContext(qb query.Builder) (string, []any, error) { - return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { - switch { - case key == "source_account_id", key == "destination_account_id": - if operator != "$match" { - return "", nil, fmt.Errorf("'%s' columns can only be used with $match", key) - } - - switch accountID := value.(type) { - case string: - return fmt.Sprintf("%s = ?", key), []any{accountID}, nil - default: - return "", nil, fmt.Errorf("unexpected type %T for column '%s'", accountID, key) - } - default: - return "", nil, fmt.Errorf("unknown key '%s' when building query", key) - } - })) -} diff --git a/cmd/api/internal/storage/transfer_initiation_test.go b/cmd/api/internal/storage/transfer_initiation_test.go deleted file mode 100644 index 0cd9aae7..00000000 --- a/cmd/api/internal/storage/transfer_initiation_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package storage - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/stretchr/testify/require" -) - -func insertTransferInitiation(t *testing.T, store *Storage, connectorID models.ConnectorID) []models.TransferInitiation { - tf1 := models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "tf_1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 14, 8, 0, 0, 0, time.UTC), - Description: "test_1", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &models.AccountID{ - Reference: "test_account", - ConnectorID: connectorID, - }, - DestinationAccountID: models.AccountID{ - Reference: "test_account2", - ConnectorID: connectorID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: "EUR/2", - Metadata: map[string]string{ - "foo": "bar", - }, - } - _, err := store.DB().NewInsert(). - Model(&tf1). - Exec(context.Background()) - require.NoError(t, err) - - tf2 := models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "tf_2", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 14, 9, 0, 0, 0, time.UTC), - Description: "test_2", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &models.AccountID{ - Reference: "test_account", - ConnectorID: connectorID, - }, - DestinationAccountID: models.AccountID{ - Reference: "test_account2", - ConnectorID: connectorID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: "USD/2", - Metadata: map[string]string{ - "foo2": "bar2", - }, - } - - _, err = store.DB().NewInsert(). - Model(&tf2). - Exec(context.Background()) - require.NoError(t, err) - - return []models.TransferInitiation{tf1, tf2} -} - -func TestListTransferInitiation(t *testing.T) { - t.Parallel() - - store := newStore(t) - - connectorID := installConnector(t, store) - insertAccounts(t, store, connectorID) - tfs := insertTransferInitiation(t, store, connectorID) - - t.Run("list all transfer initiations with page size 1", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListTransferInitiations( - context.Background(), - NewListTransferInitiationsQuery(NewPaginatedQueryOptions(TransferInitiationQuery{}).WithPageSize(1)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].ScheduledAt = cursor.Data[0].ScheduledAt.UTC() - require.Equal(t, tfs[1], cursor.Data[0]) - - var query ListTransferInitiationsQuery - err = bunpaginate.UnmarshalCursor(cursor.Next, &query) - require.NoError(t, err) - cursor, err = store.ListTransferInitiations( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].ScheduledAt = cursor.Data[0].ScheduledAt.UTC() - require.Equal(t, tfs[0], cursor.Data[0]) - - err = bunpaginate.UnmarshalCursor(cursor.Previous, &query) - require.NoError(t, err) - cursor, err = store.ListTransferInitiations( - context.Background(), - query, - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 1) - require.True(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].ScheduledAt = cursor.Data[0].ScheduledAt.UTC() - require.Equal(t, tfs[1], cursor.Data[0]) - }) - - t.Run("list all transfer initiations with page size 2", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListTransferInitiations( - context.Background(), - NewListTransferInitiationsQuery(NewPaginatedQueryOptions(TransferInitiationQuery{}).WithPageSize(2)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].ScheduledAt = cursor.Data[0].ScheduledAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[1].ScheduledAt = cursor.Data[1].ScheduledAt.UTC() - require.Equal(t, tfs[1], cursor.Data[0]) - require.Equal(t, tfs[0], cursor.Data[1]) - }) - - t.Run("list all transfer initiations with page size > number of transfer initiations", func(t *testing.T) { - t.Parallel() - - cursor, err := store.ListTransferInitiations( - context.Background(), - NewListTransferInitiationsQuery(NewPaginatedQueryOptions(TransferInitiationQuery{}).WithPageSize(10)), - ) - require.NoError(t, err) - require.Len(t, cursor.Data, 2) - require.False(t, cursor.HasMore) - cursor.Data[0].CreatedAt = cursor.Data[0].CreatedAt.UTC() - cursor.Data[0].ScheduledAt = cursor.Data[0].ScheduledAt.UTC() - cursor.Data[1].CreatedAt = cursor.Data[1].CreatedAt.UTC() - cursor.Data[1].ScheduledAt = cursor.Data[1].ScheduledAt.UTC() - require.Equal(t, tfs[1], cursor.Data[0]) - require.Equal(t, tfs[0], cursor.Data[1]) - }) -} diff --git a/cmd/api/internal/storage/utils.go b/cmd/api/internal/storage/utils.go deleted file mode 100644 index f61f5e34..00000000 --- a/cmd/api/internal/storage/utils.go +++ /dev/null @@ -1,7 +0,0 @@ -package storage - -import "regexp" - -var ( - metadataRegex = regexp.MustCompile("metadata\\[(.+)\\]") -) diff --git a/cmd/api/root.go b/cmd/api/root.go deleted file mode 100644 index 145e5cf8..00000000 --- a/cmd/api/root.go +++ /dev/null @@ -1,47 +0,0 @@ -package api - -import ( - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/aws/iam" - "github.com/formancehq/go-libs/bun/bunconnect" - "github.com/formancehq/go-libs/otlp" - "github.com/formancehq/go-libs/otlp/otlpmetrics" - "github.com/formancehq/go-libs/otlp/otlptraces" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/go-libs/service" - "github.com/spf13/cobra" -) - -func NewAPI( - version string, - addAutoMigrateCommandFunc func(cmd *cobra.Command), -) *cobra.Command { - - root := &cobra.Command{ - Use: "api", - Short: "api", - DisableAutoGenTag: true, - } - - cobra.EnableTraverseRunHooks = true - - server := newServer(version) - addAutoMigrateCommandFunc(server) - root.AddCommand(server) - - server.Flags().BoolP("toggle", "t", false, "Help message for toggle") - server.Flags().String(configEncryptionKeyFlag, "", "Config encryption key") - server.Flags().String(envFlag, "local", "Environment") - server.Flags().String(listenFlag, ":8080", "Listen address") - - service.AddFlags(server.Flags()) - otlp.AddFlags(server.Flags()) - otlptraces.AddFlags(server.Flags()) - otlpmetrics.AddFlags(server.Flags()) - auth.AddFlags(server.Flags()) - publish.AddFlags(serviceName, server.Flags()) - bunconnect.AddFlags(server.Flags()) - iam.AddFlags(server.Flags()) - - return root -} diff --git a/cmd/api/serve.go b/cmd/api/serve.go deleted file mode 100644 index ae52dc9e..00000000 --- a/cmd/api/serve.go +++ /dev/null @@ -1,91 +0,0 @@ -package api - -import ( - "github.com/bombsimon/logrusr/v3" - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/bun/bunconnect" - "github.com/formancehq/go-libs/otlp/otlpmetrics" - "github.com/formancehq/go-libs/otlp/otlptraces" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/go-libs/service" - "github.com/formancehq/payments/cmd/api/internal/api" - "github.com/formancehq/payments/cmd/api/internal/storage" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/metric/noop" - "go.uber.org/fx" -) - -const ( - stackURLFlag = "stack-url" - configEncryptionKeyFlag = "config-encryption-key" - envFlag = "env" - listenFlag = "listen" - - serviceName = "Payments" -) - -func newServer(version string) *cobra.Command { - return &cobra.Command{ - Use: "serve", - Aliases: []string{"server"}, - Short: "Launch server", - SilenceUsage: true, - RunE: runServer(version), - } -} - -func runServer(version string) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - setLogger() - - databaseOptions, err := prepareDatabaseOptions(cmd, service.IsDebug(cmd)) - if err != nil { - return err - } - - options := make([]fx.Option, 0) - - options = append(options, databaseOptions) - options = append(options, - otlptraces.FXModuleFromFlags(cmd), - otlpmetrics.FXModuleFromFlags(cmd), - auth.FXModuleFromFlags(cmd), - fx.Provide(fx.Annotate(noop.NewMeterProvider, fx.As(new(metric.MeterProvider)))), - ) - options = append(options, publish.FXModuleFromFlags(cmd, service.IsDebug(cmd))) - listen, _ := cmd.Flags().GetString(listenFlag) - stackURL, _ := cmd.Flags().GetString(stackURLFlag) - otlpTraces, _ := cmd.Flags().GetBool(otlptraces.OtelTracesFlag) - - options = append(options, api.HTTPModule(sharedapi.ServiceInfo{ - Version: version, - Debug: service.IsDebug(cmd), - }, listen, stackURL, otlpTraces)) - - return service.New(cmd.OutOrStdout(), options...).Run(cmd) - } -} - -func setLogger() { - // Add a dedicated logger for opentelemetry in case of error - otel.SetLogger(logrusr.New(logrus.New().WithField("component", "otlp"))) -} - -func prepareDatabaseOptions(cmd *cobra.Command, debug bool) (fx.Option, error) { - configEncryptionKey, _ := cmd.Flags().GetString(configEncryptionKeyFlag) - if configEncryptionKey == "" { - return nil, errors.New("missing config encryption key") - } - - connectionOptions, err := bunconnect.ConnectionOptionsFromFlags(cmd) - if err != nil { - return nil, err - } - - return storage.Module(*connectionOptions, configEncryptionKey, debug), nil -} diff --git a/cmd/connectors/internal/api/api_utils_test.go b/cmd/connectors/internal/api/api_utils_test.go deleted file mode 100644 index 266f5c1c..00000000 --- a/cmd/connectors/internal/api/api_utils_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package api - -import ( - "testing" - - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/dummypay" - gomock "github.com/golang/mock/gomock" -) - -func newServiceTestingBackend(t *testing.T) (*backend.MockServiceBackend, *backend.MockService) { - ctrl := gomock.NewController(t) - mockService := backend.NewMockService(ctrl) - backend := backend.NewMockServiceBackend(ctrl) - backend. - EXPECT(). - GetService(). - MinTimes(0). - Return(mockService) - t.Cleanup(func() { - ctrl.Finish() - }) - return backend, mockService -} - -func newConnectorManagerTestingBackend(t *testing.T) (*backend.MockManagerBackend[dummypay.Config], *backend.MockManager[dummypay.Config]) { - ctrl := gomock.NewController(t) - mockManager := backend.NewMockManager[dummypay.Config](ctrl) - backend := backend.NewMockManagerBackend[dummypay.Config](ctrl) - backend. - EXPECT(). - GetManager(). - MinTimes(0). - Return(mockManager) - t.Cleanup(func() { - ctrl.Finish() - }) - return backend, mockManager -} - -func ptr[T any](v T) *T { - return &v -} diff --git a/cmd/connectors/internal/api/backend/backend.go b/cmd/connectors/internal/api/backend/backend.go deleted file mode 100644 index 119117d3..00000000 --- a/cmd/connectors/internal/api/backend/backend.go +++ /dev/null @@ -1,77 +0,0 @@ -package backend - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -//go:generate mockgen -source backend.go -destination backend_generated.go -package backend . Service -type Service interface { - Ping() error - CreateBankAccount(ctx context.Context, req *service.CreateBankAccountRequest) (*models.BankAccount, error) - ForwardBankAccountToConnector(ctx context.Context, id string, req *service.ForwardBankAccountToConnectorRequest) (*models.BankAccount, error) - UpdateBankAccountMetadata(ctx context.Context, id string, req *service.UpdateBankAccountMetadataRequest) error - ListConnectors(ctx context.Context) ([]*models.Connector, error) - CreateTransferInitiation(ctx context.Context, req *service.CreateTransferInitiationRequest) (*models.TransferInitiation, error) - UpdateTransferInitiationStatus(ctx context.Context, transferID string, req *service.UpdateTransferInitiationStatusRequest) error - RetryTransferInitiation(ctx context.Context, id string) error - DeleteTransferInitiation(ctx context.Context, id string) error - ReverseTransferInitiation(ctx context.Context, transferID string, req *service.ReverseTransferInitiationRequest) (*models.TransferReversal, error) -} - -//go:generate mockgen -source backend.go -destination backend_generated.go -package backend . Manager -type Manager[ConnectorConfig models.ConnectorConfigObject] interface { - IsInstalled(ctx context.Context, connectorID models.ConnectorID) (bool, error) - Connectors() map[string]*manager.ConnectorManager - ReadConfig(ctx context.Context, connectorID models.ConnectorID) (ConnectorConfig, error) - UpdateConfig(ctx context.Context, connectorID models.ConnectorID, config ConnectorConfig) error - ListTasksStates(ctx context.Context, connectorID models.ConnectorID, q storage.ListTasksQuery) (*bunpaginate.Cursor[models.Task], error) - CreateWebhookAndContext(ctx context.Context, webhook *models.Webhook) (context.Context, error) - ReadTaskState(ctx context.Context, connectorID models.ConnectorID, taskID uuid.UUID) (*models.Task, error) - Install(ctx context.Context, name string, config ConnectorConfig) (models.ConnectorID, error) - Reset(ctx context.Context, connectorID models.ConnectorID) error - Uninstall(ctx context.Context, connectorID models.ConnectorID) error -} - -type ServiceBackend interface { - GetService() Service -} - -type DefaultServiceBackend struct { - service Service -} - -func (d DefaultServiceBackend) GetService() Service { - return d.service -} - -func NewDefaultBackend(service Service) ServiceBackend { - return &DefaultServiceBackend{ - service: service, - } -} - -type ManagerBackend[ConnectorConfig models.ConnectorConfigObject] interface { - GetManager() Manager[ConnectorConfig] -} - -type DefaultManagerBackend[ConnectorConfig models.ConnectorConfigObject] struct { - manager Manager[ConnectorConfig] -} - -func (m DefaultManagerBackend[ConnectorConfig]) GetManager() Manager[ConnectorConfig] { - return m.manager -} - -func NewDefaultManagerBackend[ConnectorConfig models.ConnectorConfigObject](manager Manager[ConnectorConfig]) ManagerBackend[ConnectorConfig] { - return DefaultManagerBackend[ConnectorConfig]{ - manager: manager, - } -} diff --git a/cmd/connectors/internal/api/backend/backend_generated.go b/cmd/connectors/internal/api/backend/backend_generated.go deleted file mode 100644 index b4f7b09f..00000000 --- a/cmd/connectors/internal/api/backend/backend_generated.go +++ /dev/null @@ -1,429 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: backend.go - -// Package backend is a generated GoMock package. -package backend - -import ( - context "context" - api "github.com/formancehq/go-libs/bun/bunpaginate" - reflect "reflect" - - connectors_manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - service "github.com/formancehq/payments/cmd/connectors/internal/api/service" - storage "github.com/formancehq/payments/cmd/connectors/internal/storage" - models "github.com/formancehq/payments/internal/models" - gomock "github.com/golang/mock/gomock" - uuid "github.com/google/uuid" -) - -// MockService is a mock of Service interface. -type MockService struct { - ctrl *gomock.Controller - recorder *MockServiceMockRecorder -} - -// MockServiceMockRecorder is the mock recorder for MockService. -type MockServiceMockRecorder struct { - mock *MockService -} - -// NewMockService creates a new mock instance. -func NewMockService(ctrl *gomock.Controller) *MockService { - mock := &MockService{ctrl: ctrl} - mock.recorder = &MockServiceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockService) EXPECT() *MockServiceMockRecorder { - return m.recorder -} - -// CreateBankAccount mocks base method. -func (m *MockService) CreateBankAccount(ctx context.Context, req *service.CreateBankAccountRequest) (*models.BankAccount, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateBankAccount", ctx, req) - ret0, _ := ret[0].(*models.BankAccount) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CreateBankAccount indicates an expected call of CreateBankAccount. -func (mr *MockServiceMockRecorder) CreateBankAccount(ctx, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBankAccount", reflect.TypeOf((*MockService)(nil).CreateBankAccount), ctx, req) -} - -// CreateTransferInitiation mocks base method. -func (m *MockService) CreateTransferInitiation(ctx context.Context, req *service.CreateTransferInitiationRequest) (*models.TransferInitiation, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateTransferInitiation", ctx, req) - ret0, _ := ret[0].(*models.TransferInitiation) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CreateTransferInitiation indicates an expected call of CreateTransferInitiation. -func (mr *MockServiceMockRecorder) CreateTransferInitiation(ctx, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransferInitiation", reflect.TypeOf((*MockService)(nil).CreateTransferInitiation), ctx, req) -} - -// DeleteTransferInitiation mocks base method. -func (m *MockService) DeleteTransferInitiation(ctx context.Context, id string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteTransferInitiation", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteTransferInitiation indicates an expected call of DeleteTransferInitiation. -func (mr *MockServiceMockRecorder) DeleteTransferInitiation(ctx, id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTransferInitiation", reflect.TypeOf((*MockService)(nil).DeleteTransferInitiation), ctx, id) -} - -// ForwardBankAccountToConnector mocks base method. -func (m *MockService) ForwardBankAccountToConnector(ctx context.Context, id string, req *service.ForwardBankAccountToConnectorRequest) (*models.BankAccount, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ForwardBankAccountToConnector", ctx, id, req) - ret0, _ := ret[0].(*models.BankAccount) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ForwardBankAccountToConnector indicates an expected call of ForwardBankAccountToConnector. -func (mr *MockServiceMockRecorder) ForwardBankAccountToConnector(ctx, id, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ForwardBankAccountToConnector", reflect.TypeOf((*MockService)(nil).ForwardBankAccountToConnector), ctx, id, req) -} - -// ListConnectors mocks base method. -func (m *MockService) ListConnectors(ctx context.Context) ([]*models.Connector, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListConnectors", ctx) - ret0, _ := ret[0].([]*models.Connector) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListConnectors indicates an expected call of ListConnectors. -func (mr *MockServiceMockRecorder) ListConnectors(ctx interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListConnectors", reflect.TypeOf((*MockService)(nil).ListConnectors), ctx) -} - -// Ping mocks base method. -func (m *MockService) Ping() error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Ping") - ret0, _ := ret[0].(error) - return ret0 -} - -// Ping indicates an expected call of Ping. -func (mr *MockServiceMockRecorder) Ping() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockService)(nil).Ping)) -} - -// RetryTransferInitiation mocks base method. -func (m *MockService) RetryTransferInitiation(ctx context.Context, id string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RetryTransferInitiation", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// RetryTransferInitiation indicates an expected call of RetryTransferInitiation. -func (mr *MockServiceMockRecorder) RetryTransferInitiation(ctx, id interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryTransferInitiation", reflect.TypeOf((*MockService)(nil).RetryTransferInitiation), ctx, id) -} - -// ReverseTransferInitiation mocks base method. -func (m *MockService) ReverseTransferInitiation(ctx context.Context, transferID string, req *service.ReverseTransferInitiationRequest) (*models.TransferReversal, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReverseTransferInitiation", ctx, transferID, req) - ret0, _ := ret[0].(*models.TransferReversal) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ReverseTransferInitiation indicates an expected call of ReverseTransferInitiation. -func (mr *MockServiceMockRecorder) ReverseTransferInitiation(ctx, transferID, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReverseTransferInitiation", reflect.TypeOf((*MockService)(nil).ReverseTransferInitiation), ctx, transferID, req) -} - -// UpdateBankAccountMetadata mocks base method. -func (m *MockService) UpdateBankAccountMetadata(ctx context.Context, id string, req *service.UpdateBankAccountMetadataRequest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateBankAccountMetadata", ctx, id, req) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateBankAccountMetadata indicates an expected call of UpdateBankAccountMetadata. -func (mr *MockServiceMockRecorder) UpdateBankAccountMetadata(ctx, id, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateBankAccountMetadata", reflect.TypeOf((*MockService)(nil).UpdateBankAccountMetadata), ctx, id, req) -} - -// UpdateTransferInitiationStatus mocks base method. -func (m *MockService) UpdateTransferInitiationStatus(ctx context.Context, transferID string, req *service.UpdateTransferInitiationStatusRequest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTransferInitiationStatus", ctx, transferID, req) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateTransferInitiationStatus indicates an expected call of UpdateTransferInitiationStatus. -func (mr *MockServiceMockRecorder) UpdateTransferInitiationStatus(ctx, transferID, req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTransferInitiationStatus", reflect.TypeOf((*MockService)(nil).UpdateTransferInitiationStatus), ctx, transferID, req) -} - -// MockManager is a mock of Manager interface. -type MockManager[ConnectorConfig models.ConnectorConfigObject] struct { - ctrl *gomock.Controller - recorder *MockManagerMockRecorder[ConnectorConfig] -} - -// MockManagerMockRecorder is the mock recorder for MockManager. -type MockManagerMockRecorder[ConnectorConfig models.ConnectorConfigObject] struct { - mock *MockManager[ConnectorConfig] -} - -// NewMockManager creates a new mock instance. -func NewMockManager[ConnectorConfig models.ConnectorConfigObject](ctrl *gomock.Controller) *MockManager[ConnectorConfig] { - mock := &MockManager[ConnectorConfig]{ctrl: ctrl} - mock.recorder = &MockManagerMockRecorder[ConnectorConfig]{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockManager[ConnectorConfig]) EXPECT() *MockManagerMockRecorder[ConnectorConfig] { - return m.recorder -} - -// Connectors mocks base method. -func (m *MockManager[ConnectorConfig]) Connectors() map[string]*connectors_manager.ConnectorManager { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Connectors") - ret0, _ := ret[0].(map[string]*connectors_manager.ConnectorManager) - return ret0 -} - -// Connectors indicates an expected call of Connectors. -func (mr *MockManagerMockRecorder[ConnectorConfig]) Connectors() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connectors", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).Connectors)) -} - -// CreateWebhookAndContext mocks base method. -func (m *MockManager[ConnectorConfig]) CreateWebhookAndContext(ctx context.Context, webhook *models.Webhook) (context.Context, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateWebhookAndContext", ctx, webhook) - ret0, _ := ret[0].(context.Context) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CreateWebhookAndContext indicates an expected call of CreateWebhookAndContext. -func (mr *MockManagerMockRecorder[ConnectorConfig]) CreateWebhookAndContext(ctx, webhook interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWebhookAndContext", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).CreateWebhookAndContext), ctx, webhook) -} - -// Install mocks base method. -func (m *MockManager[ConnectorConfig]) Install(ctx context.Context, name string, config ConnectorConfig) (models.ConnectorID, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Install", ctx, name, config) - ret0, _ := ret[0].(models.ConnectorID) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Install indicates an expected call of Install. -func (mr *MockManagerMockRecorder[ConnectorConfig]) Install(ctx, name, config interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Install", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).Install), ctx, name, config) -} - -// IsInstalled mocks base method. -func (m *MockManager[ConnectorConfig]) IsInstalled(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IsInstalled", ctx, connectorID) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// IsInstalled indicates an expected call of IsInstalled. -func (mr *MockManagerMockRecorder[ConnectorConfig]) IsInstalled(ctx, connectorID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsInstalled", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).IsInstalled), ctx, connectorID) -} - -// ListTasksStates mocks base method. -func (m *MockManager[ConnectorConfig]) ListTasksStates(ctx context.Context, connectorID models.ConnectorID, q storage.ListTasksQuery) (*api.Cursor[models.Task], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListTasksStates", ctx, connectorID, q) - ret0, _ := ret[0].(*api.Cursor[models.Task]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListTasksStates indicates an expected call of ListTasksStates. -func (mr *MockManagerMockRecorder[ConnectorConfig]) ListTasksStates(ctx, connectorID, q interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTasksStates", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).ListTasksStates), ctx, connectorID, q) -} - -// ReadConfig mocks base method. -func (m *MockManager[ConnectorConfig]) ReadConfig(ctx context.Context, connectorID models.ConnectorID) (ConnectorConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReadConfig", ctx, connectorID) - ret0, _ := ret[0].(ConnectorConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ReadConfig indicates an expected call of ReadConfig. -func (mr *MockManagerMockRecorder[ConnectorConfig]) ReadConfig(ctx, connectorID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadConfig", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).ReadConfig), ctx, connectorID) -} - -// ReadTaskState mocks base method. -func (m *MockManager[ConnectorConfig]) ReadTaskState(ctx context.Context, connectorID models.ConnectorID, taskID uuid.UUID) (*models.Task, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReadTaskState", ctx, connectorID, taskID) - ret0, _ := ret[0].(*models.Task) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ReadTaskState indicates an expected call of ReadTaskState. -func (mr *MockManagerMockRecorder[ConnectorConfig]) ReadTaskState(ctx, connectorID, taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadTaskState", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).ReadTaskState), ctx, connectorID, taskID) -} - -// Reset mocks base method. -func (m *MockManager[ConnectorConfig]) Reset(ctx context.Context, connectorID models.ConnectorID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Reset", ctx, connectorID) - ret0, _ := ret[0].(error) - return ret0 -} - -// Reset indicates an expected call of Reset. -func (mr *MockManagerMockRecorder[ConnectorConfig]) Reset(ctx, connectorID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reset", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).Reset), ctx, connectorID) -} - -// Uninstall mocks base method. -func (m *MockManager[ConnectorConfig]) Uninstall(ctx context.Context, connectorID models.ConnectorID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Uninstall", ctx, connectorID) - ret0, _ := ret[0].(error) - return ret0 -} - -// Uninstall indicates an expected call of Uninstall. -func (mr *MockManagerMockRecorder[ConnectorConfig]) Uninstall(ctx, connectorID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Uninstall", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).Uninstall), ctx, connectorID) -} - -// UpdateConfig mocks base method. -func (m *MockManager[ConnectorConfig]) UpdateConfig(ctx context.Context, connectorID models.ConnectorID, config ConnectorConfig) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateConfig", ctx, connectorID, config) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateConfig indicates an expected call of UpdateConfig. -func (mr *MockManagerMockRecorder[ConnectorConfig]) UpdateConfig(ctx, connectorID, config interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateConfig", reflect.TypeOf((*MockManager[ConnectorConfig])(nil).UpdateConfig), ctx, connectorID, config) -} - -// MockServiceBackend is a mock of ServiceBackend interface. -type MockServiceBackend struct { - ctrl *gomock.Controller - recorder *MockServiceBackendMockRecorder -} - -// MockServiceBackendMockRecorder is the mock recorder for MockServiceBackend. -type MockServiceBackendMockRecorder struct { - mock *MockServiceBackend -} - -// NewMockServiceBackend creates a new mock instance. -func NewMockServiceBackend(ctrl *gomock.Controller) *MockServiceBackend { - mock := &MockServiceBackend{ctrl: ctrl} - mock.recorder = &MockServiceBackendMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockServiceBackend) EXPECT() *MockServiceBackendMockRecorder { - return m.recorder -} - -// GetService mocks base method. -func (m *MockServiceBackend) GetService() Service { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetService") - ret0, _ := ret[0].(Service) - return ret0 -} - -// GetService indicates an expected call of GetService. -func (mr *MockServiceBackendMockRecorder) GetService() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetService", reflect.TypeOf((*MockServiceBackend)(nil).GetService)) -} - -// MockManagerBackend is a mock of ManagerBackend interface. -type MockManagerBackend[ConnectorConfig models.ConnectorConfigObject] struct { - ctrl *gomock.Controller - recorder *MockManagerBackendMockRecorder[ConnectorConfig] -} - -// MockManagerBackendMockRecorder is the mock recorder for MockManagerBackend. -type MockManagerBackendMockRecorder[ConnectorConfig models.ConnectorConfigObject] struct { - mock *MockManagerBackend[ConnectorConfig] -} - -// NewMockManagerBackend creates a new mock instance. -func NewMockManagerBackend[ConnectorConfig models.ConnectorConfigObject](ctrl *gomock.Controller) *MockManagerBackend[ConnectorConfig] { - mock := &MockManagerBackend[ConnectorConfig]{ctrl: ctrl} - mock.recorder = &MockManagerBackendMockRecorder[ConnectorConfig]{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockManagerBackend[ConnectorConfig]) EXPECT() *MockManagerBackendMockRecorder[ConnectorConfig] { - return m.recorder -} - -// GetManager mocks base method. -func (m *MockManagerBackend[ConnectorConfig]) GetManager() Manager[ConnectorConfig] { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetManager") - ret0, _ := ret[0].(Manager[ConnectorConfig]) - return ret0 -} - -// GetManager indicates an expected call of GetManager. -func (mr *MockManagerBackendMockRecorder[ConnectorConfig]) GetManager() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetManager", reflect.TypeOf((*MockManagerBackend[ConnectorConfig])(nil).GetManager)) -} diff --git a/cmd/connectors/internal/api/bank_account.go b/cmd/connectors/internal/api/bank_account.go deleted file mode 100644 index 5091454d..00000000 --- a/cmd/connectors/internal/api/bank_account.go +++ /dev/null @@ -1,240 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -type bankAccountRelatedAccountsResponse struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - AccountID string `json:"accountID"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` -} - -type bankAccountResponse struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt time.Time `json:"createdAt"` - Country string `json:"country"` - Iban string `json:"iban,omitempty"` - AccountNumber string `json:"accountNumber,omitempty"` - SwiftBicCode string `json:"swiftBicCode,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - RelatedAccounts []*bankAccountRelatedAccountsResponse `json:"relatedAccounts,omitempty"` - - // Deprecated fields, but clients still use them - // They correspond to the first adjustment now. - Provider string `json:"provider,omitempty"` - ConnectorID string `json:"connectorID"` - AccountID string `json:"accountID,omitempty"` -} - -func createBankAccountHandler( - b backend.ServiceBackend, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "createBankAccountHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - var bankAccountRequest service.CreateBankAccountRequest - err := json.NewDecoder(r.Body).Decode(&bankAccountRequest) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - setAttributesFromRequest(span, &bankAccountRequest) - - if err := bankAccountRequest.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - bankAccount, err := b.GetService().CreateBankAccount(ctx, &bankAccountRequest) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - span.SetAttributes(attribute.String("bankAccount.id", bankAccount.ID.String())) - span.SetAttributes(attribute.String("bankAccount.createdAt", bankAccount.ID.String())) - - data := &bankAccountResponse{ - ID: bankAccount.ID.String(), - Name: bankAccount.Name, - CreatedAt: bankAccount.CreatedAt, - Country: bankAccount.Country, - Metadata: bankAccount.Metadata, - } - - for _, relatedAccount := range bankAccount.RelatedAccounts { - data.RelatedAccounts = append(data.RelatedAccounts, &bankAccountRelatedAccountsResponse{ - ID: relatedAccount.ID.String(), - CreatedAt: relatedAccount.CreatedAt, - AccountID: relatedAccount.AccountID.String(), - ConnectorID: relatedAccount.ConnectorID.String(), - Provider: relatedAccount.ConnectorID.Provider.String(), - }) - } - - // Keep compatibility with previous api version - data.ConnectorID = bankAccountRequest.ConnectorID - if len(bankAccount.RelatedAccounts) > 0 { - data.AccountID = bankAccount.RelatedAccounts[0].AccountID.String() - data.Provider = bankAccount.RelatedAccounts[0].ConnectorID.Provider.String() - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[bankAccountResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func forwardBankAccountToConnector( - b backend.ServiceBackend, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "forwardBankAccountToConnector") - defer span.End() - - payload := &service.ForwardBankAccountToConnectorRequest{} - err := json.NewDecoder(r.Body).Decode(payload) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - span.SetAttributes(attribute.String("request.connectorID", payload.ConnectorID)) - - if err := payload.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - bankAccountID, ok := mux.Vars(r)["bankAccountID"] - if !ok { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("bankAccount.id", bankAccountID)) - - bankAccount, err := b.GetService().ForwardBankAccountToConnector(ctx, bankAccountID, payload) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - data := &bankAccountResponse{ - ID: bankAccount.ID.String(), - Name: bankAccount.Name, - CreatedAt: bankAccount.CreatedAt, - Country: bankAccount.Country, - Metadata: bankAccount.Metadata, - } - - for _, relatedAccount := range bankAccount.RelatedAccounts { - data.RelatedAccounts = append(data.RelatedAccounts, &bankAccountRelatedAccountsResponse{ - ID: relatedAccount.ID.String(), - CreatedAt: relatedAccount.CreatedAt, - AccountID: relatedAccount.AccountID.String(), - ConnectorID: relatedAccount.ConnectorID.String(), - Provider: relatedAccount.ConnectorID.Provider.String(), - }) - } - - // Keep compatibility with previous api version - data.ConnectorID = payload.ConnectorID - if len(bankAccount.RelatedAccounts) > 0 { - data.AccountID = bankAccount.RelatedAccounts[0].AccountID.String() - data.Provider = bankAccount.RelatedAccounts[0].ConnectorID.Provider.String() - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[bankAccountResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func updateBankAccountMetadataHandler( - b backend.ServiceBackend, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "updateBankAccountMetadataHandler") - defer span.End() - - payload := &service.UpdateBankAccountMetadataRequest{} - err := json.NewDecoder(r.Body).Decode(payload) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - for k, v := range payload.Metadata { - span.SetAttributes(attribute.String(k, v)) - } - - if err := payload.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - bankAccountID, ok := mux.Vars(r)["bankAccountID"] - if !ok { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("bankAccount.id", bankAccountID)) - - err = b.GetService().UpdateBankAccountMetadata(ctx, bankAccountID, payload) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - api.NoContent(w) - } -} - -func setAttributesFromRequest(span trace.Span, request *service.CreateBankAccountRequest) { - span.SetAttributes( - attribute.String("request.name", request.Name), - attribute.String("request.country", request.Country), - attribute.String("request.connectorID", request.ConnectorID), - ) -} diff --git a/cmd/connectors/internal/api/bank_account_test.go b/cmd/connectors/internal/api/bank_account_test.go deleted file mode 100644 index 68189ac2..00000000 --- a/cmd/connectors/internal/api/bank_account_test.go +++ /dev/null @@ -1,626 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestCreateBankAccounts(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.CreateBankAccountRequest - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - acc1 := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - testCases := []testCase{ - { - name: "nominal", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - }, - { - name: "nominal without connectorID", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - Name: "test_nominal", - }, - }, - { - name: "no body", - req: nil, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "missing AccountNumber and Iban", - req: &service.CreateBankAccountRequest{ - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing name", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing country", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - - { - name: "service error duplicate key", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - serviceError: service.ErrInvalidID, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - serviceError: service.ErrPublish, - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - req: &service.CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorID.String(), - Name: "test_nominal", - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - bankAccountID := uuid.New() - createBankAccountResponse := models.BankAccount{ - ID: bankAccountID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Name: "test_nominal", - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - } - - if testCase.req != nil && testCase.req.ConnectorID != "" { - createBankAccountResponse.RelatedAccounts = []*models.BankAccountRelatedAccount{ - { - ID: uuid.New(), - BankAccountID: bankAccountID, - ConnectorID: connectorID, - AccountID: acc1, - }, - } - } - - expectedCreateBankAccountResponse := &bankAccountResponse{ - ID: createBankAccountResponse.ID.String(), - Name: createBankAccountResponse.Name, - CreatedAt: createBankAccountResponse.CreatedAt, - Country: createBankAccountResponse.Country, - } - - if testCase.req != nil && testCase.req.ConnectorID != "" { - expectedCreateBankAccountResponse.ConnectorID = createBankAccountResponse.RelatedAccounts[0].ConnectorID.String() - expectedCreateBankAccountResponse.AccountID = createBankAccountResponse.RelatedAccounts[0].AccountID.String() - expectedCreateBankAccountResponse.Provider = createBankAccountResponse.RelatedAccounts[0].ConnectorID.Provider.String() - expectedCreateBankAccountResponse.RelatedAccounts = []*bankAccountRelatedAccountsResponse{ - { - ID: createBankAccountResponse.RelatedAccounts[0].ID.String(), - AccountID: createBankAccountResponse.RelatedAccounts[0].AccountID.String(), - ConnectorID: createBankAccountResponse.RelatedAccounts[0].ConnectorID.String(), - Provider: createBankAccountResponse.RelatedAccounts[0].ConnectorID.Provider.String(), - }, - } - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - CreateBankAccount(gomock.Any(), testCase.req). - Return(&createBankAccountResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - CreateBankAccount(gomock.Any(), testCase.req). - Return(nil, testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), nil, false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, "/bank-accounts", bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[bankAccountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedCreateBankAccountResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestForwardBankAccountToConnector(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.ForwardBankAccountToConnectorRequest - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - acc1 := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - testCases := []testCase{ - { - name: "nominal", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - }, - { - name: "nominal without connectorID", - req: &service.ForwardBankAccountToConnectorRequest{}, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "no body", - req: nil, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "service error duplicate key", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - serviceError: service.ErrInvalidID, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - serviceError: service.ErrPublish, - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - req: &service.ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorID.String(), - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - bankAccountID := uuid.New() - forwardBankAccountResponse := models.BankAccount{ - ID: bankAccountID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Name: "test_nominal", - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - } - - if testCase.req != nil && testCase.req.ConnectorID != "" { - forwardBankAccountResponse.RelatedAccounts = []*models.BankAccountRelatedAccount{ - { - ID: uuid.New(), - BankAccountID: bankAccountID, - ConnectorID: connectorID, - AccountID: acc1, - }, - } - } - - expectedForwardBankAccountResponse := &bankAccountResponse{ - ID: forwardBankAccountResponse.ID.String(), - Name: forwardBankAccountResponse.Name, - CreatedAt: forwardBankAccountResponse.CreatedAt, - Country: forwardBankAccountResponse.Country, - } - - if testCase.req != nil && testCase.req.ConnectorID != "" { - expectedForwardBankAccountResponse.ConnectorID = forwardBankAccountResponse.RelatedAccounts[0].ConnectorID.String() - expectedForwardBankAccountResponse.AccountID = forwardBankAccountResponse.RelatedAccounts[0].AccountID.String() - expectedForwardBankAccountResponse.Provider = forwardBankAccountResponse.RelatedAccounts[0].ConnectorID.Provider.String() - expectedForwardBankAccountResponse.RelatedAccounts = []*bankAccountRelatedAccountsResponse{ - { - ID: forwardBankAccountResponse.RelatedAccounts[0].ID.String(), - AccountID: forwardBankAccountResponse.RelatedAccounts[0].AccountID.String(), - ConnectorID: forwardBankAccountResponse.RelatedAccounts[0].ConnectorID.String(), - Provider: forwardBankAccountResponse.RelatedAccounts[0].ConnectorID.Provider.String(), - }, - } - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ForwardBankAccountToConnector(gomock.Any(), bankAccountID.String(), testCase.req). - Return(&forwardBankAccountResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ForwardBankAccountToConnector(gomock.Any(), bankAccountID.String(), testCase.req). - Return(nil, testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), nil, false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/bank-accounts/%s/forward", bankAccountID), bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[bankAccountResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedForwardBankAccountResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestUpdateBankAccountMetadata(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.UpdateBankAccountMetadataRequest - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nominal", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - }, - { - name: "empty metadata", - req: &service.UpdateBankAccountMetadataRequest{}, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "no body", - req: nil, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "service error duplicate key", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: service.ErrInvalidID, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: service.ErrPublish, - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - req: &service.UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - bankAccountID := uuid.New() - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - UpdateBankAccountMetadata(gomock.Any(), bankAccountID.String(), testCase.req). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - UpdateBankAccountMetadata(gomock.Any(), bankAccountID.String(), testCase.req). - Return(testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{ - Debug: testing.Verbose(), - }, auth.NewNoAuth(), nil, false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/bank-accounts/%s/metadata", bankAccountID), bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/connectors/internal/api/connector.go b/cmd/connectors/internal/api/connector.go deleted file mode 100644 index e027cb35..00000000 --- a/cmd/connectors/internal/api/connector.go +++ /dev/null @@ -1,483 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/pointer" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "github.com/gorilla/mux" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -type APIVersion int - -const ( - V0 APIVersion = iota - V1 APIVersion = iota -) - -func (a APIVersion) String() string { - switch a { - case V0: - return "v0" - case V1: - return "v1" - default: - return "unknown" - } -} - -func updateConfig[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "updateConfig") - defer span.End() - - span.SetAttributes(attribute.String("apiVersion", apiVersion.String())) - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - var config Config - if r.ContentLength > 0 { - err := json.NewDecoder(r.Body).Decode(&config) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - } - - err = b.GetManager().UpdateConfig(ctx, connectorID, config) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - api.NoContent(w) - } -} - -func readConfig[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readConfig") - defer span.End() - - span.SetAttributes(attribute.String("apiVersion", apiVersion.String())) - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("connectorID", connectorID.String())) - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - config, err := b.GetManager().ReadConfig(ctx, connectorID) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[Config]{ - Data: &config, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -type listTasksResponseElement struct { - ID string `json:"id"` - ConnectorID string `json:"connectorID"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Descriptor json.RawMessage `json:"descriptor"` - Status models.TaskStatus `json:"status"` - State json.RawMessage `json:"state"` - Error string `json:"error"` -} - -func listTasks[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "listTasks") - defer span.End() - - span.SetAttributes(attribute.String("apiVersion", apiVersion.String())) - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - query, err := bunpaginate.Extract[storage.ListTasksQuery](r, func() (*storage.ListTasksQuery, error) { - pageSize, err := bunpaginate.GetPageSize(r) - if err != nil { - return nil, err - } - - return pointer.For(storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(pageSize))), nil - }) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - span.SetAttributes(attribute.Int("pageSize", int(query.PageSize))) - span.SetAttributes(attribute.String("cursor", r.URL.Query().Get("cursor"))) - - cursor, err := b.GetManager().ListTasksStates(ctx, connectorID, *query) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - tasks := cursor.Data - data := make([]listTasksResponseElement, len(tasks)) - for i, task := range tasks { - data[i] = listTasksResponseElement{ - ID: task.ID.String(), - ConnectorID: task.ConnectorID.String(), - CreatedAt: task.CreatedAt.Format(time.RFC3339), - UpdatedAt: task.UpdatedAt.Format(time.RFC3339), - Descriptor: task.Descriptor, - Status: task.Status, - State: task.State, - Error: task.Error, - } - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[listTasksResponseElement]{ - Cursor: &bunpaginate.Cursor[listTasksResponseElement]{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - Data: data, - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func webhooksMiddleware[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) func(handler http.Handler) http.Handler { - return func(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "webhooksMiddleware") - defer span.End() - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - } - defer r.Body.Close() - - webhook := &models.Webhook{ - ID: uuid.New(), - ConnectorID: connectorID, - RequestBody: body, - } - - span.SetAttributes(attribute.String("webhook.id", webhook.ID.String())) - - ctx, err = b.GetManager().CreateWebhookAndContext(ctx, webhook) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - handler.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -func readTask[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readTask") - defer span.End() - - span.SetAttributes(attribute.String("apiVersion", apiVersion.String())) - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - taskID, err := uuid.Parse(mux.Vars(r)["taskID"]) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - span.SetAttributes(attribute.String("taskID", taskID.String())) - - task, err := b.GetManager().ReadTaskState(ctx, connectorID, taskID) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - data := listTasksResponseElement{ - ID: task.ID.String(), - ConnectorID: task.ConnectorID.String(), - CreatedAt: task.CreatedAt.Format(time.RFC3339), - UpdatedAt: task.UpdatedAt.Format(time.RFC3339), - Descriptor: task.Descriptor, - Status: task.Status, - State: task.State, - Error: task.Error, - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[listTasksResponseElement]{ - Data: &data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func uninstall[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "uninstall") - defer span.End() - - span.SetAttributes(attribute.String("apiVersion", apiVersion.String())) - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - err = b.GetManager().Uninstall(ctx, connectorID) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -type installResponse struct { - ConnectorID string `json:"connectorID"` -} - -func install[Config models.ConnectorConfigObject](b backend.ManagerBackend[Config]) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "install") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - var config Config - if r.ContentLength > 0 { - err := json.NewDecoder(r.Body).Decode(&config) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - } - - connectorID, err := b.GetManager().Install(ctx, config.ConnectorName(), config) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusCreated) - err = json.NewEncoder(w).Encode(api.BaseResponse[installResponse]{ - Data: &installResponse{ - ConnectorID: connectorID.String(), - }, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func reset[Config models.ConnectorConfigObject]( - b backend.ManagerBackend[Config], - apiVersion APIVersion, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "reset") - defer span.End() - - span.SetAttributes(attribute.String("apiVersion", apiVersion.String())) - - connectorID, err := getConnectorID(span, b, r, apiVersion) - if err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrInvalidID, err) - return - } - - if connectorNotInstalled(span, b, connectorID, w, r) { - return - } - - err = b.GetManager().Reset(ctx, connectorID) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -func connectorNotInstalled[Config models.ConnectorConfigObject]( - span trace.Span, - b backend.ManagerBackend[Config], - connectorID models.ConnectorID, - w http.ResponseWriter, r *http.Request, -) bool { - installed, err := b.GetManager().IsInstalled(r.Context(), connectorID) - if err != nil { - otel.RecordError(span, err) - handleConnectorsManagerErrors(w, r, err) - return true - } - - if !installed { - otel.RecordError(span, fmt.Errorf("connector not installed")) - api.BadRequest(w, ErrValidation, fmt.Errorf("connector not installed")) - return true - } - - return false -} - -func getConnectorID[Config models.ConnectorConfigObject]( - span trace.Span, - b backend.ManagerBackend[Config], - r *http.Request, - apiVersion APIVersion, -) (models.ConnectorID, error) { - switch apiVersion { - case V0: - connectors := b.GetManager().Connectors() - if len(connectors) == 0 { - return models.ConnectorID{}, fmt.Errorf("no connectors installed") - } - - span.SetAttributes(attribute.Int("connectors.count", len(connectors))) - - if len(connectors) > 1 { - return models.ConnectorID{}, fmt.Errorf("more than one connectors installed") - } - - for id := range connectors { - return models.MustConnectorIDFromString(id), nil - } - - case V1: - c := mux.Vars(r)["connectorID"] - - span.SetAttributes(attribute.String("connectorID", c)) - - connectorID, err := models.ConnectorIDFromString(c) - if err != nil { - return models.ConnectorID{}, err - } - - return connectorID, nil - } - - return models.ConnectorID{}, fmt.Errorf("unknown API version") -} diff --git a/cmd/connectors/internal/api/connector_test.go b/cmd/connectors/internal/api/connector_test.go deleted file mode 100644 index c7ad3620..00000000 --- a/cmd/connectors/internal/api/connector_test.go +++ /dev/null @@ -1,1356 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/dummypay" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestReadConfig(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - connectorID string - connectors map[string]*manager.ConnectorManager - installed *bool - apiVersion APIVersion - expectedStatusCode int - expectedErrorCode string - managerError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - testCases := []testCase{ - // V0 tests - { - name: "nominal V0", - apiVersion: V0, - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - }, - installed: ptr(true), - }, - { - name: "too many connectors for V0", - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - "1": nil, - }, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "no connector for V0", - connectors: map[string]*manager.ConnectorManager{}, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - // V1 tests - { - name: "nominal V1", - connectorID: connectorID.String(), - apiVersion: V1, - installed: ptr(true), - }, - // Common test for V0 and V1 - { - name: "connector not installed", - connectorID: connectorID.String(), - apiVersion: V1, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(false), - }, - { - name: "manager error duplicate key value storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - installed: ptr(true), - }, - { - name: "manager error err not found storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error already installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrAlreadyInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error not installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error connector not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrConnectorNotFound, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error err not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error err validation", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error other errors", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - installed: ptr(true), - }, - } - - for _, tc := range testCases { - testCase := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - readConfigResponse := dummypay.Config{ - Name: "test", - Directory: "test", - FilePollingPeriod: connectors.Duration{ - Duration: 2 * time.Minute, - }, - } - - backend, _ := newServiceTestingBackend(t) - managerBackend, mockManager := newConnectorManagerTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockManager.EXPECT(). - ReadConfig(gomock.Any(), connectorID). - Return(readConfigResponse, nil) - } - - if testCase.managerError != nil { - mockManager.EXPECT(). - ReadConfig(gomock.Any(), connectorID). - Return(dummypay.Config{}, testCase.managerError) - } - - if testCase.apiVersion == V0 { - mockManager.EXPECT(). - Connectors(). - Return(testCase.connectors) - } - - if testCase.installed != nil { - mockManager.EXPECT(). - IsInstalled(gomock.Any(), connectorID). - Return(*testCase.installed, nil) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), []connectorHandler{ - { - Handler: connectorRouter[dummypay.Config](models.ConnectorProviderDummyPay, managerBackend), - Provider: models.ConnectorProviderDummyPay, - initiatePayment: func(ctx context.Context, transfer *models.TransferInitiation) error { - return nil - }, - }, - }, false) - - var endpoint string - switch testCase.apiVersion { - case V0: - endpoint = "/connectors/dummy-pay/config" - case V1: - endpoint = fmt.Sprintf("/connectors/dummy-pay/%s/config", testCase.connectorID) - } - req := httptest.NewRequest(http.MethodGet, endpoint, nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[dummypay.Config] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, &readConfigResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestListTasks(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - connectorID string - connectors map[string]*manager.ConnectorManager - installed *bool - apiVersion APIVersion - queryParams url.Values - pageSize int - expectedQuery storage.ListTasksQuery - expectedStatusCode int - expectedErrorCode string - managerError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - testCases := []testCase{ - // V0 tests - { - name: "nominal V0", - apiVersion: V0, - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - }, - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - connectorID: connectorID.String(), - installed: ptr(true), - queryParams: map[string][]string{}, - pageSize: 15, - }, - { - name: "too many connectors for V0", - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - "1": nil, - }, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "no connector for V0", - connectors: map[string]*manager.ConnectorManager{}, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - // V1 tests - { - name: "nominal V1", - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - connectorID: connectorID.String(), - installed: ptr(true), - apiVersion: V1, - pageSize: 15, - }, - // Common test for V0 and V1 - { - name: "page size too low", - queryParams: url.Values{ - "pageSize": {"0"}, - }, - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - pageSize: 15, - connectorID: connectorID.String(), - installed: ptr(true), - apiVersion: V1, - }, - { - name: "page size too high", - queryParams: url.Values{ - "pageSize": {"100000"}, - }, - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(100)), - pageSize: 100, - connectorID: connectorID.String(), - installed: ptr(true), - apiVersion: V1, - }, - { - name: "with invalid page size", - queryParams: url.Values{ - "pageSize": {"nan"}, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - connectorID: connectorID.String(), - installed: ptr(true), - apiVersion: V1, - }, - { - name: "connector not installed", - connectorID: connectorID.String(), - apiVersion: V1, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(false), - }, - { - name: "manager error duplicate key value storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error err not found storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error already installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrAlreadyInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error not installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error connector not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrConnectorNotFound, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error err not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error err validation", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - { - name: "manager error other errors", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - installed: ptr(true), - expectedQuery: storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}).WithPageSize(15)), - }, - } - - for _, tc := range testCases { - testCase := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - tasks := []models.Task{ - { - ID: uuid.New(), - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Name: "test", - Descriptor: []byte("{}"), - Status: models.TaskStatusActive, - State: json.RawMessage("{}"), - }, - } - listTasksResponse := &bunpaginate.Cursor[models.Task]{ - PageSize: testCase.pageSize, - HasMore: false, - Previous: "", - Next: "", - Data: tasks, - } - - expectedListTasksResponse := []listTasksResponseElement{ - { - ID: tasks[0].ID.String(), - ConnectorID: tasks[0].ConnectorID.String(), - CreatedAt: tasks[0].CreatedAt.Format(time.RFC3339), - UpdatedAt: tasks[0].UpdatedAt.Format(time.RFC3339), - Descriptor: tasks[0].Descriptor, - Status: tasks[0].Status, - State: tasks[0].State, - Error: tasks[0].Error, - }, - } - - backend, _ := newServiceTestingBackend(t) - managerBackend, mockManager := newConnectorManagerTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockManager.EXPECT(). - ListTasksStates(gomock.Any(), connectorID, testCase.expectedQuery). - Return(listTasksResponse, nil) - } - - if testCase.managerError != nil { - mockManager.EXPECT(). - ListTasksStates(gomock.Any(), connectorID, testCase.expectedQuery). - Return(nil, testCase.managerError) - } - - if testCase.apiVersion == V0 { - mockManager.EXPECT(). - Connectors(). - Return(testCase.connectors) - } - - if testCase.installed != nil { - mockManager.EXPECT(). - IsInstalled(gomock.Any(), connectorID). - Return(*testCase.installed, nil) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), []connectorHandler{ - { - Handler: connectorRouter[dummypay.Config](models.ConnectorProviderDummyPay, managerBackend), - Provider: models.ConnectorProviderDummyPay, - initiatePayment: func(ctx context.Context, transfer *models.TransferInitiation) error { - return nil - }, - }, - }, false) - - var endpoint string - switch testCase.apiVersion { - case V0: - endpoint = "/connectors/dummy-pay/tasks" - case V1: - endpoint = fmt.Sprintf("/connectors/dummy-pay/%s/tasks", testCase.connectorID) - } - req := httptest.NewRequest(http.MethodGet, endpoint, nil) - rec := httptest.NewRecorder() - req.URL.RawQuery = testCase.queryParams.Encode() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[listTasksResponseElement] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedListTasksResponse, resp.Cursor.Data) - require.Equal(t, listTasksResponse.PageSize, resp.Cursor.PageSize) - require.Equal(t, listTasksResponse.HasMore, resp.Cursor.HasMore) - require.Equal(t, listTasksResponse.Next, resp.Cursor.Next) - require.Equal(t, listTasksResponse.Previous, resp.Cursor.Previous) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestReadTask(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - connectorID string - taskID string - connectors map[string]*manager.ConnectorManager - installed *bool - apiVersion APIVersion - expectedStatusCode int - expectedErrorCode string - managerError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - taskID := uuid.New() - - testCases := []testCase{ - // V0 tests - { - name: "nominal V0", - apiVersion: V0, - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - }, - taskID: taskID.String(), - installed: ptr(true), - }, - { - name: "too many connectors for V0", - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - "1": nil, - }, - apiVersion: V0, - taskID: taskID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "no connector for V0", - connectors: map[string]*manager.ConnectorManager{}, - apiVersion: V0, - taskID: taskID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - // V1 tests - { - name: "nominal V1", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - installed: ptr(true), - }, - // Common test for V0 and V1 - { - name: "connector not installed", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(false), - }, - { - name: "manager error duplicate key value storage", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - installed: ptr(true), - }, - { - name: "manager error err not found storage", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error already installed", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: manager.ErrAlreadyInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error not installed", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: manager.ErrNotInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error connector not found", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: manager.ErrConnectorNotFound, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error err not found", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: manager.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error err validation", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: manager.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error other errors", - connectorID: connectorID.String(), - taskID: taskID.String(), - apiVersion: V1, - managerError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - installed: ptr(true), - }, - { - name: "invalid task ID", - apiVersion: V1, - connectorID: connectorID.String(), - taskID: "invalid", - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - installed: ptr(true), - }, - } - - for _, tc := range testCases { - testCase := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - readTaskResponse := &models.Task{ - ID: uuid.New(), - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Name: "test", - Descriptor: []byte("{}"), - Status: models.TaskStatusActive, - State: json.RawMessage("{}"), - } - - expectedReadTasksResponse := listTasksResponseElement{ - ID: readTaskResponse.ID.String(), - ConnectorID: readTaskResponse.ConnectorID.String(), - CreatedAt: readTaskResponse.CreatedAt.Format(time.RFC3339), - UpdatedAt: readTaskResponse.UpdatedAt.Format(time.RFC3339), - Descriptor: readTaskResponse.Descriptor, - Status: readTaskResponse.Status, - State: readTaskResponse.State, - Error: readTaskResponse.Error, - } - - backend, _ := newServiceTestingBackend(t) - managerBackend, mockManager := newConnectorManagerTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockManager.EXPECT(). - ReadTaskState(gomock.Any(), connectorID, taskID). - Return(readTaskResponse, nil) - } - - if testCase.managerError != nil { - mockManager.EXPECT(). - ReadTaskState(gomock.Any(), connectorID, taskID). - Return(nil, testCase.managerError) - } - - if testCase.apiVersion == V0 { - mockManager.EXPECT(). - Connectors(). - Return(testCase.connectors) - } - - if testCase.installed != nil { - mockManager.EXPECT(). - IsInstalled(gomock.Any(), connectorID). - Return(*testCase.installed, nil) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), []connectorHandler{ - { - Handler: connectorRouter[dummypay.Config](models.ConnectorProviderDummyPay, managerBackend), - Provider: models.ConnectorProviderDummyPay, - initiatePayment: func(ctx context.Context, transfer *models.TransferInitiation) error { - return nil - }, - }, - }, false) - - var endpoint string - switch testCase.apiVersion { - case V0: - endpoint = fmt.Sprintf("/connectors/dummy-pay/tasks/%s", testCase.taskID) - case V1: - endpoint = fmt.Sprintf("/connectors/dummy-pay/%s/tasks/%s", testCase.connectorID, testCase.taskID) - } - req := httptest.NewRequest(http.MethodGet, endpoint, nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[listTasksResponseElement] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, &expectedReadTasksResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestUninstall(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - connectorID string - connectors map[string]*manager.ConnectorManager - installed *bool - apiVersion APIVersion - expectedStatusCode int - expectedErrorCode string - managerError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - testCases := []testCase{ - // V0 tests - { - name: "nominal V0", - apiVersion: V0, - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - }, - installed: ptr(true), - }, - { - name: "too many connectors for V0", - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - "1": nil, - }, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "no connector for V0", - connectors: map[string]*manager.ConnectorManager{}, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - // V1 tests - { - name: "nominal V1", - connectorID: connectorID.String(), - apiVersion: V1, - installed: ptr(true), - }, - // Common test for V0 and V1 - { - name: "connector not installed", - connectorID: connectorID.String(), - apiVersion: V1, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(false), - }, - { - name: "manager error duplicate key value storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - installed: ptr(true), - }, - { - name: "manager error err not found storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error already installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrAlreadyInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error not installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error connector not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrConnectorNotFound, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error err not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error err validation", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error other errors", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - installed: ptr(true), - }, - } - - for _, tc := range testCases { - testCase := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, _ := newServiceTestingBackend(t) - managerBackend, mockManager := newConnectorManagerTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockManager.EXPECT(). - Uninstall(gomock.Any(), connectorID). - Return(nil) - } - - if testCase.managerError != nil { - mockManager.EXPECT(). - Uninstall(gomock.Any(), connectorID). - Return(testCase.managerError) - } - - if testCase.apiVersion == V0 { - mockManager.EXPECT(). - Connectors(). - Return(testCase.connectors) - } - - if testCase.installed != nil { - mockManager.EXPECT(). - IsInstalled(gomock.Any(), connectorID). - Return(*testCase.installed, nil) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), []connectorHandler{ - { - Handler: connectorRouter[dummypay.Config](models.ConnectorProviderDummyPay, managerBackend), - Provider: models.ConnectorProviderDummyPay, - initiatePayment: func(ctx context.Context, transfer *models.TransferInitiation) error { - return nil - }, - }, - }, false) - - var endpoint string - switch testCase.apiVersion { - case V0: - endpoint = "/connectors/dummy-pay" - case V1: - endpoint = fmt.Sprintf("/connectors/dummy-pay/%s", testCase.connectorID) - } - req := httptest.NewRequest(http.MethodDelete, endpoint, nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestInstall(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - body []byte - expectedStatusCode int - expectedErrorCode string - managerError error - } - - dummypayConfig := dummypay.Config{ - Name: "test", - Directory: "test", - FilePollingPeriod: connectors.Duration{ - Duration: 2 * time.Minute, - }, - } - - body, err := json.Marshal(dummypayConfig) - require.NoError(t, err) - - testCases := []testCase{ - { - name: "nominal", - body: body, - }, - { - name: "invalid body", - body: []byte("invalid"), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "manager error duplicate key value storage", - body: body, - managerError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "manager error err not found storage", - body: body, - managerError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "manager error already installed", - body: body, - managerError: manager.ErrAlreadyInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "manager error not installed", - body: body, - managerError: manager.ErrNotInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "manager error connector not found", - body: body, - managerError: manager.ErrConnectorNotFound, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "manager error err not found", - body: body, - managerError: manager.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "manager error err validation", - body: body, - managerError: manager.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "manager error other errors", - body: body, - managerError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, tc := range testCases { - testCase := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusCreated - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - expectedResponse := installResponse{ - ConnectorID: connectorID.String(), - } - - backend, _ := newServiceTestingBackend(t) - managerBackend, mockManager := newConnectorManagerTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockManager.EXPECT(). - Install(gomock.Any(), dummypayConfig.Name, dummypayConfig). - Return(connectorID, nil) - } - - if testCase.managerError != nil { - mockManager.EXPECT(). - Install(gomock.Any(), dummypayConfig.Name, dummypayConfig). - Return(models.ConnectorID{}, testCase.managerError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), []connectorHandler{ - { - Handler: connectorRouter[dummypay.Config](models.ConnectorProviderDummyPay, managerBackend), - Provider: models.ConnectorProviderDummyPay, - initiatePayment: func(ctx context.Context, transfer *models.TransferInitiation) error { - return nil - }, - }, - }, false) - - req := httptest.NewRequest(http.MethodPost, "/connectors/dummy-pay", bytes.NewReader(testCase.body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } else { - var resp sharedapi.BaseResponse[installResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, &expectedResponse, resp.Data) - } - - }) - } -} - -func TestReset(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - connectorID string - connectors map[string]*manager.ConnectorManager - installed *bool - apiVersion APIVersion - expectedStatusCode int - expectedErrorCode string - managerError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - testCases := []testCase{ - // V0 tests - { - name: "nominal V0", - apiVersion: V0, - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - }, - installed: ptr(true), - }, - { - name: "too many connectors for V0", - connectors: map[string]*manager.ConnectorManager{ - connectorID.String(): nil, - "1": nil, - }, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "no connector for V0", - connectors: map[string]*manager.ConnectorManager{}, - apiVersion: V0, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - // V1 tests - { - name: "nominal V1", - connectorID: connectorID.String(), - apiVersion: V1, - installed: ptr(true), - }, - // Common test for V0 and V1 - { - name: "connector not installed", - connectorID: connectorID.String(), - apiVersion: V1, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(false), - }, - { - name: "manager error duplicate key value storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - installed: ptr(true), - }, - { - name: "manager error err not found storage", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error already installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrAlreadyInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error not installed", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotInstalled, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error connector not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrConnectorNotFound, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error err not found", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - installed: ptr(true), - }, - { - name: "manager error err validation", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: manager.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - installed: ptr(true), - }, - { - name: "manager error other errors", - connectorID: connectorID.String(), - apiVersion: V1, - managerError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - installed: ptr(true), - }, - } - - for _, tc := range testCases { - testCase := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, _ := newServiceTestingBackend(t) - managerBackend, mockManager := newConnectorManagerTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockManager.EXPECT(). - Reset(gomock.Any(), connectorID). - Return(nil) - } - - if testCase.managerError != nil { - mockManager.EXPECT(). - Reset(gomock.Any(), connectorID). - Return(testCase.managerError) - } - - if testCase.apiVersion == V0 { - mockManager.EXPECT(). - Connectors(). - Return(testCase.connectors) - } - - if testCase.installed != nil { - mockManager.EXPECT(). - IsInstalled(gomock.Any(), connectorID). - Return(*testCase.installed, nil) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), []connectorHandler{ - { - Handler: connectorRouter[dummypay.Config](models.ConnectorProviderDummyPay, managerBackend), - Provider: models.ConnectorProviderDummyPay, - initiatePayment: func(ctx context.Context, transfer *models.TransferInitiation) error { - return nil - }, - }, - }, false) - - var endpoint string - switch testCase.apiVersion { - case V0: - endpoint = "/connectors/dummy-pay/reset" - case V1: - endpoint = fmt.Sprintf("/connectors/dummy-pay/%s/reset", testCase.connectorID) - } - req := httptest.NewRequest(http.MethodPost, endpoint, nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/connectors/internal/api/connectorconfigs.go b/cmd/connectors/internal/api/connectorconfigs.go deleted file mode 100644 index 6088b97f..00000000 --- a/cmd/connectors/internal/api/connectorconfigs.go +++ /dev/null @@ -1,49 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/adyen" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/dummypay" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise" -) - -func connectorConfigsHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // TODO: It's not ideal to re-identify available connectors - // Refactor it when refactoring the HTTP lib. - - configs := configtemplate.BuildConfigs( - atlar.Config{}, - adyen.Config{}, - bankingcircle.Config{}, - currencycloud.Config{}, - dummypay.Config{}, - modulr.Config{}, - stripe.Config{}, - wise.Config{}, - mangopay.Config{}, - moneycorp.Config{}, - generic.Config{}, - ) - - err := json.NewEncoder(w).Encode(api.BaseResponse[configtemplate.Configs]{ - Data: &configs, - }) - if err != nil { - api.InternalServerError(w, r, err) - return - } - } -} diff --git a/cmd/connectors/internal/api/connectormodule.go b/cmd/connectors/internal/api/connectormodule.go deleted file mode 100644 index 945226e0..00000000 --- a/cmd/connectors/internal/api/connectormodule.go +++ /dev/null @@ -1,105 +0,0 @@ -package api - -import ( - "context" - "net/http" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "github.com/pkg/errors" - "go.uber.org/dig" - "go.uber.org/fx" -) - -type connectorHandler struct { - Handler http.Handler - WebhookHandler http.Handler - Provider models.ConnectorProvider - - // TODO(polo): refactor to remove this ugly hack to access the connector manager - initiatePayment service.InitiatePaymentHandler - reversePayment service.ReversePaymentHandler - createExternalBankAccount service.BankAccountHandler -} - -func addConnector[ConnectorConfig models.ConnectorConfigObject](loader manager.Loader[ConnectorConfig], -) fx.Option { - return fx.Options( - fx.Provide(func(store *storage.Storage, - publisher message.Publisher, - metricsRegistry metrics.MetricsRegistry, - messages *messages.Messages, - ) *manager.ConnectorsManager[ConnectorConfig] { - schedulerFactory := manager.TaskSchedulerFactoryFn(func( - connectorID models.ConnectorID, resolver task.Resolver, maxTasks int, - ) *task.DefaultTaskScheduler { - return task.NewDefaultScheduler(connectorID, store, func(ctx context.Context, - descriptor models.TaskDescriptor, - taskID uuid.UUID, - ) (*dig.Container, error) { - container := dig.New() - - if err := container.Provide(func() ingestion.Ingester { - return ingestion.NewDefaultIngester(loader.Name(), connectorID, descriptor, store, publisher, messages) - }); err != nil { - return nil, err - } - - if err := container.Provide(func() storage.Reader { - return store - }); err != nil { - return nil, err - } - - return container, nil - }, resolver, metricsRegistry, maxTasks) - }) - - return manager.NewConnectorManager( - loader.Name(), store, loader, schedulerFactory, publisher, messages) - }), - fx.Provide(func(cm *manager.ConnectorsManager[ConnectorConfig]) backend.ManagerBackend[ConnectorConfig] { - return backend.NewDefaultManagerBackend[ConnectorConfig](cm) - }), - fx.Provide(fx.Annotate(func( - store *storage.Storage, - b backend.ManagerBackend[ConnectorConfig], - cm *manager.ConnectorsManager[ConnectorConfig], - ) connectorHandler { - return connectorHandler{ - Handler: connectorRouter(loader.Name(), b), - WebhookHandler: webhookConnectorRouter(loader.Name(), loader.Router(store), b), - Provider: loader.Name(), - initiatePayment: cm.InitiatePayment, - reversePayment: cm.ReversePayment, - createExternalBankAccount: cm.CreateExternalBankAccount, - } - }, fx.ResultTags(`group:"connectorHandlers"`))), - fx.Invoke(func(lc fx.Lifecycle, cm *manager.ConnectorsManager[ConnectorConfig]) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - ctx, span := otel.Tracer().Start(ctx, "connectorsManager.Restore") - defer span.End() - - err := cm.Restore(ctx) - if err != nil && !errors.Is(err, manager.ErrNotInstalled) { - return err - } - - return nil - }, - OnStop: cm.Close, - }) - }), - ) -} diff --git a/cmd/connectors/internal/api/connectors_manager/connector_test.go b/cmd/connectors/internal/api/connectors_manager/connector_test.go deleted file mode 100644 index e8aac712..00000000 --- a/cmd/connectors/internal/api/connectors_manager/connector_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package connectors_manager - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -type ConnectorBuilder struct { - name string - uninstall func(ctx context.Context) error - resolve func(descriptor models.TaskDescriptor) task.Task - install func(ctx task.ConnectorContext) error - initiatePayment func(ctx task.ConnectorContext, transfer *models.TransferInitiation) error - createExternalBankAccount func(ctx task.ConnectorContext, account *models.BankAccount) error -} - -func (b *ConnectorBuilder) WithUninstall( - uninstallFunction func(ctx context.Context) error, -) *ConnectorBuilder { - b.uninstall = uninstallFunction - - return b -} - -func (b *ConnectorBuilder) WithResolve(resolveFunction func(name models.TaskDescriptor) task.Task) *ConnectorBuilder { - b.resolve = resolveFunction - - return b -} - -func (b *ConnectorBuilder) WithInstall(installFunction func(ctx task.ConnectorContext) error) *ConnectorBuilder { - b.install = installFunction - - return b -} - -func (b *ConnectorBuilder) Build() connectors.Connector { - return &BuiltConnector{ - name: b.name, - uninstall: b.uninstall, - resolve: b.resolve, - install: b.install, - initiatePayment: b.initiatePayment, - createExternalBankAccount: b.createExternalBankAccount, - } -} - -func NewConnectorBuilder() *ConnectorBuilder { - return &ConnectorBuilder{} -} - -type BuiltConnector struct { - name string - uninstall func(ctx context.Context) error - resolve func(name models.TaskDescriptor) task.Task - updateConfig func(ctx task.ConnectorContext, config models.ConnectorConfigObject) error - install func(ctx task.ConnectorContext) error - initiatePayment func(ctx task.ConnectorContext, transfer *models.TransferInitiation) error - reversePayment func(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error - createExternalBankAccount func(ctx task.ConnectorContext, account *models.BankAccount) error -} - -func (b *BuiltConnector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - if b.updateConfig != nil { - return b.updateConfig(ctx, config) - } - - return nil -} - -func (b *BuiltConnector) SupportedCurrenciesAndDecimals() map[string]int { - return map[string]int{} -} - -func (b *BuiltConnector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - if b.initiatePayment != nil { - return b.initiatePayment(ctx, transfer) - } - - return nil -} - -func (b *BuiltConnector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - if b.reversePayment != nil { - return b.reversePayment(ctx, transferReversal) - } - - return nil -} - -func (b *BuiltConnector) CreateExternalBankAccount(ctx task.ConnectorContext, account *models.BankAccount) error { - if b.createExternalBankAccount != nil { - return b.createExternalBankAccount(ctx, account) - } - - return nil -} - -func (b *BuiltConnector) HandleWebhook(ctx task.ConnectorContext, webhook *models.Webhook) error { - return nil -} - -func (b *BuiltConnector) Name() string { - return b.name -} - -func (b *BuiltConnector) Install(ctx task.ConnectorContext) error { - if b.install != nil { - return b.install(ctx) - } - - return nil -} - -func (b *BuiltConnector) Uninstall(ctx context.Context) error { - if b.uninstall != nil { - return b.uninstall(ctx) - } - - return nil -} - -func (b *BuiltConnector) Resolve(name models.TaskDescriptor) task.Task { - if b.resolve != nil { - return b.resolve(name) - } - - return nil -} - -var _ connectors.Connector = &BuiltConnector{} diff --git a/cmd/connectors/internal/api/connectors_manager/errors.go b/cmd/connectors/internal/api/connectors_manager/errors.go deleted file mode 100644 index 81263ec8..00000000 --- a/cmd/connectors/internal/api/connectors_manager/errors.go +++ /dev/null @@ -1,44 +0,0 @@ -package connectors_manager - -import ( - "errors" - "fmt" -) - -var ( - ErrNotFound = errors.New("not found") - ErrAlreadyInstalled = errors.New("already installed") - ErrNotInstalled = errors.New("not installed") - ErrNotEnabled = errors.New("not enabled") - ErrAlreadyRunning = errors.New("already running") - ErrConnectorNotFound = errors.New("connector not found") - ErrValidation = errors.New("validation error") -) - -type storageError struct { - err error - msg string -} - -func (e *storageError) Error() string { - return fmt.Sprintf("%s: %s", e.msg, e.err) -} - -func (e *storageError) Is(err error) bool { - _, ok := err.(*storageError) - return ok -} - -func (e *storageError) Unwrap() error { - return e.err -} - -func newStorageError(err error, msg string) error { - if err == nil { - return nil - } - return &storageError{ - err: err, - msg: msg, - } -} diff --git a/cmd/connectors/internal/api/connectors_manager/loader.go b/cmd/connectors/internal/api/connectors_manager/loader.go deleted file mode 100644 index 38ffcee4..00000000 --- a/cmd/connectors/internal/api/connectors_manager/loader.go +++ /dev/null @@ -1,107 +0,0 @@ -package connectors_manager - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader[ConnectorConfig models.ConnectorConfigObject] interface { - Name() models.ConnectorProvider - Load(logger logging.Logger, config ConnectorConfig) connectors.Connector - - // ApplyDefaults is used to fill default values of the provided configuration object - ApplyDefaults(t ConnectorConfig) ConnectorConfig - - // Extra routes to be added to the connectors manager API - Router(store *storage.Storage) *mux.Router - - // AllowTasks define how many task the connector can run - // If too many tasks are scheduled by the connector, - // those will be set to pending state and restarted later when some other tasks will be terminated - AllowTasks() int -} - -type LoaderBuilder[ConnectorConfig models.ConnectorConfigObject] struct { - loadFunction func(logger logging.Logger, config ConnectorConfig) connectors.Connector - applyDefaults func(t ConnectorConfig) ConnectorConfig - name models.ConnectorProvider - allowedTasks int -} - -func (b *LoaderBuilder[ConnectorConfig]) WithLoad(loadFunction func(logger logging.Logger, - config ConnectorConfig) connectors.Connector, -) *LoaderBuilder[ConnectorConfig] { - b.loadFunction = loadFunction - - return b -} - -func (b *LoaderBuilder[ConnectorConfig]) WithApplyDefaults( - applyDefaults func(t ConnectorConfig) ConnectorConfig, -) *LoaderBuilder[ConnectorConfig] { - b.applyDefaults = applyDefaults - - return b -} - -func (b *LoaderBuilder[ConnectorConfig]) WithAllowedTasks(v int) *LoaderBuilder[ConnectorConfig] { - b.allowedTasks = v - - return b -} - -func (b *LoaderBuilder[ConnectorConfig]) Build() *BuiltLoader[ConnectorConfig] { - return &BuiltLoader[ConnectorConfig]{ - loadFunction: b.loadFunction, - applyDefaults: b.applyDefaults, - name: b.name, - allowedTasks: b.allowedTasks, - } -} - -func NewLoaderBuilder[ConnectorConfig models.ConnectorConfigObject](name models.ConnectorProvider, -) *LoaderBuilder[ConnectorConfig] { - return &LoaderBuilder[ConnectorConfig]{ - name: name, - } -} - -type BuiltLoader[ConnectorConfig models.ConnectorConfigObject] struct { - loadFunction func(logger logging.Logger, config ConnectorConfig) connectors.Connector - applyDefaults func(t ConnectorConfig) ConnectorConfig - name models.ConnectorProvider - allowedTasks int -} - -func (b *BuiltLoader[ConnectorConfig]) AllowTasks() int { - return b.allowedTasks -} - -func (b *BuiltLoader[ConnectorConfig]) Name() models.ConnectorProvider { - return b.name -} - -func (b *BuiltLoader[ConnectorConfig]) Load(logger logging.Logger, config ConnectorConfig) connectors.Connector { - if b.loadFunction != nil { - return b.loadFunction(logger, config) - } - - return nil -} - -func (b *BuiltLoader[ConnectorConfig]) ApplyDefaults(t ConnectorConfig) ConnectorConfig { - if b.applyDefaults != nil { - return b.applyDefaults(t) - } - - return t -} - -func (b *BuiltLoader[ConnectorConfig]) Router(store *storage.Storage) *mux.Router { - return nil -} - -var _ Loader[models.EmptyConnectorConfig] = &BuiltLoader[models.EmptyConnectorConfig]{} diff --git a/cmd/connectors/internal/api/connectors_manager/manager.go b/cmd/connectors/internal/api/connectors_manager/manager.go deleted file mode 100644 index f28e06c6..00000000 --- a/cmd/connectors/internal/api/connectors_manager/manager.go +++ /dev/null @@ -1,565 +0,0 @@ -package connectors_manager - -import ( - "context" - "fmt" - "sync" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -type ConnectorManager struct { - connector connectors.Connector - scheduler *task.DefaultTaskScheduler -} - -type ConnectorsManager[Config models.ConnectorConfigObject] struct { - provider models.ConnectorProvider - loader Loader[Config] - store Store - schedulerFactory TaskSchedulerFactory - publisher message.Publisher - messages *messages.Messages - - connectors map[string]*ConnectorManager - mu sync.RWMutex -} - -func (l *ConnectorsManager[ConnectorConfig]) logger(ctx context.Context) logging.Logger { - return logging.FromContext(ctx).WithFields(map[string]interface{}{ - "component": "connector-manager", - "provider": l.loader.Name(), - }) -} - -func (l *ConnectorsManager[ConnectorConfig]) getManager(connectorID models.ConnectorID) (*ConnectorManager, error) { - l.mu.RLock() - defer l.mu.RUnlock() - - connector, ok := l.connectors[connectorID.String()] - if !ok { - return nil, ErrNotInstalled - } - - return connector, nil -} - -func (l *ConnectorsManager[ConnectorConfig]) Connectors() map[string]*ConnectorManager { - l.mu.RLock() - defer l.mu.RUnlock() - - copy := make(map[string]*ConnectorManager, len(l.connectors)) - for k, v := range l.connectors { - copy[k] = v - } - - return copy -} - -func (l *ConnectorsManager[ConnectorConfig]) ReadConfig( - ctx context.Context, - connectorID models.ConnectorID, -) (ConnectorConfig, error) { - var config ConnectorConfig - connector, err := l.store.GetConnector(ctx, connectorID) - if err != nil { - return config, newStorageError(err, "getting connector") - } - - return l.readConfig(ctx, connector) -} - -func (l *ConnectorsManager[ConnectorConfig]) readConfig( - ctx context.Context, - connector *models.Connector, -) (ConnectorConfig, error) { - var config ConnectorConfig - if connector == nil { - var err error - connector, err = l.store.GetConnector(ctx, connector.ID) - if err != nil { - return config, newStorageError(err, "getting connector") - } - } - - err := connector.ParseConfig(&config) - if err != nil { - return config, err - } - - config = l.loader.ApplyDefaults(config) - - return config, nil -} - -func (l *ConnectorsManager[ConnectorConfig]) UpdateConfig( - ctx context.Context, - connectorID models.ConnectorID, - config ConnectorConfig, -) error { - l.logger(ctx).Infof("Updating config of connector: %s", connectorID) - - connectorManager, err := l.getManager(connectorID) - if err != nil { - l.logger(ctx).Errorf("Connector not installed") - return err - } - - config = l.loader.ApplyDefaults(config) - if err = config.Validate(); err != nil { - return err - } - - cfg, err := config.Marshal() - if err != nil { - return err - } - - if err := l.store.UpdateConfig(ctx, connectorID, cfg); err != nil { - return newStorageError(err, "updating connector config") - } - - // Detach the context since we're launching an async task and we're mostly - // coming from a HTTP request. - detachedCtx, span := detachedCtxWithSpan(ctx, trace.SpanFromContext(ctx), "connectorManager.UpdateConfig", connectorID) - defer span.End() - if err := connectorManager.connector.UpdateConfig(task.NewConnectorContext(logging.ContextWithLogger( - detachedCtx, - logging.FromContext(ctx), - ), connectorManager.scheduler), config); err != nil { - switch { - case errors.Is(err, connectors.ErrInvalidConfig): - return errors.Wrap(ErrValidation, err.Error()) - default: - return err - } - } - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) load( - ctx context.Context, - connectorID models.ConnectorID, - connectorConfig ConnectorConfig, -) error { - c := l.loader.Load(l.logger(ctx), connectorConfig) - scheduler := l.schedulerFactory.Make(connectorID, c, l.loader.AllowTasks()) - - l.mu.Lock() - l.connectors[connectorID.String()] = &ConnectorManager{ - connector: c, - scheduler: scheduler, - } - l.mu.Unlock() - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) Install( - ctx context.Context, - name string, - config ConnectorConfig, -) (models.ConnectorID, error) { - l.logger(ctx).WithFields(map[string]interface{}{ - "config": config, - }).Infof("Install connector %s", name) - - isInstalled, err := l.store.IsInstalledByConnectorName(ctx, name) - if err != nil { - return models.ConnectorID{}, newStorageError(err, "checking if connector is installed") - } - - if isInstalled { - l.logger(ctx).Errorf("Connector already installed") - return models.ConnectorID{}, ErrAlreadyInstalled - } - - config = l.loader.ApplyDefaults(config) - - if err = config.Validate(); err != nil { - return models.ConnectorID{}, err - } - - cfg, err := config.Marshal() - if err != nil { - return models.ConnectorID{}, err - } - - connector := &models.Connector{ - ID: models.ConnectorID{ - Provider: l.provider, - Reference: uuid.New(), - }, - Name: name, - Provider: l.provider, - } - - err = l.store.Install(ctx, connector, cfg) - if err != nil { - return models.ConnectorID{}, newStorageError(err, "installing connector") - } - - if err := l.load(ctx, connector.ID, config); err != nil { - return models.ConnectorID{}, err - } - - connectorManager, err := l.getManager(connector.ID) - if err != nil { - return models.ConnectorID{}, err - } - - // Detach the context since we're launching an async task and we're mostly - // coming from a HTTP request. - detachedCtx, span := detachedCtxWithSpan(ctx, trace.SpanFromContext(ctx), "connectorManager.Install", connector.ID) - defer span.End() - err = connectorManager.connector.Install(task.NewConnectorContext(logging.ContextWithLogger( - detachedCtx, - logging.FromContext(ctx), - ), connectorManager.scheduler)) - if err != nil { - l.logger(ctx).Errorf("Error starting connector: %s", err) - - return models.ConnectorID{}, err - } - - l.logger(ctx).Infof("Connector installed") - - return connector.ID, nil -} - -func (l *ConnectorsManager[ConnectorConfig]) Uninstall(ctx context.Context, connectorID models.ConnectorID) error { - l.logger(ctx).Infof("Uninstalling connector: %s", connectorID) - - connectorManager, err := l.getManager(connectorID) - if err != nil { - l.logger(ctx).Errorf("Connector not installed") - return err - } - - err = connectorManager.scheduler.Shutdown(ctx) - if err != nil { - return err - } - - err = connectorManager.connector.Uninstall(ctx) - if err != nil { - return err - } - - err = l.store.Uninstall(ctx, connectorID) - if err != nil { - return newStorageError(err, "uninstalling connector") - } - - if l.publisher != nil { - err = l.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, l.messages.NewEventResetConnector(connectorID))) - if err != nil { - l.logger(ctx).Errorf("Publishing message: %w", err) - } - } - - l.mu.Lock() - delete(l.connectors, connectorID.String()) - l.mu.Unlock() - - l.logger(ctx).Infof("Connector %s uninstalled", connectorID) - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) Restore(ctx context.Context) error { - l.logger(ctx).Info("Restoring state for all connectors") - - connectors, err := l.store.ListConnectors(ctx) - if err != nil { - return newStorageError(err, "listing connectors") - } - - for _, connector := range connectors { - if connector.Provider != l.provider { - continue - } - - if err := l.restore(ctx, connector); err != nil { - l.logger(ctx).Errorf("Unable to restore connector %s: %s", connector.Name, err) - return err - } - } - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) restore(ctx context.Context, connector *models.Connector) error { - l.logger(ctx).Infof("Restoring state for connector: %s", connector.Name) - - if manager, _ := l.getManager(connector.ID); manager != nil { - return ErrAlreadyRunning - } - - connectorConfig, err := l.readConfig(ctx, connector) - if err != nil { - return err - } - - if err := l.load(ctx, connector.ID, connectorConfig); err != nil { - return err - } - - manager, err := l.getManager(connector.ID) - if err != nil { - return err - } - - if err := manager.scheduler.Restore(ctx); err != nil { - return err - } - - l.logger(ctx).Infof("State restored for connector: %s", connector.Name) - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) FindAll(ctx context.Context) ([]*models.Connector, error) { - connectors, err := l.store.ListConnectors(ctx) - if err != nil { - return nil, newStorageError(err, "listing connectors") - } - - providerConnectors := make([]*models.Connector, 0, len(connectors)) - for _, connector := range connectors { - if connector.Provider == l.provider { - providerConnectors = append(providerConnectors, connector) - } - } - - return providerConnectors, nil -} - -func (l *ConnectorsManager[ConnectorConfig]) IsInstalled(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - isInstalled, err := l.store.IsInstalledByConnectorID(ctx, connectorID) - return isInstalled, newStorageError(err, "checking if connector is installed") -} - -func (l *ConnectorsManager[ConnectorConfig]) ListTasksStates( - ctx context.Context, - connectorID models.ConnectorID, - q storage.ListTasksQuery, -) (*bunpaginate.Cursor[models.Task], error) { - connectorManager, err := l.getManager(connectorID) - if err != nil { - return nil, ErrConnectorNotFound - } - - return connectorManager.scheduler.ListTasks(ctx, q) -} - -func (l *ConnectorsManager[Config]) ReadTaskState(ctx context.Context, connectorID models.ConnectorID, taskID uuid.UUID) (*models.Task, error) { - connectorManager, err := l.getManager(connectorID) - if err != nil { - return nil, ErrConnectorNotFound - } - - return connectorManager.scheduler.ReadTask(ctx, taskID) -} - -func (l *ConnectorsManager[ConnectorConfig]) Reset(ctx context.Context, connectorID models.ConnectorID) error { - connector, err := l.store.GetConnector(ctx, connectorID) - if err != nil { - return newStorageError(err, "getting connector") - } - - config, err := l.readConfig(ctx, connector) - if err != nil { - return err - } - - err = l.Uninstall(ctx, connectorID) - if err != nil { - return err - } - - _, err = l.Install(ctx, connector.Name, config) - if err != nil { - return err - } - - err = l.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, l.messages.NewEventResetConnector(connectorID))) - if err != nil { - l.logger(ctx).Errorf("Publishing message: %w", err) - } - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) InitiatePayment(ctx context.Context, transfer *models.TransferInitiation) error { - connectorManager, err := l.getManager(transfer.ConnectorID) - if err != nil { - return ErrConnectorNotFound - } - - if err := l.validateAssets(ctx, connectorManager, transfer.ConnectorID, transfer.Asset); err != nil { - return err - } - - detachedCtx, span := detachedCtxWithSpan(ctx, trace.SpanFromContext(ctx), "connectorManager.InitiatePayment", transfer.ConnectorID) - defer span.End() - err = connectorManager.connector.InitiatePayment(task.NewConnectorContext(detachedCtx, connectorManager.scheduler), transfer) - if err != nil { - return fmt.Errorf("initiating transfer: %w", err) - } - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) ReversePayment(ctx context.Context, transferReversal *models.TransferReversal) error { - connectorManager, err := l.getManager(transferReversal.ConnectorID) - if err != nil { - return ErrConnectorNotFound - } - - if err := l.validateAssets(ctx, connectorManager, transferReversal.ConnectorID, transferReversal.Asset); err != nil { - return err - } - - err = connectorManager.connector.ReversePayment(task.NewConnectorContext(ctx, connectorManager.scheduler), transferReversal) - if err != nil { - return fmt.Errorf("reversing transfer: %w", err) - } - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) CreateExternalBankAccount(ctx context.Context, connectorID models.ConnectorID, bankAccount *models.BankAccount) error { - connectorManager, err := l.getManager(connectorID) - if err != nil { - return ErrConnectorNotFound - } - - detachedCtx, span := detachedCtxWithSpan(ctx, trace.SpanFromContext(ctx), "connectorManager.CreateExternalBankAccount", connectorID) - defer span.End() - err = connectorManager.connector.CreateExternalBankAccount(task.NewConnectorContext(detachedCtx, connectorManager.scheduler), bankAccount) - if err != nil { - switch { - case errors.Is(err, connectors.ErrNotImplemented): - return errors.Wrap(ErrValidation, "bank account creation not implemented for this connector") - default: - return fmt.Errorf("creating bank account: %w", err) - } - } - - return nil -} - -func (l *ConnectorsManager[ConnectorConfig]) CreateWebhookAndContext( - ctx context.Context, - webhook *models.Webhook, -) (context.Context, error) { - connectorManager, err := l.getManager(webhook.ConnectorID) - if err != nil { - return nil, ErrConnectorNotFound - } - - if err := l.store.CreateWebhook(ctx, webhook); err != nil { - return nil, newStorageError(err, "creating webhook") - } - - connectorContext := task.NewConnectorContext(ctx, connectorManager.scheduler) - ctx = task.ContextWithConnectorContext(connectors.ContextWithWebhookID(ctx, webhook.ID), connectorContext) - - return ctx, nil -} - -func (l *ConnectorsManager[ConnectorConfig]) validateAssets( - ctx context.Context, - connectorManager *ConnectorManager, - connectorID models.ConnectorID, - asset models.Asset, -) error { - supportedCurrencies := connectorManager.connector.SupportedCurrenciesAndDecimals() - currency, precision, err := models.GetCurrencyAndPrecisionFromAsset(asset) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - supportedPrecision, ok := supportedCurrencies[currency] - if !ok { - return errors.Wrap(ErrValidation, fmt.Sprintf("currency %s not supported", currency)) - } - - if precision != int64(supportedPrecision) { - return errors.Wrap(ErrValidation, fmt.Sprintf("currency %s has precision %d, but %d is required", currency, precision, supportedPrecision)) - } - - return nil -} - -func detachedCtxWithSpan( - ctx context.Context, - parentSpan trace.Span, - spanName string, - connectorID models.ConnectorID, -) (context.Context, trace.Span) { - detachedCtx, _ := contextutil.Detached(ctx) - - ctx, span := otel.Tracer().Start( - detachedCtx, - spanName, - trace.WithLinks(trace.Link{ - SpanContext: parentSpan.SpanContext(), - }), - trace.WithAttributes( - attribute.String("connectorID", connectorID.String()), - ), - ) - - return ctx, span -} - -func (l *ConnectorsManager[ConnectorConfig]) Close(ctx context.Context) error { - for _, connectorManager := range l.connectors { - err := connectorManager.scheduler.Shutdown(ctx) - if err != nil { - return err - } - } - - return nil -} - -func NewConnectorManager[ConnectorConfig models.ConnectorConfigObject]( - provider models.ConnectorProvider, - store Store, - loader Loader[ConnectorConfig], - schedulerFactory TaskSchedulerFactory, - publisher message.Publisher, - messages *messages.Messages, -) *ConnectorsManager[ConnectorConfig] { - return &ConnectorsManager[ConnectorConfig]{ - provider: provider, - connectors: make(map[string]*ConnectorManager), - store: store, - loader: loader, - schedulerFactory: schedulerFactory, - publisher: publisher, - messages: messages, - } -} diff --git a/cmd/connectors/internal/api/connectors_manager/manager_test.go b/cmd/connectors/internal/api/connectors_manager/manager_test.go deleted file mode 100644 index 6a7f5eaa..00000000 --- a/cmd/connectors/internal/api/connectors_manager/manager_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package connectors_manager - -import ( - "context" - "testing" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" - "go.uber.org/dig" -) - -func ChanClosed[T any](ch chan T) bool { - select { - case <-ch: - return true - default: - return false - } -} - -type testContext[ConnectorConfig models.ConnectorConfigObject] struct { - manager *ConnectorsManager[ConnectorConfig] - taskStore task.Repository - connectorStore Store - loader Loader[ConnectorConfig] - provider models.ConnectorProvider -} - -func withManager[ConnectorConfig models.ConnectorConfigObject](builder *ConnectorBuilder, - callback func(ctx *testContext[ConnectorConfig]), -) { - l := logrus.New() - if testing.Verbose() { - l.SetLevel(logrus.DebugLevel) - } - - DefaultContainerFactory := task.ContainerCreateFunc(func(ctx context.Context, descriptor models.TaskDescriptor, taskID uuid.UUID) (*dig.Container, error) { - return dig.New(), nil - }) - - taskStore := task.NewInMemoryStore() - managerStore := NewInMemoryStore() - provider := models.ConnectorProvider(uuid.New().String()) - schedulerFactory := TaskSchedulerFactoryFn(func( - connectorID models.ConnectorID, - resolver task.Resolver, - maxTasks int, - ) *task.DefaultTaskScheduler { - return task.NewDefaultScheduler(connectorID, taskStore, - DefaultContainerFactory, resolver, metrics.NewNoOpMetricsRegistry(), maxTasks) - }) - - loader := NewLoaderBuilder[ConnectorConfig](provider). - WithLoad(func(logger logging.Logger, config ConnectorConfig) connectors.Connector { - return builder.Build() - }). - WithAllowedTasks(1). - Build() - manager := NewConnectorManager[ConnectorConfig](provider, managerStore, loader, schedulerFactory, nil, messages.NewMessages("")) - - callback(&testContext[ConnectorConfig]{ - manager: manager, - taskStore: taskStore, - connectorStore: managerStore, - loader: loader, - provider: provider, - }) -} - -func TestInstallConnector(t *testing.T) { - t.Parallel() - - installed := make(chan struct{}) - builder := NewConnectorBuilder(). - WithInstall(func(ctx task.ConnectorContext) error { - close(installed) - - return nil - }) - withManager(builder, func(tc *testContext[models.EmptyConnectorConfig]) { - _, err := tc.manager.Install(context.TODO(), "test1", models.EmptyConnectorConfig{ - Name: "test1", - }) - require.NoError(t, err) - require.True(t, ChanClosed(installed)) - - _, err = tc.manager.Install(context.TODO(), "test1", models.EmptyConnectorConfig{ - Name: "test1", - }) - require.Equal(t, ErrAlreadyInstalled, err) - - connectors, err := tc.manager.FindAll(context.TODO()) - require.NoError(t, err) - require.Len(t, connectors, 1) - require.Equal(t, "test1", connectors[0].Name) - - isInstalled, err := tc.manager.IsInstalled(context.TODO(), connectors[0].ID) - require.NoError(t, err) - require.True(t, isInstalled) - - err = tc.manager.Uninstall(context.TODO(), connectors[0].ID) - require.NoError(t, err) - - isInstalled, err = tc.manager.IsInstalled(context.TODO(), connectors[0].ID) - require.NoError(t, err) - require.False(t, isInstalled) - }) -} - -func TestUninstallConnector(t *testing.T) { - t.Parallel() - - uninstalled := make(chan struct{}) - taskTerminated := make(chan struct{}) - taskStarted := make(chan struct{}) - builder := NewConnectorBuilder(). - WithResolve(func(name models.TaskDescriptor) task.Task { - return func(ctx context.Context, stopChan task.StopChan) { - close(taskStarted) - defer close(taskTerminated) - select { - case flag := <-stopChan: - flag <- struct{}{} - case <-ctx.Done(): - } - } - }). - WithInstall(func(ctx task.ConnectorContext) error { - return ctx.Scheduler().Schedule(ctx.Context(), []byte(uuid.New().String()), models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - }) - }). - WithUninstall(func(ctx context.Context) error { - close(uninstalled) - - return nil - }) - withManager(builder, func(tc *testContext[models.EmptyConnectorConfig]) { - _, err := tc.manager.Install(context.TODO(), "test1", models.EmptyConnectorConfig{ - Name: "test1", - }) - require.NoError(t, err) - <-taskStarted - - connectors, err := tc.manager.FindAll(context.TODO()) - require.NoError(t, err) - require.Len(t, connectors, 1) - require.Equal(t, "test1", connectors[0].Name) - - require.NoError(t, tc.manager.Uninstall(context.TODO(), connectors[0].ID)) - require.True(t, ChanClosed(uninstalled)) - // TODO: We need to give a chance to the connector to properly stop execution - require.True(t, ChanClosed(taskTerminated)) - - isInstalled, err := tc.manager.IsInstalled(context.TODO(), connectors[0].ID) - require.NoError(t, err) - require.False(t, isInstalled) - }) -} - -func TestRestoreConnector(t *testing.T) { - t.Parallel() - - builder := NewConnectorBuilder() - withManager(builder, func(tc *testContext[models.EmptyConnectorConfig]) { - cfg, err := models.EmptyConnectorConfig{ - Name: "test1", - }.Marshal() - require.NoError(t, err) - - connector := &models.Connector{ - ID: models.ConnectorID{ - Provider: tc.provider, - Reference: uuid.New(), - }, - Name: "test1", - Provider: tc.provider, - } - - err = tc.connectorStore.Install(context.TODO(), connector, cfg) - require.NoError(t, err) - - err = tc.manager.Restore(context.TODO()) - require.NoError(t, err) - require.Len(t, tc.manager.Connectors(), 1) - - require.NoError(t, tc.manager.Uninstall(context.TODO(), connector.ID)) - }) -} - -func TestRestoreNotInstalledConnector(t *testing.T) { - t.Parallel() - - builder := NewConnectorBuilder() - withManager(builder, func(tc *testContext[models.EmptyConnectorConfig]) { - err := tc.manager.Restore(context.TODO()) - require.NoError(t, err) - require.Len(t, tc.manager.Connectors(), 0) - }) -} diff --git a/cmd/connectors/internal/api/connectors_manager/store.go b/cmd/connectors/internal/api/connectors_manager/store.go deleted file mode 100644 index e1cb8570..00000000 --- a/cmd/connectors/internal/api/connectors_manager/store.go +++ /dev/null @@ -1,19 +0,0 @@ -package connectors_manager - -import ( - "context" - "encoding/json" - - "github.com/formancehq/payments/internal/models" -) - -type Store interface { - ListConnectors(ctx context.Context) ([]*models.Connector, error) - IsInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) - IsInstalledByConnectorName(ctx context.Context, name string) (bool, error) - Install(ctx context.Context, connector *models.Connector, config json.RawMessage) error - Uninstall(ctx context.Context, connectorID models.ConnectorID) error - UpdateConfig(ctx context.Context, connectorID models.ConnectorID, config json.RawMessage) error - GetConnector(ctx context.Context, connectorID models.ConnectorID) (*models.Connector, error) - CreateWebhook(ctx context.Context, webhook *models.Webhook) error -} diff --git a/cmd/connectors/internal/api/connectors_manager/storememory_test.go b/cmd/connectors/internal/api/connectors_manager/storememory_test.go deleted file mode 100644 index 6da72f4e..00000000 --- a/cmd/connectors/internal/api/connectors_manager/storememory_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package connectors_manager - -import ( - "context" - "encoding/json" - "sync" - - "github.com/formancehq/payments/internal/models" -) - -type connector struct { - name string - id models.ConnectorID - config json.RawMessage - provider models.ConnectorProvider -} - -type InMemoryConnectorStore struct { - connectorsByID map[string]*connector - connectorsByName map[string]*connector - mu sync.RWMutex -} - -func (i *InMemoryConnectorStore) Uninstall(ctx context.Context, connectorID models.ConnectorID) error { - i.mu.Lock() - defer i.mu.Unlock() - - connector, ok := i.connectorsByID[connectorID.String()] - if !ok { - return nil - } - - delete(i.connectorsByID, connectorID.String()) - delete(i.connectorsByName, connector.name) - - return nil -} - -func (i *InMemoryConnectorStore) ListConnectors(_ context.Context) ([]*models.Connector, error) { - i.mu.RLock() - defer i.mu.RUnlock() - - connectors := make([]*models.Connector, 0, len(i.connectorsByID)) - for _, c := range i.connectorsByID { - connectors = append(connectors, &models.Connector{ - ID: c.id, - Name: c.name, - Config: c.config, - Provider: c.provider, - }) - } - return connectors, nil -} - -func (i *InMemoryConnectorStore) IsInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - i.mu.RLock() - defer i.mu.RUnlock() - - _, ok := i.connectorsByID[connectorID.String()] - return ok, nil -} - -func (i *InMemoryConnectorStore) IsInstalledByConnectorName(ctx context.Context, name string) (bool, error) { - i.mu.RLock() - defer i.mu.RUnlock() - - _, ok := i.connectorsByName[name] - return ok, nil -} - -func (i *InMemoryConnectorStore) Install(ctx context.Context, newConnector *models.Connector, config json.RawMessage) error { - i.mu.Lock() - defer i.mu.Unlock() - - c := &connector{ - name: newConnector.Name, - id: newConnector.ID, - config: config, - provider: newConnector.Provider, - } - - i.connectorsByID[newConnector.ID.String()] = c - i.connectorsByName[newConnector.Name] = c - - return nil -} - -func (i *InMemoryConnectorStore) UpdateConfig(ctx context.Context, connectorID models.ConnectorID, config json.RawMessage) error { - i.mu.Lock() - defer i.mu.Unlock() - - i.connectorsByID[connectorID.String()].config = config - return nil -} - -func (i *InMemoryConnectorStore) GetConnector(ctx context.Context, connectorID models.ConnectorID) (*models.Connector, error) { - i.mu.RLock() - defer i.mu.RUnlock() - - c, ok := i.connectorsByID[connectorID.String()] - if !ok { - return nil, ErrNotFound - } - - return &models.Connector{ - ID: c.id, - Name: c.name, - Config: c.config, - Provider: c.provider, - }, nil -} - -func (i *InMemoryConnectorStore) ReadConfig(ctx context.Context, connectorID models.ConnectorID, to interface{}) error { - connector, err := i.GetConnector(ctx, connectorID) - if err != nil { - return err - } - - if err = connector.ParseConfig(to); err != nil { - return err - } - - return nil -} - -func (i *InMemoryConnectorStore) CreateWebhook(ctx context.Context, webhook *models.Webhook) error { - return nil -} - -var _ Store = &InMemoryConnectorStore{} - -func NewInMemoryStore() *InMemoryConnectorStore { - return &InMemoryConnectorStore{ - connectorsByID: make(map[string]*connector), - connectorsByName: make(map[string]*connector), - } -} diff --git a/cmd/connectors/internal/api/connectors_manager/taskscheduler.go b/cmd/connectors/internal/api/connectors_manager/taskscheduler.go deleted file mode 100644 index 86d54ce9..00000000 --- a/cmd/connectors/internal/api/connectors_manager/taskscheduler.go +++ /dev/null @@ -1,16 +0,0 @@ -package connectors_manager - -import ( - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -type TaskSchedulerFactory interface { - Make(connectorID models.ConnectorID, resolver task.Resolver, maxTasks int) *task.DefaultTaskScheduler -} - -type TaskSchedulerFactoryFn func(connectorID models.ConnectorID, resolver task.Resolver, maxProcesses int) *task.DefaultTaskScheduler - -func (fn TaskSchedulerFactoryFn) Make(connectorID models.ConnectorID, resolver task.Resolver, maxTasks int) *task.DefaultTaskScheduler { - return fn(connectorID, resolver, maxTasks) -} diff --git a/cmd/connectors/internal/api/health.go b/cmd/connectors/internal/api/health.go deleted file mode 100644 index 9ed5c104..00000000 --- a/cmd/connectors/internal/api/health.go +++ /dev/null @@ -1,25 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" -) - -func healthHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if err := b.GetService().Ping(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - w.WriteHeader(http.StatusOK) - } -} - -func liveHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - } -} diff --git a/cmd/connectors/internal/api/module.go b/cmd/connectors/internal/api/module.go deleted file mode 100644 index 7a1d4d1d..00000000 --- a/cmd/connectors/internal/api/module.go +++ /dev/null @@ -1,166 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "runtime/debug" - - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/httpserver" - "github.com/formancehq/go-libs/otlp" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/adyen" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/dummypay" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" - "github.com/rs/cors" - "github.com/sirupsen/logrus" - "go.uber.org/fx" -) - -const ( - serviceName = "Payments" - - ErrUniqueReference = "CONFLICT" - ErrNotFound = "NOT_FOUND" - ErrInvalidID = "INVALID_ID" - ErrMissingOrInvalidBody = "MISSING_OR_INVALID_BODY" - ErrValidation = "VALIDATION" -) - -func HTTPModule(serviceInfo api.ServiceInfo, bind, stackURL string, otelTraces bool) fx.Option { - return fx.Options( - fx.Invoke(func(m *mux.Router, lc fx.Lifecycle) { - lc.Append(httpserver.NewHook(m, httpserver.WithAddress(bind))) - }), - fx.Supply(serviceInfo), - fx.Provide(fx.Annotate(connectorsHandlerMap, fx.ParamTags(`group:"connectorHandlers"`))), - fx.Provide(func(store *storage.Storage) service.Store { - return store - }), - fx.Provide(fx.Annotate(service.New, fx.As(new(backend.Service)))), - fx.Provide(backend.NewDefaultBackend), - fx.Provide(fx.Annotate(func( - logger logging.Logger, - b backend.ServiceBackend, - serviceInfo api.ServiceInfo, - a auth.Authenticator, - connectorHandlers []connectorHandler, - ) *mux.Router { - return httpRouter(logger, b, serviceInfo, a, connectorHandlers, otelTraces) - }, fx.ParamTags(``, ``, ``, ``, `group:"connectorHandlers"`))), - fx.Provide(func() *messages.Messages { - return messages.NewMessages(stackURL) - }), - addConnector[dummypay.Config](dummypay.NewLoader()), - addConnector[modulr.Config](modulr.NewLoader()), - addConnector[stripe.Config](stripe.NewLoader()), - addConnector[wise.Config](wise.NewLoader()), - addConnector[currencycloud.Config](currencycloud.NewLoader()), - addConnector[bankingcircle.Config](bankingcircle.NewLoader()), - addConnector[mangopay.Config](mangopay.NewLoader()), - addConnector[moneycorp.Config](moneycorp.NewLoader()), - addConnector[atlar.Config](atlar.NewLoader()), - addConnector[adyen.Config](adyen.NewLoader()), - addConnector[generic.Config](generic.NewLoader()), - ) -} - -func connectorsHandlerMap(connectorHandlers []connectorHandler) map[models.ConnectorProvider]*service.ConnectorHandlers { - m := make(map[models.ConnectorProvider]*service.ConnectorHandlers) - for _, h := range connectorHandlers { - if handlers, ok := m[h.Provider]; ok { - handlers.InitiatePaymentHandler = h.initiatePayment - handlers.ReversePaymentHandler = h.reversePayment - handlers.BankAccountHandler = h.createExternalBankAccount - } else { - m[h.Provider] = &service.ConnectorHandlers{ - InitiatePaymentHandler: h.initiatePayment, - ReversePaymentHandler: h.reversePayment, - BankAccountHandler: h.createExternalBankAccount, - } - } - } - return m -} - -func httpRecoveryFunc(otelTraces bool) func(context.Context, interface{}) { - return func(ctx context.Context, e interface{}) { - if otelTraces { - otlp.RecordAsError(ctx, e) - } else { - logrus.Errorln(e) - debug.PrintStack() - } - } -} - -func httpCorsHandler() func(http.Handler) http.Handler { - return cors.New(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut}, - AllowCredentials: true, - }).Handler -} - -func httpServeFunc(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - handler.ServeHTTP(w, r) - }) -} - -func handleServiceErrors(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, storage.ErrDuplicateKeyValue): - api.BadRequest(w, ErrUniqueReference, err) - case errors.Is(err, storage.ErrNotFound): - api.NotFound(w, err) - case errors.Is(err, service.ErrValidation): - api.BadRequest(w, ErrValidation, err) - case errors.Is(err, service.ErrInvalidID): - api.BadRequest(w, ErrInvalidID, err) - case errors.Is(err, service.ErrPublish): - api.InternalServerError(w, r, err) - default: - api.InternalServerError(w, r, err) - } -} - -func handleConnectorsManagerErrors(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, storage.ErrDuplicateKeyValue): - api.BadRequest(w, ErrUniqueReference, err) - case errors.Is(err, storage.ErrNotFound): - api.NotFound(w, err) - case errors.Is(err, manager.ErrAlreadyInstalled): - api.BadRequest(w, ErrValidation, err) - case errors.Is(err, manager.ErrNotInstalled): - api.BadRequest(w, ErrValidation, err) - case errors.Is(err, manager.ErrConnectorNotFound): - api.BadRequest(w, ErrValidation, err) - case errors.Is(err, manager.ErrNotFound): - api.NotFound(w, err) - case errors.Is(err, manager.ErrValidation): - api.BadRequest(w, ErrValidation, err) - default: - api.InternalServerError(w, r, err) - } -} diff --git a/cmd/connectors/internal/api/read_connectors.go b/cmd/connectors/internal/api/read_connectors.go deleted file mode 100644 index cf788a30..00000000 --- a/cmd/connectors/internal/api/read_connectors.go +++ /dev/null @@ -1,56 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -type readConnectorsResponseElement struct { - Provider models.ConnectorProvider `json:"provider" bson:"provider"` - ConnectorID string `json:"connectorID" bson:"connectorID"` - Name string `json:"name" bson:"name"` - Enabled bool `json:"enabled" bson:"enabled"` -} - -func readConnectorsHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "readConnectorsHandler") - defer span.End() - - res, err := b.GetService().ListConnectors(ctx) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - span.SetAttributes(attribute.Int("count", len(res))) - - data := make([]readConnectorsResponseElement, len(res)) - - for i := range res { - data[i] = readConnectorsResponseElement{ - Provider: res[i].Provider, - ConnectorID: res[i].ID.String(), - Name: res[i].Name, - Enabled: true, - } - } - - err = json.NewEncoder(w).Encode( - api.BaseResponse[[]readConnectorsResponseElement]{ - Data: &data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} diff --git a/cmd/connectors/internal/api/read_connectors_test.go b/cmd/connectors/internal/api/read_connectors_test.go deleted file mode 100644 index 0648c697..00000000 --- a/cmd/connectors/internal/api/read_connectors_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - "time" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestReadConnectors(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - testCases := []testCase{ - { - name: "nominal", - }, - { - name: "service error duplicate key", - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - serviceError: service.ErrInvalidID, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - serviceError: service.ErrPublish, - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error unknown", - serviceError: errors.New("unknown"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - listConnectorsResponse := []*models.Connector{ - { - ID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - Name: "c1", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Provider: models.ConnectorProviderDummyPay, - }, - } - - expectedListConnectorsResponse := []readConnectorsResponseElement{ - { - Provider: listConnectorsResponse[0].Provider, - ConnectorID: listConnectorsResponse[0].ID.String(), - Name: "c1", - Enabled: true, - }, - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - ListConnectors(gomock.Any()). - Return(listConnectorsResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - ListConnectors(gomock.Any()). - Return(nil, testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), nil, false) - - req := httptest.NewRequest(http.MethodGet, "/connectors", nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[[]readConnectorsResponseElement] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, &expectedListConnectorsResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/connectors/internal/api/recovery.go b/cmd/connectors/internal/api/recovery.go deleted file mode 100644 index fce3087e..00000000 --- a/cmd/connectors/internal/api/recovery.go +++ /dev/null @@ -1,20 +0,0 @@ -package api - -import ( - "context" - "net/http" -) - -func recoveryHandler(reporter func(ctx context.Context, e interface{})) func(h http.Handler) http.Handler { - return func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if e := recover(); e != nil { - w.WriteHeader(http.StatusInternalServerError) - reporter(r.Context(), e) - } - }() - h.ServeHTTP(w, r) - }) - } -} diff --git a/cmd/connectors/internal/api/router.go b/cmd/connectors/internal/api/router.go deleted file mode 100644 index f367ff65..00000000 --- a/cmd/connectors/internal/api/router.go +++ /dev/null @@ -1,157 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" - "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux" -) - -func httpRouter( - logger logging.Logger, - b backend.ServiceBackend, - serviceInfo api.ServiceInfo, - a auth.Authenticator, - connectorHandlers []connectorHandler, - otelTraces bool, -) *mux.Router { - rootMux := mux.NewRouter() - - // We have to keep this recovery handler here to ensure that the health - // endpoint is not panicking - rootMux.Use(recoveryHandler(httpRecoveryFunc(otelTraces))) - rootMux.Use(httpCorsHandler()) - rootMux.Use(httpServeFunc) - rootMux.Use(func(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler.ServeHTTP(w, r.WithContext(logging.ContextWithLogger(r.Context(), logger))) - }) - }) - - rootMux.Path("/_health").Handler(healthHandler(b)) - - subRouter := rootMux.NewRoute().Subrouter() - if otelTraces { - subRouter.Use(otelmux.Middleware(serviceName)) - // Add a second recovery handler to ensure that the otel middleware - // is catching the error in the trace - rootMux.Use(recoveryHandler(httpRecoveryFunc(otelTraces))) - } - subRouter.Path("/_live").Handler(liveHandler()) - subRouter.Path("/_info").Handler(api.InfoHandler(serviceInfo)) - - authGroup := subRouter.Name("authenticated").Subrouter() - authGroup.Use(auth.Middleware(a)) - - authGroup.Path("/bank-accounts").Methods(http.MethodPost).Handler(createBankAccountHandler(b)) - authGroup.Path("/bank-accounts/{bankAccountID}/forward").Methods(http.MethodPost).Handler(forwardBankAccountToConnector(b)) - authGroup.Path("/bank-accounts/{bankAccountID}/metadata").Methods(http.MethodPatch).Handler(updateBankAccountMetadataHandler(b)) - - authGroup.Path("/transfer-initiations").Methods(http.MethodPost).Handler(createTransferInitiationHandler(b)) - authGroup.Path("/transfer-initiations/{transferID}/status").Methods(http.MethodPost).Handler(updateTransferInitiationStatusHandler(b)) - authGroup.Path("/transfer-initiations/{transferID}/retry").Methods(http.MethodPost).Handler(retryTransferInitiationHandler(b)) - authGroup.Path("/transfer-initiations/{transferID}/reverse").Methods(http.MethodPost).Handler(reverseTransferInitiationHandler(b)) - authGroup.Path("/transfer-initiations/{transferID}").Methods(http.MethodDelete).Handler(deleteTransferInitiationHandler(b)) - - authGroup.HandleFunc("/connectors", readConnectorsHandler(b)) - - connectorGroupAuthenticated := authGroup.PathPrefix("/connectors").Subrouter() - connectorGroupAuthenticated.Path("/configs").Handler(connectorConfigsHandler()) - - // Needed for webhooks - connectorGroupUnauthenticated := subRouter.PathPrefix("/connectors").Subrouter() - - for _, h := range connectorHandlers { - connectorGroupAuthenticated.PathPrefix("/" + h.Provider.String()).Handler( - http.StripPrefix("/connectors", h.Handler)) - - connectorGroupAuthenticated.PathPrefix("/" + h.Provider.StringLower()).Handler( - http.StripPrefix("/connectors", h.Handler)) - - if h.WebhookHandler != nil { - connectorGroupUnauthenticated.PathPrefix("/webhooks/" + h.Provider.String()).Handler( - http.StripPrefix("/connectors", h.WebhookHandler)) - - connectorGroupUnauthenticated.PathPrefix("/webhooks/" + h.Provider.StringLower()).Handler( - http.StripPrefix("/connectors", h.WebhookHandler)) - } - } - - return rootMux -} - -func connectorRouter[Config models.ConnectorConfigObject]( - provider models.ConnectorProvider, - b backend.ManagerBackend[Config], -) *mux.Router { - r := mux.NewRouter() - - addRoute(r, provider, "", http.MethodPost, install(b)) - addRoute(r, provider, "/{connectorID}", http.MethodDelete, uninstall(b, V1)) - addRoute(r, provider, "/{connectorID}/config", http.MethodGet, readConfig(b, V1)) - addRoute(r, provider, "/{connectorID}/config", http.MethodPost, updateConfig(b, V1)) - addRoute(r, provider, "/{connectorID}/reset", http.MethodPost, reset(b, V1)) - addRoute(r, provider, "/{connectorID}/tasks", http.MethodGet, listTasks(b, V1)) - addRoute(r, provider, "/{connectorID}/tasks/{taskID}", http.MethodGet, readTask(b, V1)) - - // Deprecated routes - addRoute(r, provider, "", http.MethodDelete, uninstall(b, V0)) - addRoute(r, provider, "/config", http.MethodGet, readConfig(b, V0)) - addRoute(r, provider, "/reset", http.MethodPost, reset(b, V0)) - addRoute(r, provider, "/tasks", http.MethodGet, listTasks(b, V0)) - addRoute(r, provider, "/tasks/{taskID}", http.MethodGet, readTask(b, V0)) - - return r -} - -func webhookConnectorRouter[Config models.ConnectorConfigObject]( - provider models.ConnectorProvider, - connectorRouter *mux.Router, - b backend.ManagerBackend[Config], -) *mux.Router { - if connectorRouter == nil { - return nil - } - - r := mux.NewRouter() - - group := r.PathPrefix("/webhooks/" + provider.String() + "/{connectorID}").Subrouter() - group.Use(webhooksMiddleware(b, V1)) - addWebhookRoute(group, connectorRouter) - - groupLower := r.PathPrefix("/webhooks/" + provider.StringLower() + "/{connectorID}").Subrouter() - groupLower.Use(webhooksMiddleware(b, V1)) - addWebhookRoute(groupLower, connectorRouter) - - return r -} - -func addRoute(r *mux.Router, provider models.ConnectorProvider, path, method string, handler http.Handler) { - r.Path("/" + provider.String() + path).Methods(method).Handler(handler) - r.Path("/" + provider.StringLower() + path).Methods(method).Handler(handler) -} - -func addWebhookRoute(r *mux.Router, subRouter *mux.Router) { - subRouter.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { - pathTemplate, err := route.GetPathTemplate() - if err != nil { - return err - } - - methods, err := route.GetMethods() - if err != nil { - return err - } - - for _, method := range methods { - r.Path(pathTemplate).Methods(method).Handler(route.GetHandler()) - } - - return nil - }) -} diff --git a/cmd/connectors/internal/api/service/bank_account.go b/cmd/connectors/internal/api/service/bank_account.go deleted file mode 100644 index 634877a3..00000000 --- a/cmd/connectors/internal/api/service/bank_account.go +++ /dev/null @@ -1,190 +0,0 @@ -package service - -import ( - "context" - "time" - - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" -) - -type CreateBankAccountRequest struct { - AccountNumber string `json:"accountNumber"` - IBAN string `json:"iban"` - SwiftBicCode string `json:"swiftBicCode"` - Country string `json:"country"` - ConnectorID string `json:"connectorID"` - Name string `json:"name"` - Metadata map[string]string `json:"metadata"` -} - -func (c *CreateBankAccountRequest) Validate() error { - if c.AccountNumber == "" && c.IBAN == "" { - return errors.New("either accountNumber or iban must be provided") - } - - if c.Name == "" { - return errors.New("name must be provided") - } - - if c.Country == "" { - return errors.New("country must be provided") - } - - return nil -} - -func (s *Service) CreateBankAccount(ctx context.Context, req *CreateBankAccountRequest) (*models.BankAccount, error) { - var handlers *ConnectorHandlers - var connectorID models.ConnectorID - if req.ConnectorID != "" { - var err error - connectorID, err = models.ConnectorIDFromString(req.ConnectorID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - connector, err := s.store.GetConnector(ctx, connectorID) - if err != nil && errors.Is(err, storage.ErrNotFound) { - return nil, errors.Wrap(ErrValidation, "connector not installed") - } else if err != nil { - return nil, newStorageError(err, "getting connector") - } - - var ok bool - handlers, ok = s.connectorHandlers[connector.Provider] - if !ok || handlers.BankAccountHandler == nil { - return nil, errors.Wrap(ErrValidation, "no bank account handler for connector") - } - } - - bankAccount := &models.BankAccount{ - CreatedAt: time.Now().UTC(), - AccountNumber: req.AccountNumber, - IBAN: req.IBAN, - SwiftBicCode: req.SwiftBicCode, - Country: req.Country, - Name: req.Name, - Metadata: req.Metadata, - } - err := s.store.CreateBankAccount(ctx, bankAccount) - if err != nil { - return nil, newStorageError(err, "creating bank account") - } - - if handlers != nil { - if err := handlers.BankAccountHandler(ctx, connectorID, bankAccount); err != nil { - switch { - case errors.Is(err, manager.ErrValidation): - return nil, errors.Wrap(ErrValidation, err.Error()) - case errors.Is(err, manager.ErrConnectorNotFound): - return nil, errors.Wrap(ErrValidation, err.Error()) - default: - return nil, err - } - } - - relatedAccounts, err := s.store.GetBankAccountRelatedAccounts(ctx, bankAccount.ID) - if err != nil { - return nil, newStorageError(err, "fetching bank account") - } - - bankAccount.RelatedAccounts = relatedAccounts - } - - return bankAccount, nil -} - -type ForwardBankAccountToConnectorRequest struct { - ConnectorID string `json:"connectorID"` -} - -func (f *ForwardBankAccountToConnectorRequest) Validate() error { - if f.ConnectorID == "" { - return errors.New("connectorID must be provided") - } - - return nil -} - -func (s *Service) ForwardBankAccountToConnector(ctx context.Context, id string, req *ForwardBankAccountToConnectorRequest) (*models.BankAccount, error) { - bankAccountID, err := uuid.Parse(id) - if err != nil { - return nil, errors.Wrap(ErrInvalidID, err.Error()) - } - - connectorID, err := models.ConnectorIDFromString(req.ConnectorID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - connector, err := s.store.GetConnector(ctx, connectorID) - if err != nil && errors.Is(err, storage.ErrNotFound) { - return nil, errors.Wrap(ErrValidation, "connector not installed") - } else if err != nil { - return nil, newStorageError(err, "getting connector") - } - - handlers, ok := s.connectorHandlers[connector.Provider] - if !ok || handlers.BankAccountHandler == nil { - return nil, errors.Wrap(ErrValidation, "no bank account handler for connector") - } - - bankAccount, err := s.store.GetBankAccount(ctx, bankAccountID, true) - if err != nil { - return nil, newStorageError(err, "fetching bank account") - } - - for _, relatedAccount := range bankAccount.RelatedAccounts { - if relatedAccount.ConnectorID == connectorID { - return nil, errors.Wrap(ErrValidation, "bank account already forwarded to connector") - } - } - - if err := handlers.BankAccountHandler(ctx, connectorID, bankAccount); err != nil { - switch { - case errors.Is(err, manager.ErrValidation): - return nil, errors.Wrap(ErrValidation, err.Error()) - case errors.Is(err, manager.ErrConnectorNotFound): - return nil, errors.Wrap(ErrValidation, err.Error()) - default: - return nil, err - } - } - - relatedAccounts, err := s.store.GetBankAccountRelatedAccounts(ctx, bankAccount.ID) - if err != nil { - return nil, newStorageError(err, "fetching bank account") - } - bankAccount.RelatedAccounts = relatedAccounts - - return bankAccount, err -} - -type UpdateBankAccountMetadataRequest struct { - Metadata map[string]string `json:"metadata"` -} - -func (u *UpdateBankAccountMetadataRequest) Validate() error { - if len(u.Metadata) == 0 { - return errors.New("metadata must be provided") - } - - return nil -} - -func (s *Service) UpdateBankAccountMetadata(ctx context.Context, id string, req *UpdateBankAccountMetadataRequest) error { - bankAccountID, err := uuid.Parse(id) - if err != nil { - return errors.Wrap(ErrInvalidID, err.Error()) - } - - if err := s.store.UpdateBankAccountMetadata(ctx, bankAccountID, req.Metadata); err != nil { - return newStorageError(err, "updating bank account metadata") - } - - return nil -} diff --git a/cmd/connectors/internal/api/service/bank_account_test.go b/cmd/connectors/internal/api/service/bank_account_test.go deleted file mode 100644 index 46a0bdbc..00000000 --- a/cmd/connectors/internal/api/service/bank_account_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" -) - -func TestCreateBankAccounts(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *CreateBankAccountRequest - expectedError error - noBankAccountCreateHandler bool - errorBankAccountCreateHandler error - } - - connectorNotFound := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderCurrencyCloud, - } - - var ErrOther = errors.New("other error") - testCases := []testCase{ - { - name: "nominal", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorDummyPay.ID.String(), - Name: "test_nominal", - }, - }, - { - name: "nominal with metadata", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorDummyPay.ID.String(), - Name: "test_nominal_metadata", - Metadata: map[string]string{"test": "metadata"}, - }, - }, - { - name: "nominal without connectorID", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - Name: "test_nominal", - }, - }, - { - name: "invalid connector id", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: "invalid", - Name: "test_nominal", - }, - expectedError: ErrValidation, - }, - { - name: "connector not found", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorNotFound.String(), - Name: "test_nominal", - }, - expectedError: ErrValidation, - }, - { - name: "no connector handler", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorDummyPay.ID.String(), - Name: "test_nominal", - }, - noBankAccountCreateHandler: true, - expectedError: ErrValidation, - }, - { - name: "connector handler error validation", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorDummyPay.ID.String(), - Name: "test_nominal", - }, - errorBankAccountCreateHandler: manager.ErrValidation, - expectedError: ErrValidation, - }, - { - name: "connector handler error connector not found", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorDummyPay.ID.String(), - Name: "test_nominal", - }, - errorBankAccountCreateHandler: manager.ErrConnectorNotFound, - expectedError: ErrValidation, - }, - { - name: "connector handler other error", - req: &CreateBankAccountRequest{ - AccountNumber: "0112345678", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - ConnectorID: connectorDummyPay.ID.String(), - Name: "test_nominal", - }, - errorBankAccountCreateHandler: ErrOther, - expectedError: ErrOther, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var handlers map[models.ConnectorProvider]*ConnectorHandlers - if !tc.noBankAccountCreateHandler { - handlers = map[models.ConnectorProvider]*ConnectorHandlers{ - models.ConnectorProviderDummyPay: { - BankAccountHandler: func(ctx context.Context, connectorID models.ConnectorID, bankAccount *models.BankAccount) error { - if tc.errorBankAccountCreateHandler != nil { - return tc.errorBankAccountCreateHandler - } - - return nil - }, - }, - } - } - - service := New(&MockStore{}, &MockPublisher{}, messages.NewMessages(""), handlers) - - _, err := service.CreateBankAccount(context.Background(), tc.req) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestForwardBankAccountToConnector(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - bankAccountID string - req *ForwardBankAccountToConnectorRequest - withBankAccountRelatedAccounts []*models.BankAccountRelatedAccount - expectedError error - noBankAccountForwardHandler bool - errorBankAccountForwardHandler error - } - - connectorNotFound := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderCurrencyCloud, - } - - var ErrOther = errors.New("other error") - bankAccountID := uuid.New() - testCases := []testCase{ - { - name: "nominal", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - }, - { - name: "already forwarded to connector", - bankAccountID: bankAccountID.String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - withBankAccountRelatedAccounts: []*models.BankAccountRelatedAccount{ - { - ID: uuid.New(), - CreatedAt: time.Now().UTC(), - BankAccountID: bankAccountID, - ConnectorID: connectorDummyPay.ID, - AccountID: models.AccountID{ - Reference: "test", - ConnectorID: connectorDummyPay.ID, - }, - }, - }, - expectedError: ErrValidation, - }, - { - name: "empty bank account id", - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - expectedError: ErrInvalidID, - }, - { - name: "invalid bank account id", - bankAccountID: "invalid", - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - expectedError: ErrInvalidID, - }, - { - name: "missing connectorID", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{}, - expectedError: ErrValidation, - }, - { - name: "invalid connector id", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: "invalid", - }, - expectedError: ErrValidation, - }, - { - name: "connector not found", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorNotFound.String(), - }, - expectedError: ErrValidation, - }, - { - name: "no connector handler", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - noBankAccountForwardHandler: true, - expectedError: ErrValidation, - }, - { - name: "connector handler error validation", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - errorBankAccountForwardHandler: manager.ErrValidation, - expectedError: ErrValidation, - }, - { - name: "connector handler error connector not found", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - errorBankAccountForwardHandler: manager.ErrConnectorNotFound, - expectedError: ErrValidation, - }, - { - name: "connector handler other error", - bankAccountID: uuid.New().String(), - req: &ForwardBankAccountToConnectorRequest{ - ConnectorID: connectorDummyPay.ID.String(), - }, - errorBankAccountForwardHandler: ErrOther, - expectedError: ErrOther, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var handlers map[models.ConnectorProvider]*ConnectorHandlers - if !tc.noBankAccountForwardHandler { - handlers = map[models.ConnectorProvider]*ConnectorHandlers{ - models.ConnectorProviderDummyPay: { - BankAccountHandler: func(ctx context.Context, connectorID models.ConnectorID, bankAccount *models.BankAccount) error { - if tc.errorBankAccountForwardHandler != nil { - return tc.errorBankAccountForwardHandler - } - - return nil - }, - }, - } - } - - store := &MockStore{} - service := New(store.WithBankAccountRelatedAccounts(tc.withBankAccountRelatedAccounts), &MockPublisher{}, messages.NewMessages(""), handlers) - - _, err := service.ForwardBankAccountToConnector(context.Background(), tc.bankAccountID, tc.req) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestUpdateBankAccountMetadata(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - bankAccountID string - req *UpdateBankAccountMetadataRequest - storageError error - expectedError error - } - - testCases := []testCase{ - { - name: "nominal", - bankAccountID: uuid.New().String(), - req: &UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - }, - { - name: "err not found from storage", - bankAccountID: uuid.New().String(), - req: &UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - storageError: storage.ErrNotFound, - expectedError: storage.ErrNotFound, - }, - { - name: "empty bank account id", - req: &UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedError: ErrInvalidID, - }, - { - name: "invalid bank account id", - bankAccountID: "invalid", - req: &UpdateBankAccountMetadataRequest{ - Metadata: map[string]string{ - "foo": "bar", - }, - }, - expectedError: ErrInvalidID, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var handlers map[models.ConnectorProvider]*ConnectorHandlers - - store := &MockStore{} - if tc.storageError != nil { - store = store.WithError(tc.storageError) - } - service := New(store, &MockPublisher{}, messages.NewMessages(""), handlers) - - err := service.UpdateBankAccountMetadata(context.Background(), tc.bankAccountID, tc.req) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/cmd/connectors/internal/api/service/connector.go b/cmd/connectors/internal/api/service/connector.go deleted file mode 100644 index fac2548c..00000000 --- a/cmd/connectors/internal/api/service/connector.go +++ /dev/null @@ -1,12 +0,0 @@ -package service - -import ( - "context" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Service) ListConnectors(ctx context.Context) ([]*models.Connector, error) { - connectors, err := s.store.ListConnectors(ctx) - return connectors, newStorageError(err, "listing connectors") -} diff --git a/cmd/connectors/internal/api/service/errors.go b/cmd/connectors/internal/api/service/errors.go deleted file mode 100644 index fff30a83..00000000 --- a/cmd/connectors/internal/api/service/errors.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -import ( - "errors" - "fmt" -) - -var ( - ErrValidation = errors.New("validation error") - ErrPublish = errors.New("publish error") - ErrInvalidID = errors.New("invalid id") -) - -type storageError struct { - err error - msg string -} - -func (e *storageError) Error() string { - return fmt.Sprintf("%s: %s", e.msg, e.err) -} - -func (e *storageError) Is(err error) bool { - _, ok := err.(*storageError) - return ok -} - -func (e *storageError) Unwrap() error { - return e.err -} - -func newStorageError(err error, msg string) error { - if err == nil { - return nil - } - return &storageError{ - err: err, - msg: msg, - } -} diff --git a/cmd/connectors/internal/api/service/ping.go b/cmd/connectors/internal/api/service/ping.go deleted file mode 100644 index 8fbedef7..00000000 --- a/cmd/connectors/internal/api/service/ping.go +++ /dev/null @@ -1,5 +0,0 @@ -package service - -func (s *Service) Ping() error { - return newStorageError(s.store.Ping(), "ping") -} diff --git a/cmd/connectors/internal/api/service/service.go b/cmd/connectors/internal/api/service/service.go deleted file mode 100644 index 9e598680..00000000 --- a/cmd/connectors/internal/api/service/service.go +++ /dev/null @@ -1,66 +0,0 @@ -package service - -import ( - "context" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -type InitiatePaymentHandler func(ctx context.Context, transfer *models.TransferInitiation) error -type ReversePaymentHandler func(ctx context.Context, transfer *models.TransferReversal) error -type BankAccountHandler func(ctx context.Context, connectorID models.ConnectorID, bankAccount *models.BankAccount) error - -type Store interface { - Ping() error - - GetConnector(ctx context.Context, connectorID models.ConnectorID) (*models.Connector, error) - ListConnectors(ctx context.Context) ([]*models.Connector, error) - - UpsertAccounts(ctx context.Context, accounts []*models.Account) ([]models.AccountID, error) - GetAccount(ctx context.Context, id string) (*models.Account, error) - - CreateBankAccount(ctx context.Context, account *models.BankAccount) error - UpdateBankAccountMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error - GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) - GetBankAccountRelatedAccounts(ctx context.Context, id uuid.UUID) ([]*models.BankAccountRelatedAccount, error) - - ListConnectorsByProvider(ctx context.Context, provider models.ConnectorProvider) ([]*models.Connector, error) - IsInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) - - CreateTransferInitiation(ctx context.Context, transferInitiation *models.TransferInitiation) error - ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) - UpdateTransferInitiationPaymentsStatus(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, adjustment *models.TransferInitiationAdjustment) error - DeleteTransferInitiation(ctx context.Context, id models.TransferInitiationID) error - - CreateTransferReversal(ctx context.Context, transferReversal *models.TransferReversal) error -} - -type Service struct { - store Store - publisher message.Publisher - messages *messages.Messages - connectorHandlers map[models.ConnectorProvider]*ConnectorHandlers -} - -type ConnectorHandlers struct { - InitiatePaymentHandler InitiatePaymentHandler - ReversePaymentHandler ReversePaymentHandler - BankAccountHandler BankAccountHandler -} - -func New( - store Store, - publisher message.Publisher, - messages *messages.Messages, - connectorHandlers map[models.ConnectorProvider]*ConnectorHandlers, -) *Service { - return &Service{ - store: store, - publisher: publisher, - connectorHandlers: connectorHandlers, - messages: messages, - } -} diff --git a/cmd/connectors/internal/api/service/service_test.go b/cmd/connectors/internal/api/service/service_test.go deleted file mode 100644 index b17269fe..00000000 --- a/cmd/connectors/internal/api/service/service_test.go +++ /dev/null @@ -1,284 +0,0 @@ -package service - -import ( - "context" - "math/big" - "time" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -var ( - connectorDummyPay = models.Connector{ - ID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - Name: "c1", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Provider: models.ConnectorProviderDummyPay, - } - - connectorBankingCircle = models.Connector{ - ID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderBankingCircle, - }, - Name: "c2", - CreatedAt: time.Date(2023, 11, 22, 9, 0, 0, 0, time.UTC), - Provider: models.ConnectorProviderBankingCircle, - } - - transferInitiationWaiting = models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorDummyPay.ID, - }, - DestinationAccountID: models.AccountID{ - Reference: "acc2", - ConnectorID: connectorDummyPay.ID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorDummyPay.ID, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: "EUR/2", - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - } - - transferInitiationFailed = models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "ref2", - ConnectorID: connectorDummyPay.ID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &models.AccountID{ - Reference: "acc1", - ConnectorID: connectorDummyPay.ID, - }, - DestinationAccountID: models.AccountID{ - Reference: "acc2", - ConnectorID: connectorDummyPay.ID, - }, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorDummyPay.ID, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: "EUR/2", - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref2", - ConnectorID: connectorDummyPay.ID, - }, - CreatedAt: time.Date(2023, 11, 22, 9, 0, 0, 0, time.UTC), - Status: models.TransferInitiationStatusFailed, - Error: "some error", - }, - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref2", - ConnectorID: connectorDummyPay.ID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - } - - sourceAccountID = models.AccountID{ - Reference: "acc1", - ConnectorID: connectorDummyPay.ID, - } - - destinationAccountID = models.AccountID{ - Reference: "acc2", - ConnectorID: connectorDummyPay.ID, - } - - destinationExternalAccountID = models.AccountID{ - Reference: "acc3", - ConnectorID: connectorDummyPay.ID, - } -) - -type MockStore struct { - errorToSend error - listConnectorsNB int - bankAccountRelatedAccounts []*models.BankAccountRelatedAccount -} - -func (m *MockStore) WithError(err error) *MockStore { - m.errorToSend = err - return m -} - -func (m *MockStore) WithListConnectorsNB(nb int) *MockStore { - m.listConnectorsNB = nb - return m -} - -func (m *MockStore) WithBankAccountRelatedAccounts(relatedAccounts []*models.BankAccountRelatedAccount) *MockStore { - m.bankAccountRelatedAccounts = relatedAccounts - return m -} - -func (m *MockStore) Ping() error { - return nil -} - -func (m *MockStore) GetConnector(ctx context.Context, connectorID models.ConnectorID) (*models.Connector, error) { - if connectorID == connectorDummyPay.ID { - return &connectorDummyPay, nil - } else if connectorID == connectorBankingCircle.ID { - return &connectorBankingCircle, nil - } - - return nil, storage.ErrNotFound -} - -func (m *MockStore) ListConnectors(ctx context.Context) ([]*models.Connector, error) { - return []*models.Connector{&connectorDummyPay, &connectorBankingCircle}, nil -} - -func (m *MockStore) UpsertAccounts(ctx context.Context, accounts []*models.Account) ([]models.AccountID, error) { - return nil, nil -} - -func (m *MockStore) GetAccount(ctx context.Context, id string) (*models.Account, error) { - switch id { - case sourceAccountID.String(): - return &models.Account{ - ID: sourceAccountID, - Type: models.AccountTypeInternal, - }, nil - case destinationAccountID.String(): - return &models.Account{ - ID: destinationAccountID, - Type: models.AccountTypeInternal, - }, nil - case destinationExternalAccountID.String(): - return &models.Account{ - ID: destinationAccountID, - Type: models.AccountTypeExternal, - }, nil - } - - return nil, nil -} - -func (m *MockStore) CreateBankAccount(ctx context.Context, account *models.BankAccount) error { - account.ID = uuid.New() - return nil -} - -func (m *MockStore) UpdateBankAccountMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error { - return m.errorToSend -} - -func (m *MockStore) GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { - return &models.BankAccount{ - ID: id, - CreatedAt: time.Now().UTC(), - Name: "test", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "HBUKGB4B", - Country: "FR", - Metadata: map[string]string{}, - RelatedAccounts: m.bankAccountRelatedAccounts, - }, nil -} - -func (m *MockStore) GetBankAccountRelatedAccounts(ctx context.Context, id uuid.UUID) ([]*models.BankAccountRelatedAccount, error) { - return nil, nil -} - -func (m *MockStore) ListConnectorsByProvider(ctx context.Context, provider models.ConnectorProvider) ([]*models.Connector, error) { - switch m.listConnectorsNB { - case 0: - return []*models.Connector{}, nil - case 1: - return []*models.Connector{&connectorDummyPay}, nil - default: - return []*models.Connector{&connectorDummyPay, &connectorBankingCircle}, nil - } -} - -func (m *MockStore) IsInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - if connectorID == connectorDummyPay.ID { - return true, nil - } - - return false, nil -} - -func (m *MockStore) CreateTransferInitiation(ctx context.Context, transferInitiation *models.TransferInitiation) error { - return nil -} - -func (m *MockStore) ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) { - if id == transferInitiationWaiting.ID { - tc := transferInitiationWaiting - return &tc, nil - } else if id == transferInitiationFailed.ID { - tc := transferInitiationFailed - return &tc, nil - } - - return nil, storage.ErrNotFound -} - -func (m *MockStore) UpdateTransferInitiationPaymentsStatus(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, adjustment *models.TransferInitiationAdjustment) error { - return nil -} - -func (m *MockStore) DeleteTransferInitiation(ctx context.Context, id models.TransferInitiationID) error { - return nil -} - -func (m *MockStore) CreateTransferReversal(ctx context.Context, transferReversal *models.TransferReversal) error { - return nil -} - -type MockPublisher struct { - errorToSend error -} - -func (m *MockPublisher) WithError(err error) *MockPublisher { - m.errorToSend = err - return m -} - -func (m *MockPublisher) Publish(topic string, messages ...*message.Message) error { - return m.errorToSend -} - -func (m *MockPublisher) Close() error { - return nil -} diff --git a/cmd/connectors/internal/api/service/transfer_initiation.go b/cmd/connectors/internal/api/service/transfer_initiation.go deleted file mode 100644 index a487a99e..00000000 --- a/cmd/connectors/internal/api/service/transfer_initiation.go +++ /dev/null @@ -1,394 +0,0 @@ -package service - -import ( - "context" - "fmt" - "math/big" - "time" - - "github.com/formancehq/go-libs/publish" - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" - "github.com/pkg/errors" -) - -type CreateTransferInitiationRequest struct { - Reference string `json:"reference"` - ScheduledAt time.Time `json:"scheduledAt"` - Description string `json:"description"` - SourceAccountID string `json:"sourceAccountID"` - DestinationAccountID string `json:"destinationAccountID"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` - Type string `json:"type"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Validated bool `json:"validated"` - Metadata map[string]string `json:"metadata"` -} - -func (r *CreateTransferInitiationRequest) Validate() error { - if r.Reference == "" { - return errors.New("reference is required") - } - - if r.SourceAccountID != "" { - _, err := models.AccountIDFromString(r.SourceAccountID) - if err != nil { - return err - } - } - - _, err := models.AccountIDFromString(r.DestinationAccountID) - if err != nil { - return err - } - - _, err = models.TransferInitiationTypeFromString(r.Type) - if err != nil { - return err - } - - if r.Amount == nil { - return errors.New("amount is required") - } - - if r.Asset == "" { - return errors.New("asset is required") - } - - return nil -} - -func (s *Service) CreateTransferInitiation(ctx context.Context, req *CreateTransferInitiationRequest) (*models.TransferInitiation, error) { - status := models.TransferInitiationStatusWaitingForValidation - if req.Validated { - status = models.TransferInitiationStatusValidated - } - - var connectorID models.ConnectorID - if req.ConnectorID == "" { - provider, err := models.ConnectorProviderFromString(req.Provider) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - connectors, err := s.store.ListConnectorsByProvider(ctx, provider) - if err != nil { - return nil, newStorageError(err, "listing connectors") - } - - if len(connectors) == 0 { - return nil, errors.Wrap(ErrValidation, fmt.Sprintf("no connector found for provider %s", provider)) - } - - if len(connectors) > 1 { - return nil, errors.Wrap(ErrValidation, fmt.Sprintf("multiple connectors found for provider %s", provider)) - } - - connectorID = connectors[0].ID - } else { - var err error - connectorID, err = models.ConnectorIDFromString(req.ConnectorID) - if err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - } - - isInstalled, _ := s.store.IsInstalledByConnectorID(ctx, connectorID) - if !isInstalled { - return nil, errors.Wrap(ErrValidation, fmt.Sprintf("connector %s is not installed", req.ConnectorID)) - } - - if req.SourceAccountID != "" { - _, err := s.store.GetAccount(ctx, req.SourceAccountID) - if err != nil { - return nil, newStorageError(err, "getting source account") - } - } - - destinationAccount, err := s.store.GetAccount(ctx, req.DestinationAccountID) - if err != nil { - return nil, newStorageError(err, "getting destination account") - } - - transferType := models.MustTransferInitiationTypeFromString(req.Type) - - switch transferType { - case models.TransferInitiationTypeTransfer: - if destinationAccount.Type != models.AccountTypeInternal { - // account should be internal when doing a transfer, return an error - return nil, errors.Wrap(ErrValidation, "destination account must be internal when doing a transfer") - } - case models.TransferInitiationTypePayout: - switch destinationAccount.Type { - case models.AccountTypeExternal, models.AccountTypeExternalFormance: - default: - // account should be external when doing a payout, return an error - return nil, errors.Wrap(ErrValidation, "destination account must be external when doing a payout") - } - } - - id := models.TransferInitiationID{ - Reference: req.Reference, - ConnectorID: connectorID, - } - - // Always insert timestamp as UTC - createdAt := time.Now().UTC() - tf := &models.TransferInitiation{ - ID: id, - CreatedAt: createdAt, - ScheduledAt: req.ScheduledAt, - Description: req.Description, - DestinationAccountID: models.MustAccountIDFromString(req.DestinationAccountID), - ConnectorID: connectorID, - Provider: connectorID.Provider, - Type: transferType, - Amount: req.Amount, - InitialAmount: req.Amount, - Asset: models.Asset(req.Asset), - Metadata: req.Metadata, - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: id, - CreatedAt: createdAt, - Status: status, - }, - }, - } - - if req.SourceAccountID != "" { - sID := models.MustAccountIDFromString(req.SourceAccountID) - tf.SourceAccountID = &sID - } - - if err := s.store.CreateTransferInitiation(ctx, tf); err != nil { - return nil, newStorageError(err, "creating transfer initiation") - } - - if err := s.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - s.messages.NewEventSavedTransferInitiations(tf), - ), - ); err != nil { - return nil, errors.Wrap(ErrPublish, err.Error()) - } - - if status == models.TransferInitiationStatusValidated { - connector, err := s.store.GetConnector(ctx, connectorID) - if err != nil { - return nil, newStorageError(err, "getting connector") - } - - handlers, ok := s.connectorHandlers[connector.Provider] - if !ok { - return nil, errors.Wrap(ErrValidation, fmt.Sprintf("no payment handler for provider %v", connector.Provider)) - } - - err = handlers.InitiatePaymentHandler(ctx, tf) - if err != nil { - switch { - case errors.Is(err, manager.ErrValidation): - return nil, errors.Wrap(ErrValidation, err.Error()) - case errors.Is(err, manager.ErrConnectorNotFound): - return nil, errors.Wrap(ErrValidation, err.Error()) - default: - return nil, err - } - } - } - - return tf, nil -} - -type UpdateTransferInitiationStatusRequest struct { - Status string `json:"status"` -} - -func (r *UpdateTransferInitiationStatusRequest) Validate() error { - if r.Status == "" { - return errors.New("status is required") - } - - return nil -} - -func (s *Service) UpdateTransferInitiationStatus(ctx context.Context, id string, req *UpdateTransferInitiationStatusRequest) error { - status, err := models.TransferInitiationStatusFromString(req.Status) - if err != nil { - return errors.Wrap(ErrValidation, err.Error()) - } - - switch status { - case models.TransferInitiationStatusWaitingForValidation: - return errors.Wrap(ErrValidation, "cannot set back transfer initiation status to waiting for validation") - case models.TransferInitiationStatusFailed, - models.TransferInitiationStatusProcessed, - models.TransferInitiationStatusProcessing: - return errors.Wrap(ErrValidation, "either VALIDATED or REJECTED status can be set") - default: - } - - transferID, err := models.TransferInitiationIDFromString(id) - if err != nil { - return errors.Wrap(ErrInvalidID, err.Error()) - } - - previousTransferInitiation, err := s.store.ReadTransferInitiation(ctx, transferID) - if err != nil { - return newStorageError(err, "reading transfer initiation") - } - - // Check last status - if len(previousTransferInitiation.RelatedAdjustments) == 0 || - previousTransferInitiation.RelatedAdjustments[0].Status != models.TransferInitiationStatusWaitingForValidation { - return errors.Wrap(ErrValidation, "only waiting for validation transfer initiation can be updated") - } - - adjustment := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: transferID, - CreatedAt: time.Now(), - Status: status, - Error: "", - } - - previousTransferInitiation.RelatedAdjustments = append(previousTransferInitiation.RelatedAdjustments, adjustment) - previousTransferInitiation.SortRelatedAdjustments() - - err = s.store.UpdateTransferInitiationPaymentsStatus(ctx, transferID, nil, adjustment) - if err != nil { - return newStorageError(err, "updating transfer initiation payments status") - } - - if err := s.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - s.messages.NewEventSavedTransferInitiations(previousTransferInitiation), - ), - ); err != nil { - return errors.Wrap(ErrPublish, err.Error()) - } - - if status == models.TransferInitiationStatusValidated { - handlers, ok := s.connectorHandlers[previousTransferInitiation.Provider] - if !ok { - return errors.Wrap(ErrValidation, fmt.Sprintf("no payment handler for provider %v", previousTransferInitiation.Provider)) - } - - err = handlers.InitiatePaymentHandler(ctx, previousTransferInitiation) - if err != nil { - switch { - case errors.Is(err, manager.ErrValidation): - return errors.Wrap(ErrValidation, err.Error()) - case errors.Is(err, manager.ErrConnectorNotFound): - return errors.Wrap(ErrValidation, err.Error()) - default: - return err - } - } - } - - return nil -} - -func (s *Service) RetryTransferInitiation(ctx context.Context, id string) error { - transferID, err := models.TransferInitiationIDFromString(id) - if err != nil { - return errors.Wrap(ErrInvalidID, err.Error()) - } - - previousTransferInitiation, err := s.store.ReadTransferInitiation(ctx, transferID) - if err != nil { - return newStorageError(err, "reading transfer initiation") - } - - if len(previousTransferInitiation.RelatedAdjustments) == 0 || - previousTransferInitiation.RelatedAdjustments[0].Status != models.TransferInitiationStatusFailed { - return errors.Wrap(ErrValidation, "only failed transfer initiation can be retried") - } - - adjustment := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: transferID, - CreatedAt: time.Now(), - Status: models.TransferInitiationStatusAskRetried, - Error: "", - Metadata: map[string]string{}, - } - - err = s.store.UpdateTransferInitiationPaymentsStatus(ctx, transferID, nil, adjustment) - if err != nil { - return newStorageError(err, "updating transfer initiation payments status") - } - - if err := s.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - s.messages.NewEventSavedTransferInitiations(previousTransferInitiation), - ), - ); err != nil { - return errors.Wrap(ErrPublish, err.Error()) - } - - handlers, ok := s.connectorHandlers[previousTransferInitiation.Provider] - if !ok { - return errors.Wrap(ErrValidation, fmt.Sprintf("no payment handler for provider %v", previousTransferInitiation.Provider)) - } - - err = handlers.InitiatePaymentHandler(ctx, previousTransferInitiation) - if err != nil { - switch { - case errors.Is(err, manager.ErrValidation): - return errors.Wrap(ErrValidation, err.Error()) - case errors.Is(err, manager.ErrConnectorNotFound): - return errors.Wrap(ErrValidation, err.Error()) - default: - return err - } - } - - return nil -} - -func (s *Service) DeleteTransferInitiation(ctx context.Context, id string) error { - transferID, err := models.TransferInitiationIDFromString(id) - if err != nil { - return errors.Wrap(ErrInvalidID, err.Error()) - } - - tf, err := s.store.ReadTransferInitiation(ctx, transferID) - if err != nil { - return newStorageError(err, "reading transfer initiation") - } - - if len(tf.RelatedAdjustments) == 0 || - tf.RelatedAdjustments[0].Status != models.TransferInitiationStatusWaitingForValidation { - return errors.Wrap(ErrValidation, "only waiting for validation transfer initiation can be deleted") - } - - err = s.store.DeleteTransferInitiation(ctx, transferID) - if err != nil { - return newStorageError(err, "deleting transfer initiation") - } - - if err := s.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - s.messages.NewEventDeleteTransferInitiation(tf.ID), - ), - ); err != nil { - return errors.Wrap(ErrPublish, err.Error()) - } - - return nil -} diff --git a/cmd/connectors/internal/api/service/transfer_initiation_test.go b/cmd/connectors/internal/api/service/transfer_initiation_test.go deleted file mode 100644 index 94354754..00000000 --- a/cmd/connectors/internal/api/service/transfer_initiation_test.go +++ /dev/null @@ -1,715 +0,0 @@ -package service - -import ( - "context" - "math/big" - "testing" - "time" - - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" -) - -func TestCreateTransferInitiation(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *CreateTransferInitiationRequest - expectedTF *models.TransferInitiation - listConnectorLength int - errorPublish bool - errorPaymentHandler error - noPaymentsHandler bool - expectedError error - } - - sourceAccountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorDummyPay.ID, - } - - destinationAccountID := models.AccountID{ - Reference: "acc2", - ConnectorID: connectorDummyPay.ID, - } - - testCases := []testCase{ - { - name: "nominal", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedTF: &models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - DestinationAccountID: destinationAccountID, - SourceAccountID: &sourceAccountID, - ConnectorID: connectorDummyPay.ID, - Provider: models.ConnectorProviderDummyPay, - Type: models.TransferInitiationTypeTransfer, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: models.Asset("EUR/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - }, - }, - { - name: "nominal without description", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedTF: &models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - DestinationAccountID: destinationAccountID, - SourceAccountID: &sourceAccountID, - ConnectorID: connectorDummyPay.ID, - Provider: models.ConnectorProviderDummyPay, - Type: models.TransferInitiationTypeTransfer, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: models.Asset("EUR/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - }, - }, - { - name: "nominal with status changed", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: true, - }, - expectedTF: &models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - DestinationAccountID: destinationAccountID, - SourceAccountID: &sourceAccountID, - ConnectorID: connectorDummyPay.ID, - Provider: models.ConnectorProviderDummyPay, - Type: models.TransferInitiationTypeTransfer, - Amount: big.NewInt(100), - InitialAmount: big.NewInt(100), - Asset: models.Asset("EUR/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorDummyPay.ID, - }, - Status: models.TransferInitiationStatusValidated, - }, - }, - }, - }, - { - name: "transfer with external account as destination", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationExternalAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedError: ErrValidation, - }, - { - name: "payout with internal account as destination", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypePayout.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedError: ErrValidation, - }, - { - name: "invalid connector id", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: "invalid", - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedError: ErrValidation, - }, - { - name: "invalid provider", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - Provider: "invalid", - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedError: ErrValidation, - }, - { - name: "too many connectors list", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - listConnectorLength: 2, - expectedError: ErrValidation, - }, - { - name: "no connectors in connectors list", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - listConnectorLength: 0, - expectedError: ErrValidation, - }, - { - name: "connector not installed", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorBankingCircle.ID.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedError: ErrValidation, - }, - { - name: "error publishing", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - errorPublish: true, - expectedError: ErrPublish, - }, - { - name: "no payments handler found", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: true, - }, - noPaymentsHandler: true, - expectedError: ErrValidation, - }, - { - name: "error in payments handler", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: true, - }, - errorPaymentHandler: manager.ErrValidation, - expectedError: ErrValidation, - }, - { - name: "error in payments handler", - req: &CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Description: "test", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorDummyPay.ID.String(), - Provider: string(models.ConnectorProviderDummyPay), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: true, - }, - errorPaymentHandler: manager.ErrConnectorNotFound, - expectedError: ErrValidation, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - m := &MockPublisher{} - s := &MockStore{} - - var errPublish error - if tc.errorPublish { - errPublish = errors.New("publish error") - } - - var handlers map[models.ConnectorProvider]*ConnectorHandlers - if !tc.noPaymentsHandler { - handlers = map[models.ConnectorProvider]*ConnectorHandlers{ - models.ConnectorProviderDummyPay: { - InitiatePaymentHandler: func(ctx context.Context, transfer *models.TransferInitiation) error { - if tc.errorPaymentHandler != nil { - return tc.errorPaymentHandler - } - - return nil - }, - }, - } - } - service := New(s.WithListConnectorsNB(tc.listConnectorLength), m.WithError(errPublish), messages.NewMessages(""), handlers) - - tf, err := service.CreateTransferInitiation(context.Background(), tc.req) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - tc.expectedTF.CreatedAt = tf.CreatedAt - require.Len(t, tf.RelatedAdjustments, 1) - tc.expectedTF.RelatedAdjustments[0].CreatedAt = tf.RelatedAdjustments[0].CreatedAt - tc.expectedTF.RelatedAdjustments[0].ID = tf.RelatedAdjustments[0].ID - require.Equal(t, tc.expectedTF, tf) - } - }) - } -} - -func TestUpdateTransferInitiationStatus(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - transferID string - req *UpdateTransferInitiationStatusRequest - errorPublish bool - errorPaymentHandler error - noPaymentsHandler bool - expectedError error - } - - tfNotFoundID := models.TransferInitiationID{ - Reference: "not_found", - ConnectorID: connectorDummyPay.ID, - } - - testCases := []testCase{ - { - name: "nominal validated", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferInitiationWaiting.ID.String(), - }, - { - name: "nominal rejected", - req: &UpdateTransferInitiationStatusRequest{ - Status: "REJECTED", - }, - transferID: transferInitiationWaiting.ID.String(), - }, - { - name: "unknown status", - req: &UpdateTransferInitiationStatusRequest{ - Status: "INVALID", - }, - transferID: transferInitiationWaiting.ID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid status waiting for validation", - req: &UpdateTransferInitiationStatusRequest{ - Status: "WAITING_FOR_VALIDATION", - }, - transferID: transferInitiationWaiting.ID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid status failed", - req: &UpdateTransferInitiationStatusRequest{ - Status: "FAILED", - }, - transferID: transferInitiationWaiting.ID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid status processed", - req: &UpdateTransferInitiationStatusRequest{ - Status: "PROCESSED", - }, - transferID: transferInitiationWaiting.ID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid status processing", - req: &UpdateTransferInitiationStatusRequest{ - Status: "PROCESSING", - }, - transferID: transferInitiationWaiting.ID.String(), - expectedError: ErrValidation, - }, - { - name: "invalid transfer id", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: "invalid", - expectedError: ErrInvalidID, - }, - { - name: "transfer not found", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: tfNotFoundID.String(), - expectedError: storage.ErrNotFound, - }, - { - name: "previous transfer with wrong status", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferInitiationFailed.ID.String(), - expectedError: ErrValidation, - }, - { - name: "error publishing", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferInitiationWaiting.ID.String(), - errorPublish: true, - expectedError: ErrPublish, - }, - { - name: "error publishing", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferInitiationWaiting.ID.String(), - noPaymentsHandler: true, - expectedError: ErrValidation, - }, - { - name: "error in payments handler", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferInitiationWaiting.ID.String(), - errorPaymentHandler: manager.ErrValidation, - expectedError: ErrValidation, - }, - { - name: "error in payments handler", - req: &UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferInitiationWaiting.ID.String(), - errorPaymentHandler: manager.ErrConnectorNotFound, - expectedError: ErrValidation, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - m := &MockPublisher{} - - var errPublish error - if tc.errorPublish { - errPublish = errors.New("publish error") - } - - var handlers map[models.ConnectorProvider]*ConnectorHandlers - if !tc.noPaymentsHandler { - handlers = map[models.ConnectorProvider]*ConnectorHandlers{ - models.ConnectorProviderDummyPay: { - InitiatePaymentHandler: func(ctx context.Context, transfer *models.TransferInitiation) error { - if tc.errorPaymentHandler != nil { - return tc.errorPaymentHandler - } - - return nil - }, - }, - } - } - service := New(&MockStore{}, m.WithError(errPublish), messages.NewMessages(""), handlers) - - err := service.UpdateTransferInitiationStatus(context.Background(), tc.transferID, tc.req) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestRetryTransferInitiation(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - transferID string - errorPublish bool - errorPaymentHandler error - noPaymentsHandler bool - expectedError error - } - - testCases := []testCase{ - { - name: "nominal", - transferID: transferInitiationFailed.ID.String(), - }, - { - name: "invalid transfer id", - transferID: "invalid", - expectedError: ErrInvalidID, - }, - { - name: "invalid previous transfer status", - transferID: transferInitiationWaiting.ID.String(), - expectedError: ErrValidation, - }, - { - name: "error publishing", - transferID: transferInitiationFailed.ID.String(), - noPaymentsHandler: true, - expectedError: ErrValidation, - }, - { - name: "error in payments handler", - transferID: transferInitiationFailed.ID.String(), - errorPaymentHandler: manager.ErrValidation, - expectedError: ErrValidation, - }, - { - name: "error in payments handler", - transferID: transferInitiationFailed.ID.String(), - errorPaymentHandler: manager.ErrConnectorNotFound, - expectedError: ErrValidation, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - m := &MockPublisher{} - - var errPublish error - if tc.errorPublish { - errPublish = errors.New("publish error") - } - - var handlers map[models.ConnectorProvider]*ConnectorHandlers - if !tc.noPaymentsHandler { - handlers = map[models.ConnectorProvider]*ConnectorHandlers{ - models.ConnectorProviderDummyPay: { - InitiatePaymentHandler: func(ctx context.Context, transfer *models.TransferInitiation) error { - if tc.errorPaymentHandler != nil { - return tc.errorPaymentHandler - } - - return nil - }, - }, - } - } - service := New(&MockStore{}, m.WithError(errPublish), messages.NewMessages(""), handlers) - - err := service.RetryTransferInitiation(context.Background(), tc.transferID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestDeleteTransferInitiation(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - transferID string - errorPublish bool - expectedError error - } - - testCases := []testCase{ - { - name: "nominal", - transferID: transferInitiationWaiting.ID.String(), - }, - { - name: "invalid transfer id", - transferID: "invalid", - expectedError: ErrInvalidID, - }, - { - name: "invalid previous transfer initiation status", - transferID: transferInitiationFailed.ID.String(), - expectedError: ErrValidation, - }, - { - name: "error publishing", - transferID: transferInitiationWaiting.ID.String(), - errorPublish: true, - expectedError: ErrPublish, - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - m := &MockPublisher{} - - var errPublish error - if tc.errorPublish { - errPublish = errors.New("publish error") - } - - service := New(&MockStore{}, m.WithError(errPublish), messages.NewMessages(""), nil) - - err := service.DeleteTransferInitiation(context.Background(), tc.transferID) - if tc.expectedError != nil { - require.True(t, errors.Is(err, tc.expectedError)) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/cmd/connectors/internal/api/service/transfer_reversal.go b/cmd/connectors/internal/api/service/transfer_reversal.go deleted file mode 100644 index 7bf526e5..00000000 --- a/cmd/connectors/internal/api/service/transfer_reversal.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "fmt" - "math/big" - "time" - - manager "github.com/formancehq/payments/cmd/connectors/internal/api/connectors_manager" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" -) - -type ReverseTransferInitiationRequest struct { - Reference string `json:"reference"` - Description string `json:"description"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Metadata map[string]string `json:"metadata"` -} - -func (r *ReverseTransferInitiationRequest) Validate() error { - if r.Reference == "" { - return errors.New("reference is required") - } - - if r.Amount == nil { - return errors.New("amount is required") - } - - if r.Asset == "" { - return errors.New("asset is required") - } - - return nil -} - -func checkIfReversalIsValid(transfer *models.TransferInitiation, req *ReverseTransferInitiationRequest) error { - finalAmount := new(big.Int) - finalAmount.Sub(transfer.Amount, req.Amount) - switch finalAmount.Cmp(big.NewInt(0)) { - case 0, 1: - // Nothing to do, requested reversed amount if less than or equal to the transfer amount - case -1: - return errors.New("reversed amount is greater than the transfer amount") - } - - if transfer.Type == models.TransferInitiationTypePayout { - return errors.New("payouts cannot be reversed") - } - - foundProcessed := false - for _, adjustment := range transfer.RelatedAdjustments { - if adjustment.Status == models.TransferInitiationStatusProcessed { - foundProcessed = true - break - } - } - - if !foundProcessed { - // transfer was never processed, so we can't reverse it - return errors.New("transfer was never processed") - } - - return nil -} - -func (s *Service) ReverseTransferInitiation(ctx context.Context, transferID string, req *ReverseTransferInitiationRequest) (*models.TransferReversal, error) { - transferInitiationID, err := models.TransferInitiationIDFromString(transferID) - if err != nil { - return nil, ErrInvalidID - } - - transfer, err := s.store.ReadTransferInitiation(ctx, transferInitiationID) - if err != nil { - return nil, newStorageError(err, "fetching transfer initiation") - } - - if err := checkIfReversalIsValid(transfer, req); err != nil { - return nil, errors.Wrap(ErrValidation, err.Error()) - } - - now := time.Now().UTC() - reversal := &models.TransferReversal{ - ID: models.TransferReversalID{ - Reference: req.Reference, - ConnectorID: transfer.ConnectorID, - }, - TransferInitiationID: transferInitiationID, - CreatedAt: now, - UpdatedAt: now, - Description: req.Description, - ConnectorID: transfer.ConnectorID, - Amount: req.Amount, - Asset: models.Asset(req.Asset), - Status: models.TransferReversalStatusProcessing, - Error: "", - Metadata: req.Metadata, - } - - if err := s.store.CreateTransferReversal(ctx, reversal); err != nil { - return nil, newStorageError(err, "creating transfer reversal") - } - - handlers, ok := s.connectorHandlers[transfer.Provider] - if !ok { - return nil, errors.Wrap(ErrValidation, fmt.Sprintf("no reverse payment handler for provider %v", transfer.Provider)) - } - - if err := handlers.ReversePaymentHandler(ctx, reversal); err != nil { - switch { - case errors.Is(err, manager.ErrValidation): - return nil, errors.Wrap(ErrValidation, err.Error()) - case errors.Is(err, manager.ErrConnectorNotFound): - return nil, errors.Wrap(ErrValidation, err.Error()) - default: - return nil, err - } - } - - return nil, nil -} diff --git a/cmd/connectors/internal/api/transfer_initiation.go b/cmd/connectors/internal/api/transfer_initiation.go deleted file mode 100644 index 869f825b..00000000 --- a/cmd/connectors/internal/api/transfer_initiation.go +++ /dev/null @@ -1,206 +0,0 @@ -package api - -import ( - "encoding/json" - "math/big" - "net/http" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -type transferInitiationResponse struct { - ID string `json:"id"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - ScheduledAt time.Time `json:"scheduledAt"` - Description string `json:"description"` - SourceAccountID string `json:"sourceAccountID"` - DestinationAccountID string `json:"destinationAccountID"` - ConnectorID string `json:"connectorID"` - Type string `json:"type"` - Amount *big.Int `json:"amount"` - InitialAmount *big.Int `json:"initialAmount"` - Asset string `json:"asset"` - Status string `json:"status"` - Error string `json:"error"` - Metadata map[string]string `json:"metadata"` -} - -func createTransferInitiationHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "createTransferInitiationHandler") - defer span.End() - - w.Header().Set("Content-Type", "application/json") - - payload := &service.CreateTransferInitiationRequest{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - setSpanAttributesFromRequest(span, payload) - - if err := payload.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - tf, err := b.GetService().CreateTransferInitiation(ctx, payload) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - span.SetAttributes( - attribute.String("transfer.id", tf.ID.String()), - attribute.String("transfer.createdAt", tf.CreatedAt.String()), - attribute.String("connectorID", tf.ConnectorID.String()), - ) - - data := &transferInitiationResponse{ - ID: tf.ID.String(), - Reference: tf.ID.Reference, - CreatedAt: tf.CreatedAt, - ScheduledAt: tf.ScheduledAt, - Description: tf.Description, - SourceAccountID: tf.SourceAccountID.String(), - DestinationAccountID: tf.DestinationAccountID.String(), - ConnectorID: tf.ConnectorID.String(), - Type: tf.Type.String(), - Amount: tf.Amount, - InitialAmount: tf.InitialAmount, - Asset: tf.Asset.String(), - Metadata: tf.Metadata, - } - - if len(tf.RelatedAdjustments) > 0 { - // Take the status and error from the last adjustment - data.Status = tf.RelatedAdjustments[0].Status.String() - data.Error = tf.RelatedAdjustments[0].Error - } - - err = json.NewEncoder(w).Encode(api.BaseResponse[transferInitiationResponse]{ - Data: data, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func updateTransferInitiationStatusHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "updateTransferInitiationStatusHandler") - defer span.End() - - payload := &service.UpdateTransferInitiationStatusRequest{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - span.SetAttributes(attribute.String("request.status", payload.Status)) - - if err := payload.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - transferID, ok := mux.Vars(r)["transferID"] - if !ok { - otel.RecordError(span, errors.New("missing transferID")) - api.BadRequest(w, ErrInvalidID, errors.New("missing transferID")) - return - } - - span.SetAttributes(attribute.String("transfer.id", transferID)) - - if err := b.GetService().UpdateTransferInitiationStatus(ctx, transferID, payload); err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -func retryTransferInitiationHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "retryTransferInitiationHandler") - defer span.End() - - transferID, ok := mux.Vars(r)["transferID"] - if !ok { - otel.RecordError(span, errors.New("missing transferID")) - api.BadRequest(w, ErrInvalidID, errors.New("missing transferID")) - return - } - - span.SetAttributes(attribute.String("transfer.id", transferID)) - - if err := b.GetService().RetryTransferInitiation(ctx, transferID); err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -func deleteTransferInitiationHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "deleteTransferInitiationHandler") - defer span.End() - - transferID, ok := mux.Vars(r)["transferID"] - if !ok { - otel.RecordError(span, errors.New("missing transferID")) - api.BadRequest(w, ErrInvalidID, errors.New("missing transferID")) - return - } - - span.SetAttributes(attribute.String("transfer.id", transferID)) - - if err := b.GetService().DeleteTransferInitiation(ctx, transferID); err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} - -func setSpanAttributesFromRequest(span trace.Span, transfer *service.CreateTransferInitiationRequest) { - span.SetAttributes( - attribute.String("request.reference", transfer.Reference), - attribute.String("request.scheduledAt", transfer.ScheduledAt.String()), - attribute.String("request.description", transfer.Description), - attribute.String("request.sourceAccountID", transfer.SourceAccountID), - attribute.String("request.destinationAccountID", transfer.DestinationAccountID), - attribute.String("request.connectorID", transfer.ConnectorID), - attribute.String("request.provider", transfer.Provider), - attribute.String("request.type", transfer.Type), - attribute.String("request.amount", transfer.Amount.String()), - attribute.String("request.asset", transfer.Asset), - attribute.String("request.validated", transfer.Asset), - ) -} diff --git a/cmd/connectors/internal/api/transfer_initiation_test.go b/cmd/connectors/internal/api/transfer_initiation_test.go deleted file mode 100644 index bfd49906..00000000 --- a/cmd/connectors/internal/api/transfer_initiation_test.go +++ /dev/null @@ -1,762 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "fmt" - "math/big" - "net/http" - "net/http/httptest" - "testing" - "time" - - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestCreateTransferInitiations(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.CreateTransferInitiationRequest - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - sourceAccountID := models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - - destinationAccountID := models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - } - - testCases := []testCase{ - { - name: "nominal", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - Metadata: map[string]string{ - "foo": "bar", - }, - }, - }, - { - name: "nominal without description", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - Metadata: map[string]string{ - "foo": "bar", - }, - }, - }, - { - name: "missing reference", - req: &service.CreateTransferInitiationRequest{ - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing destination account id", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing source account id, should not end in error", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - }, - { - name: "wrong transfer initiation type", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: "invalid", - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing amount", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Asset: "EUR/2", - Validated: false, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "missing asset", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Validated: false, - }, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "no body", - req: nil, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "service error duplicate key", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - serviceError: storage.ErrDuplicateKeyValue, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - serviceError: storage.ErrNotFound, - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - serviceError: service.ErrValidation, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - serviceError: service.ErrInvalidID, - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - serviceError: service.ErrPublish, - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - req: &service.CreateTransferInitiationRequest{ - Reference: "ref1", - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - SourceAccountID: sourceAccountID.String(), - DestinationAccountID: destinationAccountID.String(), - ConnectorID: connectorID.String(), - Provider: models.ConnectorProviderDummyPay.String(), - Type: models.TransferInitiationTypeTransfer.String(), - Amount: big.NewInt(100), - Asset: "EUR/2", - Validated: false, - }, - serviceError: errors.New("some error"), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusOK - } - - createTransferInitiationResponse := models.TransferInitiation{ - ID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: "test_nominal", - Type: models.TransferInitiationTypeTransfer, - SourceAccountID: &sourceAccountID, - DestinationAccountID: destinationAccountID, - Provider: models.ConnectorProviderDummyPay, - ConnectorID: connectorID, - Amount: big.NewInt(100), - Asset: "EUR/2", - Metadata: map[string]string{ - "foo": "bar", - }, - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: uuid.New(), - TransferInitiationID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorID, - }, - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Status: models.TransferInitiationStatusProcessing, - }, - }, - } - - expectedCreateTransferInitiationResponse := &transferInitiationResponse{ - ID: models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: connectorID, - }.String(), - Reference: "ref1", - CreatedAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - ScheduledAt: time.Date(2023, 11, 22, 8, 0, 0, 0, time.UTC), - Description: createTransferInitiationResponse.Description, - SourceAccountID: createTransferInitiationResponse.SourceAccountID.String(), - DestinationAccountID: createTransferInitiationResponse.DestinationAccountID.String(), - ConnectorID: createTransferInitiationResponse.ConnectorID.String(), - Type: createTransferInitiationResponse.Type.String(), - Amount: createTransferInitiationResponse.Amount, - Asset: createTransferInitiationResponse.Asset.String(), - Status: models.TransferInitiationStatusProcessing.String(), - Metadata: createTransferInitiationResponse.Metadata, - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - CreateTransferInitiation(gomock.Any(), testCase.req). - Return(&createTransferInitiationResponse, nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - CreateTransferInitiation(gomock.Any(), testCase.req). - Return(nil, testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), nil, false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, "/transfer-initiations", bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - var resp sharedapi.BaseResponse[transferInitiationResponse] - sharedapi.Decode(t, rec.Body, &resp) - require.Equal(t, expectedCreateTransferInitiationResponse, resp.Data) - } else { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestUpdateTransferInitiationStatus(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - req *service.UpdateTransferInitiationStatusRequest - transferID string - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - transferID := models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - transferID: transferID.String(), - }, - { - name: "missing body", - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrMissingOrInvalidBody, - }, - { - name: "service error duplicate key", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - serviceError: storage.ErrDuplicateKeyValue, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - serviceError: storage.ErrNotFound, - transferID: transferID.String(), - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - serviceError: service.ErrValidation, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - serviceError: service.ErrInvalidID, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - serviceError: service.ErrPublish, - transferID: transferID.String(), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - req: &service.UpdateTransferInitiationStatusRequest{ - Status: "VALIDATED", - }, - serviceError: errors.New("some error"), - transferID: transferID.String(), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - UpdateTransferInitiationStatus(gomock.Any(), testCase.transferID, testCase.req). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - UpdateTransferInitiationStatus(gomock.Any(), testCase.transferID, testCase.req). - Return(testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), nil, false) - - var body []byte - if testCase.req != nil { - var err error - body, err = json.Marshal(testCase.req) - require.NoError(t, err) - } - - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/transfer-initiations/%s/status", testCase.transferID), bytes.NewReader(body)) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestRetryTransferInitiation(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - transferID string - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - transferID := models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - transferID: transferID.String(), - }, - { - name: "service error duplicate key", - serviceError: storage.ErrDuplicateKeyValue, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - serviceError: storage.ErrNotFound, - transferID: transferID.String(), - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - serviceError: service.ErrValidation, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - serviceError: service.ErrInvalidID, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - serviceError: service.ErrPublish, - transferID: transferID.String(), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - serviceError: errors.New("some error"), - transferID: transferID.String(), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - RetryTransferInitiation(gomock.Any(), testCase.transferID). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - RetryTransferInitiation(gomock.Any(), testCase.transferID). - Return(testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), nil, false) - - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/transfer-initiations/%s/retry", testCase.transferID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} - -func TestDeleteTransferInitiation(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - transferID string - expectedStatusCode int - expectedErrorCode string - serviceError error - } - - transferID := models.TransferInitiationID{ - Reference: "ref1", - ConnectorID: models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, - } - - testCases := []testCase{ - { - name: "nominal", - transferID: transferID.String(), - }, - { - name: "service error duplicate key", - serviceError: storage.ErrDuplicateKeyValue, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrUniqueReference, - }, - { - name: "service error not found", - serviceError: storage.ErrNotFound, - transferID: transferID.String(), - expectedStatusCode: http.StatusNotFound, - expectedErrorCode: ErrNotFound, - }, - { - name: "service error validation", - serviceError: service.ErrValidation, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrValidation, - }, - { - name: "service error invalid ID", - serviceError: service.ErrInvalidID, - transferID: transferID.String(), - expectedStatusCode: http.StatusBadRequest, - expectedErrorCode: ErrInvalidID, - }, - { - name: "service error publish", - serviceError: service.ErrPublish, - transferID: transferID.String(), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - { - name: "service error other errors", - serviceError: errors.New("some error"), - transferID: transferID.String(), - expectedStatusCode: http.StatusInternalServerError, - expectedErrorCode: sharedapi.ErrorInternal, - }, - } - - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - - if testCase.expectedStatusCode == 0 { - testCase.expectedStatusCode = http.StatusNoContent - } - - backend, mockService := newServiceTestingBackend(t) - if testCase.expectedStatusCode < 300 && testCase.expectedStatusCode >= 200 { - mockService.EXPECT(). - DeleteTransferInitiation(gomock.Any(), testCase.transferID). - Return(nil) - } - if testCase.serviceError != nil { - mockService.EXPECT(). - DeleteTransferInitiation(gomock.Any(), testCase.transferID). - Return(testCase.serviceError) - } - - router := httpRouter(logging.Testing(), backend, sharedapi.ServiceInfo{}, auth.NewNoAuth(), nil, false) - - req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/transfer-initiations/%s", testCase.transferID), nil) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, testCase.expectedStatusCode, rec.Code) - if testCase.expectedStatusCode >= 300 || testCase.expectedStatusCode < 200 { - err := sharedapi.ErrorResponse{} - sharedapi.Decode(t, rec.Body, &err) - require.EqualValues(t, testCase.expectedErrorCode, err.ErrorCode) - } - }) - } -} diff --git a/cmd/connectors/internal/api/transfer_reversal.go b/cmd/connectors/internal/api/transfer_reversal.go deleted file mode 100644 index b49abbcc..00000000 --- a/cmd/connectors/internal/api/transfer_reversal.go +++ /dev/null @@ -1,49 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/payments/cmd/connectors/internal/api/backend" - "github.com/formancehq/payments/cmd/connectors/internal/api/service" - "github.com/formancehq/payments/internal/otel" - "github.com/gorilla/mux" - "github.com/pkg/errors" -) - -func reverseTransferInitiationHandler(b backend.ServiceBackend) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, span := otel.Tracer().Start(r.Context(), "reverseTransferInitiationHandler") - defer span.End() - - payload := &service.ReverseTransferInitiationRequest{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrMissingOrInvalidBody, err) - return - } - - if err := payload.Validate(); err != nil { - otel.RecordError(span, err) - api.BadRequest(w, ErrValidation, err) - return - } - - transferID, ok := mux.Vars(r)["transferID"] - if !ok { - otel.RecordError(span, errors.New("missing transferID")) - api.BadRequest(w, ErrInvalidID, errors.New("missing transferID")) - return - } - - _, err := b.GetService().ReverseTransferInitiation(ctx, transferID, payload) - if err != nil { - otel.RecordError(span, err) - handleServiceErrors(w, r, err) - return - } - - api.NoContent(w) - } -} diff --git a/cmd/connectors/internal/connectors/adyen/client/accounts.go b/cmd/connectors/internal/connectors/adyen/client/accounts.go deleted file mode 100644 index 3d309b52..00000000 --- a/cmd/connectors/internal/connectors/adyen/client/accounts.go +++ /dev/null @@ -1,30 +0,0 @@ -package client - -import ( - "context" - "fmt" - "time" - - "github.com/adyen/adyen-go-api-library/v7/src/management" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -func (c *Client) GetMerchantAccounts(ctx context.Context, pageNumber, pageSize int32) ([]management.Merchant, error) { - f := connectors.ClientMetrics(ctx, "adyen", "list_merchant_accounts") - now := time.Now() - defer f(ctx, now) - - listMerchantsResponse, raw, err := c.client.Management().AccountMerchantLevelApi.ListMerchantAccounts( - ctx, - c.client.Management().AccountMerchantLevelApi.ListMerchantAccountsInput().PageNumber(pageNumber).PageSize(pageSize), - ) - if err != nil { - return nil, err - } - - if raw.StatusCode >= 300 { - return nil, fmt.Errorf("failed to get merchant accounts: %d", raw.StatusCode) - } - - return listMerchantsResponse.Data, nil -} diff --git a/cmd/connectors/internal/connectors/adyen/client/client.go b/cmd/connectors/internal/connectors/adyen/client/client.go deleted file mode 100644 index e9237b61..00000000 --- a/cmd/connectors/internal/connectors/adyen/client/client.go +++ /dev/null @@ -1,37 +0,0 @@ -package client - -import ( - "github.com/adyen/adyen-go-api-library/v7/src/adyen" - "github.com/adyen/adyen-go-api-library/v7/src/common" - "github.com/formancehq/go-libs/logging" -) - -type Client struct { - client *adyen.APIClient - - HMACKey string - - logger logging.Logger -} - -func NewClient(apiKey, hmacKey, liveEndpointPrefix string, logger logging.Logger) (*Client, error) { - adyenConfig := &common.Config{ - ApiKey: apiKey, - Environment: common.TestEnv, - Debug: true, - } - - if liveEndpointPrefix != "" { - adyenConfig.Environment = common.LiveEnv - adyenConfig.LiveEndpointURLPrefix = liveEndpointPrefix - adyenConfig.Debug = false - } - - client := adyen.NewClient(adyenConfig) - - return &Client{ - client: client, - HMACKey: hmacKey, - logger: logger, - }, nil -} diff --git a/cmd/connectors/internal/connectors/adyen/client/webhooks.go b/cmd/connectors/internal/connectors/adyen/client/webhooks.go deleted file mode 100644 index c51b75aa..00000000 --- a/cmd/connectors/internal/connectors/adyen/client/webhooks.go +++ /dev/null @@ -1,7 +0,0 @@ -package client - -import "github.com/adyen/adyen-go-api-library/v7/src/webhook" - -func (c *Client) CreateWebhookForRequest(req string) (*webhook.Webhook, error) { - return webhook.HandleRequest(req) -} diff --git a/cmd/connectors/internal/connectors/adyen/config.go b/cmd/connectors/internal/connectors/adyen/config.go deleted file mode 100644 index b854af21..00000000 --- a/cmd/connectors/internal/connectors/adyen/config.go +++ /dev/null @@ -1,62 +0,0 @@ -package adyen - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" -) - -const ( - defaultPollingPeriod = 2 * time.Minute -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - APIKey string `json:"apiKey" yaml:"apiKey" bson:"apiKey"` - HMACKey string `json:"hmacKey" yaml:"hmacKey" bson:"hmacKey"` - LiveEndpointPrefix string `json:"liveEndpointPrefix" yaml:"liveEndpointPrefix" bson:"liveEndpointPrefix"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -func (c Config) String() string { - return fmt.Sprintf("liveEndpointPrefix=%s, apiKey=****, hmacKey=****", c.LiveEndpointPrefix) -} - -func (c Config) Validate() error { - if c.APIKey == "" { - return ErrMissingAPIKey - } - - if c.Name == "" { - return ErrMissingName - } - - if c.HMACKey == "" { - return ErrMissingHMACKey - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("hmacKey", configtemplate.TypeString, "", true) - cfg.AddParameter("liveEndpointPrefix", configtemplate.TypeString, "", false) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/adyen/connector.go b/cmd/connectors/internal/connectors/adyen/connector.go deleted file mode 100644 index 353ad192..00000000 --- a/cmd/connectors/internal/connectors/adyen/connector.go +++ /dev/null @@ -1,112 +0,0 @@ -package adyen - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const name = models.ConnectorProviderAdyen - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch users and transactions", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - // Restart the main task to use the new polling period. - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.logger, c.cfg)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/adyen/currencies.go b/cmd/connectors/internal/connectors/adyen/currencies.go deleted file mode 100644 index 31e5bef7..00000000 --- a/cmd/connectors/internal/connectors/adyen/currencies.go +++ /dev/null @@ -1,7 +0,0 @@ -package adyen - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - supportedCurrenciesWithDecimal = currency.ISO4217Currencies -) diff --git a/cmd/connectors/internal/connectors/adyen/errors.go b/cmd/connectors/internal/connectors/adyen/errors.go deleted file mode 100644 index 2b27cc22..00000000 --- a/cmd/connectors/internal/connectors/adyen/errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package adyen - -import "errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingAPIKey is returned when the apiKey is missing. - ErrMissingAPIKey = errors.New("missing apiKey from config") - - // ErrMissingEndpoint is returned when the endpoint is missing. - ErrMissingLiveEndpointPrefix = errors.New("missing live endpoint prefix from config") - - // ErrMissingName is returned when the name is missing. - ErrMissingName = errors.New("missing name from config") - - // ErrMissingHMACKey is returned when the hmacKey is missing. - ErrMissingHMACKey = errors.New("missing hmacKey from config") -) diff --git a/cmd/connectors/internal/connectors/adyen/loader.go b/cmd/connectors/internal/connectors/adyen/loader.go deleted file mode 100644 index ab284974..00000000 --- a/cmd/connectors/internal/connectors/adyen/loader.go +++ /dev/null @@ -1,54 +0,0 @@ -package adyen - -import ( - "net/http" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // storage is not used in this connector - - r := mux.NewRouter() - - r.Path("/").Methods(http.MethodPost).Handler(handleStandardWebhooks()) - - return r -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/adyen/task_fetch_merchants_accounts.go b/cmd/connectors/internal/connectors/adyen/task_fetch_merchants_accounts.go deleted file mode 100644 index c96cc11a..00000000 --- a/cmd/connectors/internal/connectors/adyen/task_fetch_merchants_accounts.go +++ /dev/null @@ -1,114 +0,0 @@ -package adyen - -import ( - "context" - "encoding/json" - "time" - - "github.com/adyen/adyen-go-api-library/v7/src/management" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/adyen/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -const ( - pageSize = 100 -) - -func taskFetchAccounts(client *client.Client) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "adyen.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - if err := fetchAccounts(ctx, client, connectorID, ingester, scheduler); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchAccounts( - ctx context.Context, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, -) error { - for page := 1; ; page++ { - pagedAccounts, err := client.GetMerchantAccounts(ctx, int32(page), pageSize) - if err != nil { - return err - } - - if err := ingestAccountsBatch(ctx, connectorID, ingester, pagedAccounts); err != nil { - return err - } - - if len(pagedAccounts) < pageSize { - break - } - } - - return nil -} - -func ingestAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accounts []management.Merchant, -) error { - if len(accounts) == 0 { - return nil - } - - batch := ingestion.AccountBatch{} - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - a := &models.Account{ - ID: models.AccountID{ - Reference: *account.Id, - ConnectorID: connectorID, - }, - // Moneycorp does not send the opening date of the account - CreatedAt: time.Now(), - Reference: *account.Id, - ConnectorID: connectorID, - Type: models.AccountTypeInternal, - RawData: raw, - } - - if account.Name != nil { - a.AccountName = *account.Name - } - - batch = append(batch, a) - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/adyen/task_main.go b/cmd/connectors/internal/connectors/adyen/task_main.go deleted file mode 100644 index 54a1e514..00000000 --- a/cmd/connectors/internal/connectors/adyen/task_main.go +++ /dev/null @@ -1,49 +0,0 @@ -package adyen - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "adyen.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/adyen/task_resolve.go b/cmd/connectors/internal/connectors/adyen/task_resolve.go deleted file mode 100644 index e95385f2..00000000 --- a/cmd/connectors/internal/connectors/adyen/task_resolve.go +++ /dev/null @@ -1,57 +0,0 @@ -package adyen - -import ( - "fmt" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/adyen/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/google/uuid" -) - -const ( - taskNameMain = "main" - taskNameFetchAccounts = "fetch-accounts" - taskNameHandleWebhook = "handle-webhook" -) - -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - PollingPeriod int `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` - WebhookID uuid.UUID `json:"webhookId" yaml:"webhookId" bson:"webhookId"` -} - -func resolveTasks(logger logging.Logger, config Config) func(taskDefinition TaskDescriptor) task.Task { - adyenClient, err := client.NewClient( - config.APIKey, - config.HMACKey, - config.LiveEndpointPrefix, - logger, - ) - if err != nil { - logger.Error(err) - - return func(taskDescriptor TaskDescriptor) task.Task { - return func() error { - return fmt.Errorf("cannot build adyen client: %w", err) - } - } - } - - return func(taskDescriptor TaskDescriptor) task.Task { - switch taskDescriptor.Key { - case taskNameMain: - return taskMain() - case taskNameFetchAccounts: - return taskFetchAccounts(adyenClient) - case taskNameHandleWebhook: - return taskHandleStandardWebhooks(adyenClient, taskDescriptor.WebhookID) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDescriptor.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/adyen/task_standard_webhooks.go b/cmd/connectors/internal/connectors/adyen/task_standard_webhooks.go deleted file mode 100644 index 4f80f509..00000000 --- a/cmd/connectors/internal/connectors/adyen/task_standard_webhooks.go +++ /dev/null @@ -1,619 +0,0 @@ -package adyen - -import ( - "context" - "encoding/json" - "errors" - "math/big" - "net/http" - "strings" - - "github.com/adyen/adyen-go-api-library/v7/src/hmacvalidator" - "github.com/adyen/adyen-go-api-library/v7/src/webhook" - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/adyen/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -func handleStandardWebhooks() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - connectorContext := task.ConnectorContextFromContext(r.Context()) - webhookID := connectors.WebhookIDFromContext(r.Context()) - span := trace.SpanFromContext(r.Context()) - - // Detach the context since we're launching an async task and we're mostly - // coming from a HTTP request. - detachedCtx, _ := contextutil.Detached(r.Context()) - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "handle webhook", - Key: taskNameHandleWebhook, - WebhookID: webhookID, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - err = connectorContext.Scheduler().Schedule(detachedCtx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - w.WriteHeader(http.StatusOK) - w.Write([]byte("[accepted]")) - } -} - -func taskHandleStandardWebhooks(client *client.Client, webhookID uuid.UUID) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "adyen.taskHandleStandardWebhooks", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("webhookID", webhookID.String()), - ) - defer span.End() - - w, err := storageReader.GetWebhook(ctx, webhookID) - if err != nil { - otel.RecordError(span, err) - return err - } - - webhooks, err := client.CreateWebhookForRequest(string(w.RequestBody)) - if err != nil { - otel.RecordError(span, err) - return err - } - - for _, item := range *webhooks.NotificationItems { - if !hmacvalidator.ValidateHmac(item.NotificationRequestItem, client.HMACKey) { - // Record error without setting the status to error since we - // continue the execution. - span.RecordError(err) - continue - } - - if err := handleNotificationRequestItem( - ctx, - connectorID, - storageReader, - ingester, - item.NotificationRequestItem, - ); err != nil { - otel.RecordError(span, err) - return err - } - } - - return nil - } -} - -func handleNotificationRequestItem( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - switch item.EventCode { - case webhook.EventCodeAuthorisation: - return handleAuthorisation(ctx, connectorID, ingester, item) - case webhook.EventCodeAuthorisationAdjustment: - return handleAuthorisationAdjustment(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodeCancellation: - return handleCancellation(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodeCapture: - return handleCapture(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodeCaptureFailed: - return handleCaptureFailed(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodeRefund: - return handleRefund(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodeRefundFailed: - return handleRefundFailed() - case webhook.EventCodeRefundedReversed: - return handleRefundedReversed(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodeRefundWithData: - return handleRefundWithData(ctx, connectorID, storageReader, ingester, item) - case webhook.EventCodePayoutThirdparty: - return handlePayoutThirdparty(ctx, connectorID, ingester, item) - case webhook.EventCodePayoutDecline: - return handlePayoutDecline(ctx, connectorID, ingester, item) - case webhook.EventCodePayoutExpire: - return handlePayoutExpire(ctx, connectorID, ingester, item) - } - - return nil -} - -func handleAuthorisation( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - raw, err := json.Marshal(item) - if err != nil { - return err - } - - status := models.PaymentStatusPending - if item.Success == "false" { - status = models.PaymentStatusFailed - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.PspReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: *item.EventDate, - Reference: item.PspReference, - Amount: big.NewInt(item.Amount.Value), - InitialAmount: big.NewInt(item.Amount.Value), - Type: models.PaymentTypePayIn, - Status: status, - Scheme: parseScheme(item.PaymentMethod), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), - RawData: raw, - DestinationAccountID: &models.AccountID{ - Reference: item.MerchantAccountCode, - ConnectorID: connectorID, - }, - } - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - - return nil -} - -func handleAuthorisationAdjustment( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.OriginalReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Amount = big.NewInt(item.Amount.Value) - payment.InitialAmount = big.NewInt(item.Amount.Value) - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handleCancellation( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.OriginalReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Status = models.PaymentStatusCancelled - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handleCapture( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.OriginalReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Status = models.PaymentStatusSucceeded - payment.Amount = big.NewInt(item.Amount.Value) - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handleCaptureFailed( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.OriginalReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Status = models.PaymentStatusFailed - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handleRefund( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.OriginalReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Amount = payment.Amount.Sub(payment.Amount, big.NewInt(item.Amount.Value)) - if payment.Amount.Cmp(big.NewInt(0)) == 0 { - payment.Status = models.PaymentStatusRefunded - } - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handleRefundFailed() error { - // Nothing to do for now (while waiting to enhance the payment adjustment model) - return nil -} - -func handleRefundedReversed( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.PspReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Amount = payment.Amount.Add(payment.Amount, big.NewInt(item.Amount.Value)) - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handleRefundWithData( - ctx context.Context, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success == "true" { - payment, err := storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.OriginalReference, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Amount = payment.Amount.Sub(payment.Amount, big.NewInt(item.Amount.Value)) - if payment.Amount.Cmp(big.NewInt(0)) == 0 { - payment.Status = models.PaymentStatusRefunded - } - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - } - - return nil -} - -func handlePayoutThirdparty( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - raw, err := json.Marshal(item) - if err != nil { - return err - } - - status := models.PaymentStatusSucceeded - if item.Success == "false" { - status = models.PaymentStatusFailed - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.PspReference, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: *item.EventDate, - Reference: item.PspReference, - Amount: big.NewInt(item.Amount.Value), - InitialAmount: big.NewInt(item.Amount.Value), - Type: models.PaymentTypePayOut, - Status: status, - Scheme: parseScheme(item.PaymentMethod), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), - RawData: raw, - SourceAccountID: &models.AccountID{ - Reference: item.MerchantAccountCode, - ConnectorID: connectorID, - }, - } - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - - return nil -} - -func handlePayoutDecline( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success != "true" { - return nil - } - - raw, err := json.Marshal(item) - if err != nil { - return err - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.PspReference, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: *item.EventDate, - Reference: item.PspReference, - Amount: big.NewInt(item.Amount.Value), - InitialAmount: big.NewInt(item.Amount.Value), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusCancelled, - Scheme: parseScheme(item.PaymentMethod), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), - RawData: raw, - SourceAccountID: &models.AccountID{ - Reference: item.MerchantAccountCode, - ConnectorID: connectorID, - }, - } - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - - return nil -} - -func handlePayoutExpire( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - item webhook.NotificationRequestItem, -) error { - if item.Success != "true" { - return nil - } - - raw, err := json.Marshal(item) - if err != nil { - return err - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: item.PspReference, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: *item.EventDate, - Reference: item.PspReference, - Amount: big.NewInt(item.Amount.Value), - InitialAmount: big.NewInt(item.Amount.Value), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusExpired, - Scheme: parseScheme(item.PaymentMethod), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), - RawData: raw, - SourceAccountID: &models.AccountID{ - Reference: item.MerchantAccountCode, - ConnectorID: connectorID, - }, - } - - if err := ingester.IngestPayments( - ctx, - ingestion.PaymentBatch{{Payment: payment}}, - ); err != nil { - return err - } - - return nil -} - -func parseScheme(scheme string) models.PaymentScheme { - switch { - case strings.HasPrefix(scheme, "visa"): - return models.PaymentSchemeCardVisa - case strings.HasPrefix(scheme, "electron"): - return models.PaymentSchemeCardVisa - case strings.HasPrefix(scheme, "amex"): - return models.PaymentSchemeCardAmex - case strings.HasPrefix(scheme, "alipay"): - return models.PaymentSchemeCardAlipay - case strings.HasPrefix(scheme, "cup"): - return models.PaymentSchemeCardCUP - case strings.HasPrefix(scheme, "discover"): - return models.PaymentSchemeCardDiscover - case strings.HasPrefix(scheme, "doku"): - return models.PaymentSchemeDOKU - case strings.HasPrefix(scheme, "dragonpay"): - return models.PaymentSchemeDragonPay - case strings.HasPrefix(scheme, "jcb"): - return models.PaymentSchemeCardJCB - case strings.HasPrefix(scheme, "maestro"): - return models.PaymentSchemeMaestro - case strings.HasPrefix(scheme, "mc"): - return models.PaymentSchemeCardMasterCard - case strings.HasPrefix(scheme, "molpay"): - return models.PaymentSchemeMolPay - case strings.HasPrefix(scheme, "diners"): - return models.PaymentSchemeCardDiners - default: - return models.PaymentSchemeUnknown - } -} diff --git a/cmd/connectors/internal/connectors/atlar/Insomnium.json b/cmd/connectors/internal/connectors/atlar/Insomnium.json deleted file mode 100644 index 65c54cc3..00000000 --- a/cmd/connectors/internal/connectors/atlar/Insomnium.json +++ /dev/null @@ -1 +0,0 @@ -{"_type":"export","__export_format":4,"__export_date":"2023-11-28T15:46:25.856Z","__export_source":"insomnia.desktop.app:v0.2.3","resources":[{"_id":"req_e9545a9d7ffd4e44b982e2ddb8b15e83","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756567874,"created":1700665329960,"url":"{{ _.baseUrlApi }}/_info","name":"Info","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700665329960,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"wrk_9ca49e64d481426cb9e1831e73b552ba","parentId":null,"modified":1700663660087,"created":1700663635161,"name":"Local Formance Payments Atlar","description":"","scope":"collection","_type":"workspace"},{"_id":"req_05b0176074ef4c37a7a1be0926635e79","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700732400128,"created":1700732368940,"url":"{{ _.baseUrl }}/connectors/configs","name":"List the configs of each available connector","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700664496984.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"req_545f3ec751574e399e31b9ff899cf77d","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756760593,"created":1700755772447,"url":"{{ _.baseUrlApi }}/accounts","name":"List accounts","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700664392862.5625,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"req_c133874ab7094fcf85ecdc255b3f138b","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1701094404853,"created":1700755638397,"url":"{{ _.baseUrlApi }}/payments","name":"List payments","description":"","method":"GET","body":{},"parameters":[{"id":"pair_629ff5e5c1be4c54a90b8664ea656b0a","name":"","value":"","description":""}],"headers":[{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700664288740.625,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"req_6088705ca6e44f9693b07dea3d0aad11","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756584625,"created":1700745901467,"url":"{{ _.baseUrlConnectors }}/connectors","name":"List all installed connectors","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700664080496.75,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"req_4da5e386b56141889cb7769e8846f3cd","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756591790,"created":1700746001449,"url":"{{ _.baseUrlConnectors }}/connectors/configs","name":"List the configs of each available connector","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700663872252.875,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"req_a20a27621407489cb8998b4e3238af6e","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756596678,"created":1700663664009,"url":"{{ _.baseUrlConnectors }}/connectors/atlar","name":"Install Atlar Connector","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Test\",\n\t\"pollingPeriod\": \"10s\",\n\t\"baseUrl\": \"https://api.atlar.com\",\n\t\"accessKey\": \"{{ _.atlar_accessKey }}\",\n\t\"secret\": \"{{ _.atlar_secret }}\"\n}\n"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"},{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700663664009,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[],"_type":"request"},{"_id":"req_a678810335464e0bba10f57ee3bc9f95","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756602079,"created":1700743647460,"url":"{{ _.baseUrlConnectors }}/connectors/atlar/:connectorID/reset","name":"Reset Atlar Connector","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Test\",\n\t\"pollingPeriod\": \"10s\",\n\t\"baseUrl\": \"https://api.atlar.com\",\n\t\"accessKey\": \"{{ _.atlar_accessKey }}\",\n\t\"secret\": \"{{ _.atlar_secret }}\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"},{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700534131609,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[{"name":"connectorID","value":"{% response 'body', 'req_a20a27621407489cb8998b4e3238af6e', 'b64::JC5kYXRhLmNvbm5lY3RvcklE::46b', 'never', 60 %}","disabled":false,"id":"pair_519cdcc16fad41d8afccf27bd428eb3aending0","fileName":""}],"_type":"request"},{"_id":"req_4419fe631b61412d9ecbb80ea3ecd29c","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756607072,"created":1700736368735,"url":"{{ _.baseUrlConnectors }}/connectors/atlar/:connectorID","name":"Uninstall Atlar Connector","description":"","method":"DELETE","body":{"mimeType":"application/json","text":""},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"},{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700501748509,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[{"name":"connectorID","value":"{% response 'body', 'req_a20a27621407489cb8998b4e3238af6e', 'b64::JC5kYXRhLmNvbm5lY3RvcklE::46b', 'never', 60 %}","disabled":false,"id":"pair_519cdcc16fad41d8afccf27bd428eb3aending0","fileName":""}],"_type":"request"},{"_id":"req_e8c035af9b6e48fda8410b089764c8f6","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756613381,"created":1700747923012,"url":"{{ _.baseUrlConnectors }}/connectors/atlar/:connectorID/tasks","name":"List tasks from a connector","description":"","method":"GET","body":{"mimeType":"application/json","text":""},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"},{"name":"User-Agent","value":"insomnium/0.2.3"}],"authentication":{},"metaSortKey":-1700469365409,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","segmentParams":[{"name":"connectorID","value":"{% response 'body', 'req_a20a27621407489cb8998b4e3238af6e', 'b64::JC5kYXRhLmNvbm5lY3RvcklE::46b', 'never', 60 %}","disabled":false,"id":"pair_519cdcc16fad41d8afccf27bd428eb3aending0","fileName":""}],"_type":"request"},{"_id":"env_90638f53ef3039b5d60b8dfc5744952b4e2de8c7","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700756537786,"created":1700663635163,"name":"Base Environment","data":{"baseUrlApi":"http://localhost:8080","baseUrlConnectors":"http://localhost:8081","atlar_accessKey":"","atlar_secret":""},"dataPropertyOrder":{"&":["baseUrlApi","baseUrlConnectors","atlar_accessKey","atlar_secret"]},"color":null,"isPrivate":false,"metaSortKey":1700663635163,"_type":"environment"},{"_id":"jar_90638f53ef3039b5d60b8dfc5744952b4e2de8c7","parentId":"wrk_9ca49e64d481426cb9e1831e73b552ba","modified":1700663635163,"created":1700663635163,"name":"Default Jar","cookies":[],"_type":"cookie_jar"}]} \ No newline at end of file diff --git a/cmd/connectors/internal/connectors/atlar/account_utils.go b/cmd/connectors/internal/connectors/atlar/account_utils.go deleted file mode 100644 index 82e2fbef..00000000 --- a/cmd/connectors/internal/connectors/atlar/account_utils.go +++ /dev/null @@ -1,93 +0,0 @@ -package atlar - -import ( - "encoding/json" - "fmt" - - "github.com/formancehq/go-libs/metadata" - "github.com/formancehq/payments/internal/models" - atlar_models "github.com/get-momo/atlar-v1-go-client/models" -) - -type AtlarExternalAccountAndCounterparty struct { - ExternalAccount atlar_models.ExternalAccount `json:"externalAccount" yaml:"externalAccount" bson:"externalAccount"` - Counterparty atlar_models.Counterparty `json:"counterparty" yaml:"counterparty" bson:"counterparty"` -} - -func ExternalAccountFromAtlarData( - connectorID models.ConnectorID, - externalAccount *atlar_models.ExternalAccount, - counterparty *atlar_models.Counterparty, -) (*models.Account, error) { - raw, err := json.Marshal(AtlarExternalAccountAndCounterparty{ExternalAccount: *externalAccount, Counterparty: *counterparty}) - if err != nil { - return nil, err - } - - createdAt, err := ParseAtlarTimestamp(externalAccount.Created) - if err != nil { - return nil, fmt.Errorf("failed to parse opening date: %w", err) - } - - return &models.Account{ - ID: models.AccountID{ - Reference: externalAccount.ID, - ConnectorID: connectorID, - }, - CreatedAt: createdAt, - Reference: externalAccount.ID, - ConnectorID: connectorID, - // DefaultAsset: left empty because the information is not provided by Atlar, - AccountName: counterparty.Name, // TODO: is that okay? External accounts do not have a name at Atlar. - Type: models.AccountTypeExternal, - Metadata: extractExternalAccountAndCounterpartyMetadata(externalAccount, counterparty), - RawData: raw, - }, nil -} - -func ExtractAccountMetadata(account *atlar_models.Account, bank *atlar_models.ThirdParty) metadata.Metadata { - result := metadata.Metadata{} - result = result.Merge(ComputeAccountMetadataBool("fictive", account.Fictive)) - result = result.Merge(ComputeAccountMetadata("bank/id", bank.ID)) - result = result.Merge(ComputeAccountMetadata("bank/name", bank.Name)) - result = result.Merge(ComputeAccountMetadata("bank/bic", account.Bank.Bic)) - result = result.Merge(IdentifiersToMetadata(account.Identifiers)) - result = result.Merge(ComputeAccountMetadata("alias", account.Alias)) - result = result.Merge(ComputeAccountMetadata("owner/name", account.Owner.Name)) - return result -} - -func IdentifiersToMetadata(identifiers []*atlar_models.AccountIdentifier) metadata.Metadata { - result := metadata.Metadata{} - for _, i := range identifiers { - result = result.Merge(ComputeAccountMetadata( - fmt.Sprintf("identifier/%s/%s", *i.Market, *i.Type), - *i.Number, - )) - if *i.Type == "IBAN" { - result = result.Merge(ComputeAccountMetadata( - fmt.Sprintf("identifier/%s", *i.Type), - *i.Number, - )) - } - } - return result -} - -func extractExternalAccountAndCounterpartyMetadata(externalAccount *atlar_models.ExternalAccount, counterparty *atlar_models.Counterparty) metadata.Metadata { - result := metadata.Metadata{} - result = result.Merge(ComputeAccountMetadata("bank/id", externalAccount.Bank.ID)) - result = result.Merge(ComputeAccountMetadata("bank/name", externalAccount.Bank.Name)) - result = result.Merge(ComputeAccountMetadata("bank/bic", externalAccount.Bank.Bic)) - result = result.Merge(IdentifiersToMetadata(externalAccount.Identifiers)) - result = result.Merge(ComputeAccountMetadata("owner/name", counterparty.Name)) - result = result.Merge(ComputeAccountMetadata("owner/type", counterparty.PartyType)) - result = result.Merge(ComputeAccountMetadata("owner/contact/email", counterparty.ContactDetails.Email)) - result = result.Merge(ComputeAccountMetadata("owner/contact/phone", counterparty.ContactDetails.Phone)) - result = result.Merge(ComputeAccountMetadata("owner/contact/address/streetName", counterparty.ContactDetails.Address.StreetName)) - result = result.Merge(ComputeAccountMetadata("owner/contact/address/streetNumber", counterparty.ContactDetails.Address.StreetNumber)) - result = result.Merge(ComputeAccountMetadata("owner/contact/address/city", counterparty.ContactDetails.Address.City)) - result = result.Merge(ComputeAccountMetadata("owner/contact/address/postalCode", counterparty.ContactDetails.Address.PostalCode)) - result = result.Merge(ComputeAccountMetadata("owner/contact/address/country", counterparty.ContactDetails.Address.Country)) - return result -} diff --git a/cmd/connectors/internal/connectors/atlar/client/accounts.go b/cmd/connectors/internal/connectors/atlar/client/accounts.go deleted file mode 100644 index 07ee30f4..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/accounts.go +++ /dev/null @@ -1,36 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/get-momo/atlar-v1-go-client/client/accounts" -) - -func (c *Client) GetV1AccountsID(ctx context.Context, id string) (*accounts.GetV1AccountsIDOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "list_accounts") - now := time.Now() - defer f(ctx, now) - - accountsParams := accounts.GetV1AccountsIDParams{ - Context: ctx, - ID: id, - } - - return c.client.Accounts.GetV1AccountsID(&accountsParams) -} - -func (c *Client) GetV1Accounts(ctx context.Context, token string, pageSize int64) (*accounts.GetV1AccountsOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "list_accounts") - now := time.Now() - defer f(ctx, now) - - accountsParams := accounts.GetV1AccountsParams{ - Limit: &pageSize, - Context: ctx, - Token: &token, - } - - return c.client.Accounts.GetV1Accounts(&accountsParams) -} diff --git a/cmd/connectors/internal/connectors/atlar/client/client.go b/cmd/connectors/internal/connectors/atlar/client/client.go deleted file mode 100644 index 887a3321..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/client.go +++ /dev/null @@ -1,33 +0,0 @@ -package client - -import ( - "net/url" - - "github.com/go-openapi/strfmt" - - atlar_client "github.com/get-momo/atlar-v1-go-client/client" - - httptransport "github.com/go-openapi/runtime/client" -) - -type Client struct { - client *atlar_client.Rest -} - -func NewClient(baseURL url.URL, accessKey, secret string) *Client { - return &Client{ - client: createAtlarClient(baseURL, accessKey, secret), - } -} - -func createAtlarClient(baseURL url.URL, accessKey, secret string) *atlar_client.Rest { - transport := httptransport.New( - baseURL.Host, - baseURL.Path, - []string{baseURL.Scheme}, - ) - basicAuth := httptransport.BasicAuth(accessKey, secret) - transport.DefaultAuthentication = basicAuth - client := atlar_client.New(transport, strfmt.Default) - return client -} diff --git a/cmd/connectors/internal/connectors/atlar/client/counter_parties.go b/cmd/connectors/internal/connectors/atlar/client/counter_parties.go deleted file mode 100644 index 0ac1b199..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/counter_parties.go +++ /dev/null @@ -1,110 +0,0 @@ -package client - -import ( - "context" - "errors" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/internal/models" - "github.com/get-momo/atlar-v1-go-client/client/counterparties" - atlar_models "github.com/get-momo/atlar-v1-go-client/models" -) - -func (c *Client) GetV1CounterpartiesID(ctx context.Context, counterPartyID string) (*counterparties.GetV1CounterpartiesIDOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "get_counterparty") - now := time.Now() - defer f(ctx, now) - - getCounterpartyParams := counterparties.GetV1CounterpartiesIDParams{ - Context: ctx, - ID: counterPartyID, - } - counterpartyResponse, err := c.client.Counterparties.GetV1CounterpartiesID(&getCounterpartyParams) - if err != nil { - return nil, err - } - - return counterpartyResponse, nil -} - -func (c *Client) CreateCounterParty(ctx context.Context, newExternalBankAccount *models.BankAccount) (*string, error) { - f := connectors.ClientMetrics(ctx, "atlar", "create_counterparty") - now := time.Now() - defer f(ctx, now) - - // TODO: make sure an account with that IBAN does not already exist (Atlar API v2 needed, v1 lacks the filters) - // alternatively we could query the local DB - - createCounterpartyRequest := atlar_models.CreateCounterpartyRequest{ - Name: ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/name"), - PartyType: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/type"), - ContactDetails: &atlar_models.ContactDetails{ - Email: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/email"), - Phone: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/phone"), - Address: &atlar_models.Address{ - StreetName: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/streetName"), - StreetNumber: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/streetNumber"), - City: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/city"), - PostalCode: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/postalCode"), - Country: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/country"), - }, - }, - ExternalAccounts: []*atlar_models.CreateEmbeddedExternalAccountRequest{ - { - // ExternalID could cause problems when synchronizing with Accounts[type=external] - Bank: &atlar_models.UpdatableBank{ - Bic: newExternalBankAccount.SwiftBicCode, - }, - Identifiers: extractAtlarAccountIdentifiersFromBankAccount(newExternalBankAccount), - }, - }, - } - postCounterpartiesParams := counterparties.PostV1CounterpartiesParams{ - Context: ctx, - Counterparty: &createCounterpartyRequest, - } - postCounterpartiesResponse, err := c.client.Counterparties.PostV1Counterparties(&postCounterpartiesParams) - if err != nil { - return nil, err - } - - if len(postCounterpartiesResponse.Payload.ExternalAccounts) != 1 { - // should never occur, but when in case it happens it's nice to have an error to search for - return nil, errors.New("counterparty was not created with exactly one account") - } - - externalAccountID := postCounterpartiesResponse.Payload.ExternalAccounts[0].ID - - return &externalAccountID, nil -} - -func extractAtlarAccountIdentifiersFromBankAccount(bankAccount *models.BankAccount) []*atlar_models.AccountIdentifier { - ownerName := bankAccount.Metadata[atlarMetadataSpecNamespace+"owner/name"] - ibanType := "IBAN" - accountIdentifiers := []*atlar_models.AccountIdentifier{{ - HolderName: &ownerName, - Market: &bankAccount.Country, - Type: &ibanType, - Number: &bankAccount.IBAN, - }} - for k := range bankAccount.Metadata { - // check whether the key has format com.atlar.spec/identifier// - identifierData, err := metadataToIdentifierData(k, bankAccount.Metadata[k]) - if err != nil { - // matadata does not describe an identifier - continue - } - if identifierData.Market == bankAccount.Country && identifierData.Type == "IBAN" { - // avoid duplicate identifiers - continue - } - accountIdentifiers = append(accountIdentifiers, &atlar_models.AccountIdentifier{ - HolderName: &ownerName, - Market: &identifierData.Market, - Type: &identifierData.Type, - Number: &identifierData.Number, - }) - } - return accountIdentifiers -} diff --git a/cmd/connectors/internal/connectors/atlar/client/external_accounts.go b/cmd/connectors/internal/connectors/atlar/client/external_accounts.go deleted file mode 100644 index fab92c05..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/external_accounts.go +++ /dev/null @@ -1,41 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/get-momo/atlar-v1-go-client/client/external_accounts" -) - -func (c *Client) GetV1ExternalAccountsID(ctx context.Context, externalAccountID string) (*external_accounts.GetV1ExternalAccountsIDOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "get_external_account") - now := time.Now() - defer f(ctx, now) - - getExternalAccountParams := external_accounts.GetV1ExternalAccountsIDParams{ - Context: ctx, - ID: externalAccountID, - } - - externalAccountResponse, err := c.client.ExternalAccounts.GetV1ExternalAccountsID(&getExternalAccountParams) - if err != nil { - return nil, err - } - - return externalAccountResponse, nil -} - -func (c *Client) GetV1ExternalAccounts(ctx context.Context, token string, pageSize int64) (*external_accounts.GetV1ExternalAccountsOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "list_external_accounts") - now := time.Now() - defer f(ctx, now) - - externalAccountsParams := external_accounts.GetV1ExternalAccountsParams{ - Limit: &pageSize, - Context: ctx, - Token: &token, - } - - return c.client.ExternalAccounts.GetV1ExternalAccounts(&externalAccountsParams) -} diff --git a/cmd/connectors/internal/connectors/atlar/client/third_parties.go b/cmd/connectors/internal/connectors/atlar/client/third_parties.go deleted file mode 100644 index 8a04ce02..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/third_parties.go +++ /dev/null @@ -1,22 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/get-momo/atlar-v1-go-client/client/third_parties" -) - -func (c *Client) GetV1BetaThirdPartiesID(ctx context.Context, id string) (*third_parties.GetV1betaThirdPartiesIDOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "list_third_parties") - now := time.Now() - defer f(ctx, now) - - params := third_parties.GetV1betaThirdPartiesIDParams{ - Context: ctx, - ID: id, - } - - return c.client.ThirdParties.GetV1betaThirdPartiesID(¶ms) -} diff --git a/cmd/connectors/internal/connectors/atlar/client/transactions.go b/cmd/connectors/internal/connectors/atlar/client/transactions.go deleted file mode 100644 index 733d6c1f..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/transactions.go +++ /dev/null @@ -1,36 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/get-momo/atlar-v1-go-client/client/transactions" -) - -func (c *Client) GetV1Transactions(ctx context.Context, token string, pageSize int64) (*transactions.GetV1TransactionsOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "list_transactions") - now := time.Now() - defer f(ctx, now) - - params := transactions.GetV1TransactionsParams{ - Limit: &pageSize, - Context: ctx, - Token: &token, - } - - return c.client.Transactions.GetV1Transactions(¶ms) -} - -func (c *Client) GetV1TransactionsID(ctx context.Context, id string) (*transactions.GetV1TransactionsIDOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "list_transactions") - now := time.Now() - defer f(ctx, now) - - params := transactions.GetV1TransactionsIDParams{ - Context: ctx, - ID: id, - } - - return c.client.Transactions.GetV1TransactionsID(¶ms) -} diff --git a/cmd/connectors/internal/connectors/atlar/client/transfers.go b/cmd/connectors/internal/connectors/atlar/client/transfers.go deleted file mode 100644 index 5aa3f924..00000000 --- a/cmd/connectors/internal/connectors/atlar/client/transfers.go +++ /dev/null @@ -1,37 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/get-momo/atlar-v1-go-client/client/credit_transfers" - atlar_models "github.com/get-momo/atlar-v1-go-client/models" -) - -func (c *Client) PostV1CreditTransfers(ctx context.Context, req *atlar_models.CreatePaymentRequest) (*credit_transfers.PostV1CreditTransfersCreated, error) { - f := connectors.ClientMetrics(ctx, "atlar", "create_credit_transfer") - now := time.Now() - defer f(ctx, now) - - postCreditTransfersParams := credit_transfers.PostV1CreditTransfersParams{ - Context: ctx, - CreditTransfer: req, - } - - return c.client.CreditTransfers.PostV1CreditTransfers(&postCreditTransfersParams) - -} - -func (c *Client) GetV1CreditTransfersGetByExternalIDExternalID(ctx context.Context, externalID string) (*credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK, error) { - f := connectors.ClientMetrics(ctx, "atlar", "get_credit_transfer") - now := time.Now() - defer f(ctx, now) - - getCreditTransferParams := credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDParams{ - Context: ctx, - ExternalID: externalID, - } - - return c.client.CreditTransfers.GetV1CreditTransfersGetByExternalIDExternalID(&getCreditTransferParams) -} diff --git a/cmd/connectors/internal/connectors/atlar/config.go b/cmd/connectors/internal/connectors/atlar/config.go deleted file mode 100644 index 7f9ea4c7..00000000 --- a/cmd/connectors/internal/connectors/atlar/config.go +++ /dev/null @@ -1,113 +0,0 @@ -package atlar - -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -var ( - //"https://api.atlar.com" - defaultURLValue = url.URL{ - Scheme: "https", - Host: "api.atlar.com", - } - defaultPollingPeriod = 2 * time.Minute - defaultPageSize uint64 = 25 -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` - TransferInitiationStatusPollingPeriod connectors.Duration `json:"transferInitiationStatusPollingPeriod" yaml:"transferInitiationStatusPollingPeriod" bson:"transferInitiationStatusPollingPeriod"` - BaseUrl url.URL `json:"-" yaml:"-" bson:"-"` // Already marshalled as string in the MarshalJson function - AccessKey string `json:"accessKey" yaml:"accessKey" bson:"accessKey"` - Secret string `json:"secret" yaml:"secret" bson:"secret"` - ApiConfig `bson:",inline"` -} - -// String obfuscates sensitive fields and returns a string representation of the config. -// This is used for logging. -func (c Config) String() string { - return fmt.Sprintf("baseUrl=%s, pollingPeriod=%s, transferInitiationStatusPollingPeriod=%s, pageSize=%d, accessKey=%s, secret=****", - c.BaseUrl.String(), c.PollingPeriod, c.TransferInitiationStatusPollingPeriod, c.PageSize, c.AccessKey) -} - -func (c Config) Validate() error { - if c.AccessKey == "" { - return errors.New("missing api access key") - } - - if c.Secret == "" { - return errors.New("missing api secret") - } - - return nil -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) Marshal() ([]byte, error) { - type CopyType Config - - basicConfig := struct { - BaseUrl string `json:"baseUrl"` - CopyType - }{ - BaseUrl: c.BaseUrl.String(), - CopyType: (CopyType)(c), - } - - return json.Marshal(basicConfig) -} - -func (c *Config) UnmarshalJSON(data []byte) error { - type CopyType Config - - tmp := struct { - BaseUrl string `json:"baseUrl"` - *CopyType - }{ - CopyType: (*CopyType)(c), - } - - err := json.Unmarshal(data, &tmp) - if err != nil { - return err - } - - baseUrl, err := url.Parse(tmp.BaseUrl) - if err != nil { - return err - } - c.BaseUrl = *baseUrl - - return nil -} - -type ApiConfig struct { - PageSize uint64 `json:"pageSize" yaml:"pageSize" bson:"pageSize"` -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("baseUrl", configtemplate.TypeString, defaultURLValue.String(), false) - cfg.AddParameter("accessKey", configtemplate.TypeString, "", true) - cfg.AddParameter("secret", configtemplate.TypeString, "", true) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - cfg.AddParameter("transferInitiationStatusPollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - cfg.AddParameter("pageSize", configtemplate.TypeDurationUnsignedInteger, strconv.Itoa(int(defaultPageSize)), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/atlar/connector.go b/cmd/connectors/internal/connectors/atlar/connector.go deleted file mode 100644 index 056131c4..00000000 --- a/cmd/connectors/internal/connectors/atlar/connector.go +++ /dev/null @@ -1,151 +0,0 @@ -package atlar - -import ( - "context" - "errors" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const name = models.ConnectorProviderAtlar - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch transactions", - Main: true, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - descriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - descriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.logger, c.cfg)(taskDescriptor) -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - err := ValidateTransferInitiation(transfer) - if err != nil { - return err - } - - descriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - if err := ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }); err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - descriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Create external bank account", - Key: taskNameCreateExternalBankAccount, - BankAccount: bankAccount, - }) - if err != nil { - return err - } - if err := ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW_SYNC, - }); err != nil { - return err - } - - // TODO: it might make sense to return the external account ID so the client can use it for initiating a payment - return nil -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/atlar/currencies.go b/cmd/connectors/internal/connectors/atlar/currencies.go deleted file mode 100644 index 9f647089..00000000 --- a/cmd/connectors/internal/connectors/atlar/currencies.go +++ /dev/null @@ -1,10 +0,0 @@ -package atlar - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - supportedCurrenciesWithDecimal = map[string]int{ - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "DKK": currency.ISO4217Currencies["DKK"], - } -) diff --git a/cmd/connectors/internal/connectors/atlar/loader.go b/cmd/connectors/internal/connectors/atlar/loader.go deleted file mode 100644 index 77be83a7..00000000 --- a/cmd/connectors/internal/connectors/atlar/loader.go +++ /dev/null @@ -1,63 +0,0 @@ -package atlar - -import ( - "net/url" - - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - emptyUrl := url.URL{} - if cfg.BaseUrl == emptyUrl { - //"https://api.atlar.com" - cfg.BaseUrl = defaultURLValue - } - - if cfg.PageSize == 0 { - cfg.PageSize = defaultPageSize - } - - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod = connectors.Duration{Duration: defaultPollingPeriod} - } - - if cfg.TransferInitiationStatusPollingPeriod.Duration == 0 { - cfg.TransferInitiationStatusPollingPeriod = connectors.Duration{Duration: defaultPollingPeriod} - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/atlar/loader_test.go b/cmd/connectors/internal/connectors/atlar/loader_test.go deleted file mode 100644 index 144dd323..00000000 --- a/cmd/connectors/internal/connectors/atlar/loader_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package atlar - -import ( - "context" - "net/url" - "testing" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/stretchr/testify/assert" -) - -// TestLoader tests the loader. -func TestLoader(t *testing.T) { - t.Parallel() - - config := Config{} - logger := logging.FromContext(context.TODO()) - - loader := NewLoader() - - assert.Equal(t, name, loader.Name()) - assert.Equal(t, 50, loader.AllowTasks()) - - baseUrl, err := url.Parse("https://api.atlar.com") - assert.Nil(t, err) - - assert.Equal(t, Config{ - Name: "ATLAR", - BaseUrl: *baseUrl, - PollingPeriod: connectors.Duration{Duration: 2 * time.Minute}, - TransferInitiationStatusPollingPeriod: connectors.Duration{Duration: 2 * time.Minute}, - ApiConfig: ApiConfig{PageSize: 25}, - }, loader.ApplyDefaults(config)) - - assert.EqualValues(t, newConnector(logger, config), loader.Load(logger, config)) -} diff --git a/cmd/connectors/internal/connectors/atlar/metadata.go b/cmd/connectors/internal/connectors/atlar/metadata.go deleted file mode 100644 index cd1a8398..00000000 --- a/cmd/connectors/internal/connectors/atlar/metadata.go +++ /dev/null @@ -1,56 +0,0 @@ -package atlar - -import ( - "fmt" - "time" - - "github.com/formancehq/go-libs/metadata" - "github.com/formancehq/payments/internal/models" -) - -const ( - atlarMetadataSpecNamespace = "com.atlar.spec/" - valueTRUE = "TRUE" - valueFALSE = "FALSE" -) - -func ComputeAccountMetadata(key, value string) metadata.Metadata { - namespacedKey := fmt.Sprintf("%s%s", atlarMetadataSpecNamespace, key) - return metadata.Metadata{ - namespacedKey: value, - } -} - -func ComputeAccountMetadataBool(key string, value bool) metadata.Metadata { - computedValue := valueFALSE - if value { - computedValue = valueTRUE - } - return ComputeAccountMetadata(key, computedValue) -} - -func ComputePaymentMetadata(paymentId models.PaymentID, key, value string) *models.PaymentMetadata { - namespacedKey := fmt.Sprintf("%s%s", atlarMetadataSpecNamespace, key) - return &models.PaymentMetadata{ - PaymentID: paymentId, - CreatedAt: time.Now(), - Key: namespacedKey, - Value: value, - } -} - -func ComputePaymentMetadataBool(paymentId models.PaymentID, key string, value bool) *models.PaymentMetadata { - computedValue := valueFALSE - if value { - computedValue = valueTRUE - } - return ComputePaymentMetadata(paymentId, key, computedValue) -} - -func ExtractNamespacedMetadata(metadata map[string]string, key string) (*string, error) { - value, ok := metadata[atlarMetadataSpecNamespace+key] - if !ok { - return nil, fmt.Errorf("unable to find metadata with key %s%s", atlarMetadataSpecNamespace, key) - } - return &value, nil -} diff --git a/cmd/connectors/internal/connectors/atlar/task_create_external_bank_account.go b/cmd/connectors/internal/connectors/atlar/task_create_external_bank_account.go deleted file mode 100644 index 1570ba7f..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_create_external_bank_account.go +++ /dev/null @@ -1,126 +0,0 @@ -package atlar - -import ( - "context" - "errors" - "fmt" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func CreateExternalBankAccountTask(config Config, client *client.Client, newExternalBankAccount *models.BankAccount) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskCreateExternalBankAccount", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("bankAccount.name", newExternalBankAccount.Name), - attribute.String("bankAccount.id", newExternalBankAccount.ID.String()), - ) - defer span.End() - - err := validateExternalBankAccount(newExternalBankAccount) - if err != nil { - otel.RecordError(span, err) - return err - } - - externalAccountID, err := createExternalBankAccount(ctx, client, newExternalBankAccount) - if err != nil { - otel.RecordError(span, err) - return err - } - if externalAccountID == nil { - err := errors.New("no external account id returned") - otel.RecordError(span, err) - return err - } - - err = ingestExternalAccountFromAtlar( - ctx, - connectorID, - ingester, - client, - newExternalBankAccount, - *externalAccountID, - ) - if err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -// TODO: validation (also metadata) needs to return a 400 -func validateExternalBankAccount(newExternalBankAccount *models.BankAccount) error { - _, err := ExtractNamespacedMetadata(newExternalBankAccount.Metadata, "owner/name") - if err != nil { - return fmt.Errorf("required metadata field %sowner/name is missing", atlarMetadataSpecNamespace) - } - ownerType, err := ExtractNamespacedMetadata(newExternalBankAccount.Metadata, "owner/type") - if err != nil { - return fmt.Errorf("required metadata field %sowner/type is missing", atlarMetadataSpecNamespace) - } - if *ownerType != "INDIVIDUAL" && *ownerType != "COMPANY" { - return fmt.Errorf("metadata field %sowner/type needs to be one of [ INDIVIDUAL COMPANY ]", atlarMetadataSpecNamespace) - } - - return nil -} - -func createExternalBankAccount(ctx context.Context, client *client.Client, newExternalBankAccount *models.BankAccount) (*string, error) { - return client.CreateCounterParty(ctx, newExternalBankAccount) -} - -func ingestExternalAccountFromAtlar( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - client *client.Client, - formanceBankAccount *models.BankAccount, - externalAccountID string, -) error { - accountsBatch := ingestion.AccountBatch{} - - externalAccountResponse, err := client.GetV1ExternalAccountsID(ctx, externalAccountID) - if err != nil { - return err - } - - counterpartyResponse, err := client.GetV1CounterpartiesID(ctx, externalAccountResponse.Payload.CounterpartyID) - if err != nil { - return err - } - - newAccount, err := ExternalAccountFromAtlarData(connectorID, externalAccountResponse.Payload, counterpartyResponse.Payload) - if err != nil { - return err - } - - accountsBatch = append(accountsBatch, newAccount) - - err = ingester.IngestAccounts(ctx, accountsBatch) - if err != nil { - return err - } - - if err := ingester.LinkBankAccountWithAccount(ctx, formanceBankAccount, &newAccount.ID); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/atlar/task_fetch_accounts.go b/cmd/connectors/internal/connectors/atlar/task_fetch_accounts.go deleted file mode 100644 index c2174ccf..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_fetch_accounts.go +++ /dev/null @@ -1,220 +0,0 @@ -package atlar - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "math/big" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/get-momo/atlar-v1-go-client/client/accounts" - "github.com/get-momo/atlar-v1-go-client/client/external_accounts" - "go.opentelemetry.io/otel/attribute" -) - -func FetchAccountsTask(config Config, client *client.Client) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - // Pagination works by cursor token. - for token := ""; ; { - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - pagedAccounts, err := client.GetV1Accounts(requestCtx, token, int64(config.PageSize)) - if err != nil { - otel.RecordError(span, err) - return err - } - - token = pagedAccounts.Payload.NextToken - - if err := ingestAccountsBatch(ctx, connectorID, taskID, ingester, pagedAccounts, client); err != nil { - otel.RecordError(span, err) - return err - } - - if token == "" { - break - } - } - - // Pagination works by cursor token. - for token := ""; ; { - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - pagedExternalAccounts, err := client.GetV1ExternalAccounts(requestCtx, token, int64(config.PageSize)) - if err != nil { - otel.RecordError(span, err) - return err - } - - token = pagedExternalAccounts.Payload.NextToken - - if err := ingestExternalAccountsBatch(ctx, connectorID, ingester, pagedExternalAccounts, client); err != nil { - otel.RecordError(span, err) - return err - } - - if token == "" { - break - } - } - - // Fetch payments after inserting all accounts in order to link them correctly - taskPayments, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch payments from Atlar", - Key: taskNameFetchTransactions, - }) - if err != nil { - otel.RecordError(span, err) - return err - } - - err = scheduler.Schedule(ctx, taskPayments, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - taskID models.TaskID, - ingester ingestion.Ingester, - pagedAccounts *accounts.GetV1AccountsOK, - client *client.Client, -) error { - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskFetchAccounts.ingestAccountsBatch", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - accountsBatch := ingestion.AccountBatch{} - balanceBatch := ingestion.BalanceBatch{} - - for _, account := range pagedAccounts.Payload.Items { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - createdAt, err := ParseAtlarTimestamp(account.Created) - if err != nil { - return fmt.Errorf("failed to parse opening date: %w", err) - } - - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - thirdPartyResponse, err := client.GetV1BetaThirdPartiesID(requestCtx, account.ThirdPartyID) - if err != nil { - otel.RecordError(span, err) - return err - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: *account.ID, - ConnectorID: connectorID, - }, - CreatedAt: createdAt, - Reference: *account.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency), - AccountName: account.Name, - Type: models.AccountTypeInternal, - Metadata: ExtractAccountMetadata(account, thirdPartyResponse.Payload), - RawData: raw, - }) - - balance := account.Balance - balanceTimestamp, err := ParseAtlarTimestamp(balance.Timestamp) - if err != nil { - return err - } - balanceBatch = append(balanceBatch, &models.Balance{ - AccountID: models.AccountID{ - Reference: *account.ID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, *balance.Amount.Currency), - Balance: big.NewInt(*balance.Amount.Value), - CreatedAt: balanceTimestamp, - LastUpdatedAt: time.Now().UTC(), - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return err - } - - if err := ingester.IngestBalances(ctx, balanceBatch, false); err != nil { - return err - } - - return nil -} - -func ingestExternalAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - pagedExternalAccounts *external_accounts.GetV1ExternalAccountsOK, - client *client.Client, -) error { - accountsBatch := ingestion.AccountBatch{} - - for _, externalAccount := range pagedExternalAccounts.Payload.Items { - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - counterparty_response, err := client.GetV1CounterpartiesID(requestCtx, externalAccount.CounterpartyID) - if err != nil { - return err - } - counterparty := counterparty_response.Payload - - newAccount, err := ExternalAccountFromAtlarData(connectorID, externalAccount, counterparty) - if err != nil { - return err - } - - accountsBatch = append(accountsBatch, newAccount) - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/atlar/task_fetch_transactions.go b/cmd/connectors/internal/connectors/atlar/task_fetch_transactions.go deleted file mode 100644 index ec2f3dd4..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_fetch_transactions.go +++ /dev/null @@ -1,297 +0,0 @@ -package atlar - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/get-momo/atlar-v1-go-client/client/transactions" - atlar_models "github.com/get-momo/atlar-v1-go-client/models" - "go.opentelemetry.io/otel/attribute" -) - -func FetchTransactionsTask(config Config, client *client.Client) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - resolver task.StateResolver, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskFetchTransactions", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - // Pagination works by cursor token. - for token := ""; ; { - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - pagedTransactions, err := client.GetV1Transactions(requestCtx, token, int64(config.PageSize)) - if err != nil { - otel.RecordError(span, err) - return err - } - - token = pagedTransactions.Payload.NextToken - - if err := ingestPaymentsBatch(ctx, connectorID, taskID, ingester, client, pagedTransactions); err != nil { - otel.RecordError(span, err) - return err - } - - if token == "" { - break - } - } - - return nil - } -} - -func ingestPaymentsBatch( - ctx context.Context, - connectorID models.ConnectorID, - taskID models.TaskID, - ingester ingestion.Ingester, - client *client.Client, - pagedTransactions *transactions.GetV1TransactionsOK, -) error { - batch := ingestion.PaymentBatch{} - - for _, item := range pagedTransactions.Payload.Items { - batchElement, err := atlarTransactionToPaymentBatchElement(ctx, connectorID, taskID, item, client) - if err != nil { - return err - } - if batchElement == nil { - continue - } - - batch = append(batch, *batchElement) - } - - if err := ingester.IngestPayments(ctx, batch); err != nil { - return err - } - - return nil -} - -func atlarTransactionToPaymentBatchElement( - ctx context.Context, - connectorID models.ConnectorID, - taskID models.TaskID, - transaction *atlar_models.Transaction, - client *client.Client, -) (*ingestion.PaymentBatchElement, error) { - ctx, span := connectors.StartSpan( - ctx, - "atlar.atlarTransactionToPaymentBatchElement", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - if _, ok := supportedCurrenciesWithDecimal[*transaction.Amount.Currency]; !ok { - // Discard transactions with unsupported currencies - return nil, nil - } - - raw, err := json.Marshal(transaction) - if err != nil { - return nil, err - } - - paymentType := determinePaymentType(transaction) - - itemAmount := transaction.Amount - amount, err := atlarTransactionAmountToPaymentAbsoluteAmount(*itemAmount.Value) - if err != nil { - return nil, err - } - - createdAt, err := ParseAtlarTimestamp(transaction.Created) - if err != nil { - return nil, err - } - - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - accountResponse, err := client.GetV1AccountsID(requestCtx, *transaction.Account.ID) - if err != nil { - otel.RecordError(span, err) - return nil, err - } - - requestCtx, cancel = contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - thirdPartyResponse, err := client.GetV1BetaThirdPartiesID(requestCtx, *&accountResponse.Payload.ThirdPartyID) - if err != nil { - otel.RecordError(span, err) - return nil, err - } - - paymentId := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: transaction.ID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: paymentId, - Reference: transaction.ID, - Type: paymentType, - ConnectorID: connectorID, - CreatedAt: createdAt, - Status: determinePaymentStatus(transaction), - Scheme: determinePaymentScheme(transaction), - Amount: amount, - InitialAmount: amount, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, *transaction.Amount.Currency), - Metadata: ExtractPaymentMetadata(paymentId, transaction, accountResponse.Payload, thirdPartyResponse.Payload), - RawData: raw, - }, - } - - if *itemAmount.Value >= 0 { - // DEBIT - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: *transaction.Account.ID, - ConnectorID: connectorID, - } - } else { - // CREDIT - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: *transaction.Account.ID, - ConnectorID: connectorID, - } - } - - return &batchElement, nil -} - -func determinePaymentType(item *atlar_models.Transaction) models.PaymentType { - if *item.Amount.Value >= 0 { - return models.PaymentTypePayIn - } else { - return models.PaymentTypePayOut - } -} - -func determinePaymentStatus(item *atlar_models.Transaction) models.PaymentStatus { - if item.Reconciliation.Status == atlar_models.ReconciliationDetailsStatusEXPECTED { - // A payment initiated by the owner of the accunt through the Atlar API, - // which was not yet reconciled with a payment from the statement - return models.PaymentStatusPending - } - if item.Reconciliation.Status == atlar_models.ReconciliationDetailsStatusBOOKED { - // A payment comissioned with the bank, which was not yet reconciled with a - // payment from the statement - return models.PaymentStatusSucceeded - } - if item.Reconciliation.Status == atlar_models.ReconciliationDetailsStatusRECONCILED { - return models.PaymentStatusSucceeded - } - return models.PaymentStatusOther -} - -func determinePaymentScheme(item *atlar_models.Transaction) models.PaymentScheme { - // item.Characteristics.BankTransactionCode.Domain - // item.Characteristics.BankTransactionCode.Family - // TODO: fees and interest -> models.PaymentSchemeOther with additional info on metadata. Will need example transactions for that - - if *item.Amount.Value > 0 { - return models.PaymentSchemeSepaDebit - } else if *item.Amount.Value < 0 { - return models.PaymentSchemeSepaCredit - } - return models.PaymentSchemeSepa -} - -func ExtractPaymentMetadata(paymentId models.PaymentID, transaction *atlar_models.Transaction, account *atlar_models.Account, bank *atlar_models.ThirdParty) []*models.PaymentMetadata { - result := []*models.PaymentMetadata{} - if transaction.Date != "" { - result = append(result, ComputePaymentMetadata(paymentId, "date", transaction.Date)) - } - if transaction.ValueDate != "" { - result = append(result, ComputePaymentMetadata(paymentId, "valueDate", transaction.ValueDate)) - } - result = append(result, ComputePaymentMetadata(paymentId, "remittanceInformation/type", *transaction.RemittanceInformation.Type)) - result = append(result, ComputePaymentMetadata(paymentId, "remittanceInformation/value", *transaction.RemittanceInformation.Value)) - result = append(result, ComputePaymentMetadata(paymentId, "bank/id", bank.ID)) - result = append(result, ComputePaymentMetadata(paymentId, "bank/name", bank.Name)) - result = append(result, ComputePaymentMetadata(paymentId, "bank/bic", account.Bank.Bic)) - result = append(result, ComputePaymentMetadata(paymentId, "btc/domain", transaction.Characteristics.BankTransactionCode.Domain)) - result = append(result, ComputePaymentMetadata(paymentId, "btc/family", transaction.Characteristics.BankTransactionCode.Family)) - result = append(result, ComputePaymentMetadata(paymentId, "btc/subfamily", transaction.Characteristics.BankTransactionCode.Subfamily)) - result = append(result, ComputePaymentMetadata(paymentId, "btc/description", transaction.Characteristics.BankTransactionCode.Description)) - result = append(result, ComputePaymentMetadataBool(paymentId, "returned", transaction.Characteristics.Returned)) - if transaction.CounterpartyDetails != nil && transaction.CounterpartyDetails.Name != "" { - result = append(result, ComputePaymentMetadata(paymentId, "counterparty/name", transaction.CounterpartyDetails.Name)) - if transaction.CounterpartyDetails.ExternalAccount != nil && transaction.CounterpartyDetails.ExternalAccount.Identifier != nil { - result = append(result, ComputePaymentMetadata(paymentId, "counterparty/bank/bic", transaction.CounterpartyDetails.ExternalAccount.Bank.Bic)) - result = append(result, ComputePaymentMetadata(paymentId, "counterparty/bank/name", transaction.CounterpartyDetails.ExternalAccount.Bank.Name)) - result = append(result, ComputePaymentMetadata(paymentId, - fmt.Sprintf("counterparty/identifier/%s", transaction.CounterpartyDetails.ExternalAccount.Identifier.Type), - transaction.CounterpartyDetails.ExternalAccount.Identifier.Number)) - } - } - if transaction.Characteristics.Returned { - result = append(result, ComputePaymentMetadata(paymentId, "returnReason/code", transaction.Characteristics.ReturnReason.Code)) - result = append(result, ComputePaymentMetadata(paymentId, "returnReason/description", transaction.Characteristics.ReturnReason.Description)) - result = append(result, ComputePaymentMetadata(paymentId, "returnReason/btc/domain", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Domain)) - result = append(result, ComputePaymentMetadata(paymentId, "returnReason/btc/family", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Family)) - result = append(result, ComputePaymentMetadata(paymentId, "returnReason/btc/subfamily", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Subfamily)) - result = append(result, ComputePaymentMetadata(paymentId, "returnReason/btc/description", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Description)) - } - if transaction.Characteristics.VirtualAccount != nil { - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/market", transaction.Characteristics.VirtualAccount.Market)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/rawIdentifier", transaction.Characteristics.VirtualAccount.RawIdentifier)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/bank/id", transaction.Characteristics.VirtualAccount.Bank.ID)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/bank/name", transaction.Characteristics.VirtualAccount.Bank.Name)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/bank/bic", transaction.Characteristics.VirtualAccount.Bank.Bic)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/identifier/holderName", *transaction.Characteristics.VirtualAccount.Identifier.HolderName)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/identifier/market", transaction.Characteristics.VirtualAccount.Identifier.Market)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/identifier/type", transaction.Characteristics.VirtualAccount.Identifier.Type)) - result = append(result, ComputePaymentMetadata(paymentId, "virtualAccount/identifier/number", transaction.Characteristics.VirtualAccount.Identifier.Number)) - } - result = append(result, ComputePaymentMetadata(paymentId, "reconciliation/status", transaction.Reconciliation.Status)) - result = append(result, ComputePaymentMetadata(paymentId, "reconciliation/transactableId", transaction.Reconciliation.TransactableID)) - result = append(result, ComputePaymentMetadata(paymentId, "reconciliation/transactableType", transaction.Reconciliation.TransactableType)) - if transaction.Characteristics.CurrencyExchange != nil { - result = append(result, ComputePaymentMetadata(paymentId, "currencyExchange/sourceCurrency", transaction.Characteristics.CurrencyExchange.SourceCurrency)) - result = append(result, ComputePaymentMetadata(paymentId, "currencyExchange/targetCurrency", transaction.Characteristics.CurrencyExchange.TargetCurrency)) - result = append(result, ComputePaymentMetadata(paymentId, "currencyExchange/exchangeRate", transaction.Characteristics.CurrencyExchange.ExchangeRate)) - result = append(result, ComputePaymentMetadata(paymentId, "currencyExchange/unitCurrency", transaction.Characteristics.CurrencyExchange.UnitCurrency)) - } - if transaction.CounterpartyDetails.MandateReference != "" { - result = append(result, ComputePaymentMetadata(paymentId, "mandateReference", transaction.CounterpartyDetails.MandateReference)) - } - - return result -} - -func atlarTransactionAmountToPaymentAbsoluteAmount(atlarAmount int64) (*big.Int, error) { - var amount big.Int - amountInt := amount.SetInt64(atlarAmount) - amountInt = amountInt.Abs(amountInt) - return amountInt, nil -} diff --git a/cmd/connectors/internal/connectors/atlar/task_fetch_transactions_test.go b/cmd/connectors/internal/connectors/atlar/task_fetch_transactions_test.go deleted file mode 100644 index 20599a18..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_fetch_transactions_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package atlar - -import ( - "math/big" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestAtlarTransactionAmountToPaymentAbsoluteAmount(t *testing.T) { - t.Parallel() - var result *big.Int - var err error - - result, err = atlarTransactionAmountToPaymentAbsoluteAmount(30) - if assert.Nil(t, err) { - assert.Equal(t, *big.NewInt(30), *result) - } - - result, err = atlarTransactionAmountToPaymentAbsoluteAmount(330) - if assert.Nil(t, err) { - assert.Equal(t, *big.NewInt(330), *result) - } - - result, err = atlarTransactionAmountToPaymentAbsoluteAmount(330) - if assert.Nil(t, err) { - assert.Equal(t, *big.NewInt(330), *result) - } - - result, err = atlarTransactionAmountToPaymentAbsoluteAmount(-30) - if assert.Nil(t, err) { - assert.Equal(t, *big.NewInt(30), *result) - } - - result, err = atlarTransactionAmountToPaymentAbsoluteAmount(-330) - if assert.Nil(t, err) { - assert.Equal(t, *big.NewInt(330), *result) - } -} diff --git a/cmd/connectors/internal/connectors/atlar/task_main.go b/cmd/connectors/internal/connectors/atlar/task_main.go deleted file mode 100644 index 35a2d878..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_main.go +++ /dev/null @@ -1,52 +0,0 @@ -package atlar - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// Launch accounts and payments tasks. -// Period between runs dictated by config.PollingPeriod. -func MainTask(logger logging.Logger) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_ALWAYS, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/atlar/task_payments.go b/cmd/connectors/internal/connectors/atlar/task_payments.go deleted file mode 100644 index d9969c89..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_payments.go +++ /dev/null @@ -1,394 +0,0 @@ -package atlar - -import ( - "context" - "errors" - "fmt" - "math/big" - "regexp" - "strings" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/get-momo/atlar-v1-go-client/client/credit_transfers" - atlar_models "github.com/get-momo/atlar-v1-go-client/models" - "go.opentelemetry.io/otel/attribute" -) - -func InitiatePaymentTask(config Config, client *client.Client, transferID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - storageReader storage.Reader, - ingester ingestion.Ingester, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - return err - } - - var paymentID *models.PaymentID - defer func() { - if err != nil { - otel.RecordError(span, err) - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - if err := ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()); err != nil { - otel.RecordError(span, err) - } - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount != nil { - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - } - - currency, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - paymentSchemeType := "SCT" // SEPA Credit Transfer - remittanceInformationType := "UNSTRUCTURED" - remittanceInformationValue := transfer.Description - amount := atlar_models.AmountInput{ - Currency: ¤cy, - Value: transfer.Amount.Int64(), - StringValue: amountToString(*transfer.Amount, precision), - } - date := transfer.ScheduledAt - if date.IsZero() { - date = time.Now() - } - dateString := date.Format(time.DateOnly) - - createPaymentRequest := atlar_models.CreatePaymentRequest{ - SourceAccountID: &transfer.SourceAccount.Reference, - DestinationExternalAccountID: &transfer.DestinationAccount.Reference, - Amount: &amount, - Date: &dateString, - ExternalID: serializeAtlarPaymentExternalID(transfer.ID.Reference, transfer.CountRetries()), - PaymentSchemeType: &paymentSchemeType, - RemittanceInformation: &atlar_models.RemittanceInformation{ - Type: &remittanceInformationType, - Value: &remittanceInformationValue, - }, - } - - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - var postCreditTransferResponse *credit_transfers.PostV1CreditTransfersCreated - postCreditTransferResponse, err = client.PostV1CreditTransfers(requestCtx, &createPaymentRequest) - if err != nil { - return err - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: postCreditTransferResponse.Payload.Reconciliation.ExpectedTransactionID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - - var taskDescriptor models.TaskDescriptor - taskDescriptor, err = models.EncodeTaskDescriptor(TaskDescriptor{ - Name: fmt.Sprintf("Update transfer initiation status of transfer %s", transfer.ID.String()), - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil - } -} - -func ValidateTransferInitiation(transfer *models.TransferInitiation) error { - if transfer == nil { - return errors.New("transfer cannot be nil") - } - if transfer.Type.String() != "PAYOUT" { - return errors.New("this connector only supports type PAYOUT") - } - return nil -} - -func UpdatePaymentStatusTask( - config Config, - client *client.Client, - transferID string, - stringPaymentID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - storageReader storage.Reader, - ingester ingestion.Ingester, - ) error { - paymentID := models.MustPaymentIDFromString(stringPaymentID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", stringPaymentID), - attribute.Int("attempt", attempt), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - getCreditTransferResponse, err := client.GetV1CreditTransfersGetByExternalIDExternalID( - requestCtx, - serializeAtlarPaymentExternalID(transfer.ID.Reference, transfer.CountRetries()), - ) - if err != nil { - otel.RecordError(span, err) - return err - } - - status := getCreditTransferResponse.Payload.Status - // Status docs: https://docs.atlar.com/docs/payment-details#payment-states--events - switch status { - case "CREATED", "APPROVED", "PENDING_SUBMISSION", "SENT", "PENDING_AT_BANK", "ACCEPTED", "EXECUTED": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: fmt.Sprintf("Update transfer initiation status of transfer %s", transfer.ID.String()), - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - otel.RecordError(span, err) - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: config.TransferInitiationStatusPollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return err - } - return nil - - case "RECONCILED": - err = ingestAtlarTransaction(ctx, - ingester, - connectorID, - taskID, - client, - getCreditTransferResponse.Payload.Reconciliation.BookedTransactionID, - ) - if err != nil { - otel.RecordError(span, err) - return err - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: getCreditTransferResponse.Payload.Reconciliation.BookedTransactionID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - - err = ingester.UpdateTransferInitiationPayment(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - otel.RecordError(span, err) - return err - } - - return nil - - case "REJECTED", "FAILED", "RETURNED": - err = ingester.UpdateTransferInitiationPaymentsStatus( - ctx, transfer, paymentID, models.TransferInitiationStatusFailed, - fmt.Sprintf("paymant initiation status is \"%s\"", status), time.Now(), - ) - if err != nil { - otel.RecordError(span, err) - return err - } - - return nil - - default: - err := fmt.Errorf( - "unknown status \"%s\" encountered while fetching payment initiation status of payment \"%s\"", - status, getCreditTransferResponse.Payload.ID, - ) - otel.RecordError(span, err) - return err - } - } -} - -func amountToString(amount big.Int, precision int) string { - raw := amount.String() - if precision < 0 { - precision = 0 - } - insertPosition := len(raw) - precision - if insertPosition <= 0 { - return "0." + strings.Repeat("0", -insertPosition) + raw - } - return raw[:insertPosition] + "." + raw[insertPosition:] -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} - -func serializeAtlarPaymentExternalID(ID string, attempts int) string { - return fmt.Sprintf("%s_%d", ID, attempts) -} - -var deserializeAtlarPaymentExternalIDRegex = regexp.MustCompile(`^([^\_]+)_([0-9]+)$`) - -func deserializeAtlarPaymentExternalID(serialized string) (string, int, error) { - var attempts int - - // Find matches in the input string - matches := deserializeAtlarPaymentExternalIDRegex.FindStringSubmatch(serialized) - if matches == nil || len(matches) != 3 { - return "", 0, errors.New("cannot deserialize malformed externalID") - } - - parsed, err := fmt.Sscanf(matches[2], "%d", &attempts) - if err != nil { - return "", 0, errors.New("cannot deserialize malformed externalID") - } - if parsed != 1 { - return "", 0, errors.New("cannot deserialize malformed externalID") - } - return matches[1], attempts, nil -} - -func ingestAtlarTransaction( - ctx context.Context, - ingester ingestion.Ingester, - connectorID models.ConnectorID, - taskID models.TaskID, - client *client.Client, - transactionId string, -) error { - ctx, span := connectors.StartSpan( - ctx, - "atlar.taskUpdatePaymentStatus.ingestAtlarTransaction", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transactionID", transactionId), - ) - defer span.End() - - requestCtx, cancel := contextutil.DetachedWithTimeout(ctx, 30*time.Second) - defer cancel() - transactionResponse, err := client.GetV1TransactionsID(requestCtx, transactionId) - if err != nil { - otel.RecordError(span, err) - return err - } - - batchElement, err := atlarTransactionToPaymentBatchElement(ctx, connectorID, taskID, transactionResponse.Payload, client) - if err != nil { - otel.RecordError(span, err) - return err - } - if batchElement == nil { - return nil - } - - batch := ingestion.PaymentBatch{*batchElement} - - err = ingester.IngestPayments(ctx, batch) - if err != nil { - otel.RecordError(span, err) - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/atlar/task_payments_test.go b/cmd/connectors/internal/connectors/atlar/task_payments_test.go deleted file mode 100644 index b01621f5..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_payments_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package atlar - -import ( - "errors" - "math/big" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestAmountToString(t *testing.T) { - t.Parallel() - - assert.EqualValues(t, "0.032", amountToString(*big.NewInt(32), 3)) - assert.EqualValues(t, "0.32", amountToString(*big.NewInt(32), 2)) - assert.EqualValues(t, "3.2", amountToString(*big.NewInt(32), 1)) - assert.EqualValues(t, "5.432", amountToString(*big.NewInt(5432), 3)) - assert.EqualValues(t, "54.32", amountToString(*big.NewInt(5432), 2)) - assert.EqualValues(t, "543.2", amountToString(*big.NewInt(5432), 1)) -} - -func TestSerializeAtlarPaymentExternalID(t *testing.T) { - t.Parallel() - - assert.EqualValues(t, "testID_1", serializeAtlarPaymentExternalID("testID", 1)) - assert.EqualValues(t, "tqmbAGgV4S2pHics57BT5tV2_682", serializeAtlarPaymentExternalID("tqmbAGgV4S2pHics57BT5tV2", 682)) -} - -func TestDeserializeAtlarPaymentExternalID(t *testing.T) { - t.Parallel() - - var ID string - var attempts int - var err error - - ID, attempts, err = deserializeAtlarPaymentExternalID("testID_1") - if assert.Nil(t, err) { - assert.EqualValues(t, "testID", ID) - assert.EqualValues(t, 1, attempts) - } - - ID, attempts, err = deserializeAtlarPaymentExternalID("tqmbAGgV4S2pHics57BT5tV2_682") - if assert.Nil(t, err) { - assert.EqualValues(t, "tqmbAGgV4S2pHics57BT5tV2", ID) - assert.EqualValues(t, 682, attempts) - } - - _, _, err = deserializeAtlarPaymentExternalID("tqmbAGgV4S2pHics57BT5tV2_682_432") - if assert.Error(t, err) { - assert.Equal(t, errors.New("cannot deserialize malformed externalID"), err) - } - - _, _, err = deserializeAtlarPaymentExternalID("tqmbAGgV4S2pHics57BT5tV2_") - if assert.Error(t, err) { - assert.Equal(t, errors.New("cannot deserialize malformed externalID"), err) - } - - _, _, err = deserializeAtlarPaymentExternalID("tqmbAGgV4S2pHics57BT5tV2") - if assert.Error(t, err) { - assert.Equal(t, errors.New("cannot deserialize malformed externalID"), err) - } -} diff --git a/cmd/connectors/internal/connectors/atlar/task_resolve.go b/cmd/connectors/internal/connectors/atlar/task_resolve.go deleted file mode 100644 index 1483d257..00000000 --- a/cmd/connectors/internal/connectors/atlar/task_resolve.go +++ /dev/null @@ -1,52 +0,0 @@ -package atlar - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/atlar/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const ( - taskNameFetchAccounts = "fetch_accounts" - taskNameFetchTransactions = "fetch_transactions" - taskNameCreateExternalBankAccount = "create_external_bank_account" - taskNameInitiatePayment = "initiate_payment" - taskNameUpdatePaymentStatus = "update_payment_status" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - Main bool `json:"main,omitempty" yaml:"main" bson:"main"` - BankAccount *models.BankAccount `json:"bankAccount,omitempty" yaml:"bankAccount" bson:"bankAccount"` - TransferID string `json:"transferId,omitempty" yaml:"transferId" bson:"transferId"` - PaymentID string `json:"paymentId,omitempty" yaml:"paymentId" bson:"paymentId"` - Attempt int `json:"attempt,omitempty" yaml:"attempt" bson:"attempt"` -} - -func resolveTasks(logger logging.Logger, config Config) func(taskDefinition TaskDescriptor) task.Task { - client := client.NewClient(config.BaseUrl, config.AccessKey, config.Secret) - - return func(taskDescriptor TaskDescriptor) task.Task { - if taskDescriptor.Main { - return MainTask(logger) - } - - switch taskDescriptor.Key { - case taskNameFetchAccounts: - return FetchAccountsTask(config, client) - case taskNameFetchTransactions: - return FetchTransactionsTask(config, client) - case taskNameCreateExternalBankAccount: - return CreateExternalBankAccountTask(config, client, taskDescriptor.BankAccount) - case taskNameInitiatePayment: - return InitiatePaymentTask(config, client, taskDescriptor.TransferID) - case taskNameUpdatePaymentStatus: - return UpdatePaymentStatusTask(config, client, taskDescriptor.TransferID, taskDescriptor.PaymentID, taskDescriptor.Attempt) - default: - return nil - } - } -} diff --git a/cmd/connectors/internal/connectors/atlar/time.go b/cmd/connectors/internal/connectors/atlar/time.go deleted file mode 100644 index 8a90de7c..00000000 --- a/cmd/connectors/internal/connectors/atlar/time.go +++ /dev/null @@ -1,16 +0,0 @@ -package atlar - -import "time" - -func ParseAtlarTimestamp(value string) (time.Time, error) { - return time.Parse(time.RFC3339Nano, value) -} - -func ParseAtlarDate(value string) (time.Time, error) { - return time.Parse(time.DateOnly, value) -} - -func TimeToAtlarTimestamp(input *time.Time) *string { - atlarTimestamp := input.Format(time.RFC3339Nano) - return &atlarTimestamp -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/client/accounts.go b/cmd/connectors/internal/connectors/bankingcircle/client/accounts.go deleted file mode 100644 index 2e1ded44..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/client/accounts.go +++ /dev/null @@ -1,131 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Account struct { - AccountID string `json:"accountId"` - AccountDescription string `json:"accountDescription"` - AccountIdentifiers []struct { - Account string `json:"account"` - FinancialInstitution string `json:"financialInstitution"` - Country string `json:"country"` - } `json:"accountIdentifiers"` - Status string `json:"status"` - Currency string `json:"currency"` - OpeningDate string `json:"openingDate"` - ClosingDate string `json:"closingDate"` - OwnedByCompanyID string `json:"ownedByCompanyId"` - ProtectionType string `json:"protectionType"` - Balances []struct { - Type string `json:"type"` - Currency string `json:"currency"` - BeginOfDayAmount json.Number `json:"beginOfDayAmount"` - FinancialDate string `json:"financialDate"` - IntraDayAmount json.Number `json:"intraDayAmount"` - LastTransactionTimestamp string `json:"lastTransactionTimestamp"` - } `json:"balances"` -} - -func (c *Client) GetAccounts(ctx context.Context, page int) ([]*Account, error) { - if err := c.ensureAccessTokenIsValid(ctx); err != nil { - return nil, err - } - - f := connectors.ClientMetrics(ctx, "bankingcircle", "list_accounts") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+"/api/v1/accounts", http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create account request: %w", err) - } - - q := req.URL.Query() - q.Add("PageSize", "100") - q.Add("PageNumber", fmt.Sprint(page)) - - req.URL.RawQuery = q.Encode() - - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read accounts response body: %w", err) - } - - type response struct { - Result []*Account `json:"result"` - PageInfo struct { - CurrentPage int `json:"currentPage"` - PageSize int `json:"pageSize"` - } `json:"pageInfo"` - } - - var res response - - if err = json.Unmarshal(responseBody, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal accounts response: %w", err) - } - - return res.Result, nil -} - -func (c *Client) GetAccount(ctx context.Context, accountID string) (*Account, error) { - if err := c.ensureAccessTokenIsValid(ctx); err != nil { - return nil, err - } - - f := connectors.ClientMetrics(ctx, "bankingcircle", "get_account") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/accounts/%s", c.endpoint, accountID), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create account request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("wrong status code: %d", resp.StatusCode) - } - - var account Account - if err := json.NewDecoder(resp.Body).Decode(&account); err != nil { - return nil, fmt.Errorf("failed to decode account response: %w", err) - } - - return &account, nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/client/auth.go b/cmd/connectors/internal/connectors/bankingcircle/client/auth.go deleted file mode 100644 index 7374edd9..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/client/auth.go +++ /dev/null @@ -1,94 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -func (c *Client) login(ctx context.Context) error { - f := connectors.ClientMetrics(ctx, "bankingcircle", "authorize") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - c.authorizationEndpoint+"/api/v1/authorizations/authorize", http.NoBody) - if err != nil { - return fmt.Errorf("failed to create login request: %w", err) - } - - req.SetBasicAuth(c.username, c.password) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to login: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read login response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - type responseError struct { - ErrorCode string `json:"errorCode"` - ErrorText string `json:"errorText"` - } - var errors []responseError - if err = json.Unmarshal(responseBody, &errors); err != nil { - return fmt.Errorf("failed to unmarshal login response: %w", err) - } - if len(errors) > 0 { - return fmt.Errorf("failed to login: %s %s", errors[0].ErrorCode, errors[0].ErrorText) - } - return fmt.Errorf("failed to login: %s", resp.Status) - } - - //nolint:tagliatelle // allow for client-side structures - type response struct { - AccessToken string `json:"access_token"` - ExpiresIn string `json:"expires_in"` - } - - var res response - - if err = json.Unmarshal(responseBody, &res); err != nil { - return fmt.Errorf("failed to unmarshal login response: %w", err) - } - - c.accessToken = res.AccessToken - - expiresIn, err := strconv.Atoi(res.ExpiresIn) - if err != nil { - return fmt.Errorf("failed to convert expires_in to int: %w", err) - } - - c.accessTokenExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) - - return nil -} - -func (c *Client) ensureAccessTokenIsValid(ctx context.Context) error { - if c.accessToken == "" { - return c.login(ctx) - } - - if c.accessTokenExpiresAt.After(time.Now().Add(5 * time.Second)) { - return nil - } - - return c.login(ctx) -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/client/client.go b/cmd/connectors/internal/connectors/bankingcircle/client/client.go deleted file mode 100644 index 319833bf..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/client/client.go +++ /dev/null @@ -1,66 +0,0 @@ -package client - -import ( - "crypto/tls" - "net/http" - "time" - - "github.com/formancehq/go-libs/logging" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -type Client struct { - httpClient *http.Client - - username string - password string - - endpoint string - authorizationEndpoint string - - logger logging.Logger - - accessToken string - accessTokenExpiresAt time.Time -} - -func newHTTPClient(userCertificate, userCertificateKey string) (*http.Client, error) { - cert, err := tls.X509KeyPair([]byte(userCertificate), []byte(userCertificateKey)) - if err != nil { - return nil, err - } - - tr := http.DefaultTransport.(*http.Transport).Clone() - tr.TLSClientConfig = &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - - return &http.Client{ - Timeout: 10 * time.Second, - Transport: otelhttp.NewTransport(tr), - }, nil -} - -func NewClient( - username, password, - endpoint, authorizationEndpoint, - uCertificate, uCertificateKey string, - logger logging.Logger) (*Client, error) { - httpClient, err := newHTTPClient(uCertificate, uCertificateKey) - if err != nil { - return nil, err - } - - c := &Client{ - httpClient: httpClient, - - username: username, - password: password, - endpoint: endpoint, - authorizationEndpoint: authorizationEndpoint, - - logger: logger, - } - - return c, nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/client/payments.go b/cmd/connectors/internal/connectors/bankingcircle/client/payments.go deleted file mode 100644 index 54479009..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/client/payments.go +++ /dev/null @@ -1,181 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -//nolint:tagliatelle // allow for client-side structures -type Payment struct { - PaymentID string `json:"paymentId"` - TransactionReference string `json:"transactionReference"` - ConcurrencyToken string `json:"concurrencyToken"` - Classification string `json:"classification"` - Status string `json:"status"` - Errors interface{} `json:"errors"` - LastChangedTimestamp time.Time `json:"lastChangedTimestamp"` - DebtorInformation struct { - PaymentBulkID interface{} `json:"paymentBulkId"` - AccountID string `json:"accountId"` - Account struct { - Account string `json:"account"` - FinancialInstitution string `json:"financialInstitution"` - Country string `json:"country"` - } `json:"account"` - VibanID interface{} `json:"vibanId"` - Viban struct { - Account string `json:"account"` - FinancialInstitution string `json:"financialInstitution"` - Country string `json:"country"` - } `json:"viban"` - InstructedDate interface{} `json:"instructedDate"` - DebitAmount struct { - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - } `json:"debitAmount"` - DebitValueDate time.Time `json:"debitValueDate"` - FxRate interface{} `json:"fxRate"` - Instruction interface{} `json:"instruction"` - } `json:"debtorInformation"` - Transfer struct { - DebtorAccount interface{} `json:"debtorAccount"` - DebtorName interface{} `json:"debtorName"` - DebtorAddress interface{} `json:"debtorAddress"` - Amount struct { - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - } `json:"amount"` - ValueDate interface{} `json:"valueDate"` - ChargeBearer interface{} `json:"chargeBearer"` - RemittanceInformation interface{} `json:"remittanceInformation"` - CreditorAccount interface{} `json:"creditorAccount"` - CreditorName interface{} `json:"creditorName"` - CreditorAddress interface{} `json:"creditorAddress"` - } `json:"transfer"` - CreditorInformation struct { - AccountID string `json:"accountId"` - Account struct { - Account string `json:"account"` - FinancialInstitution string `json:"financialInstitution"` - Country string `json:"country"` - } `json:"account"` - VibanID interface{} `json:"vibanId"` - Viban struct { - Account string `json:"account"` - FinancialInstitution string `json:"financialInstitution"` - Country string `json:"country"` - } `json:"viban"` - CreditAmount struct { - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - } `json:"creditAmount"` - CreditValueDate time.Time `json:"creditValueDate"` - FxRate interface{} `json:"fxRate"` - } `json:"creditorInformation"` -} - -func (c *Client) GetPayments(ctx context.Context, page int) ([]*Payment, error) { - if err := c.ensureAccessTokenIsValid(ctx); err != nil { - return nil, err - } - - f := connectors.ClientMetrics(ctx, "bankingcircle", "list_payments") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+"/api/v1/payments/singles", http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create payments request: %w", err) - } - - q := req.URL.Query() - q.Add("PageSize", "100") - q.Add("PageNumber", fmt.Sprint(page)) - - req.URL.RawQuery = q.Encode() - - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payments: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read payments response body: %w", err) - } - - type response struct { - Result []*Payment `json:"result"` - PageInfo struct { - CurrentPage int `json:"currentPage"` - PageSize int `json:"pageSize"` - } `json:"pageInfo"` - } - - var res response - - if err = json.Unmarshal(responseBody, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal payments response: %w", err) - } - - return res.Result, nil -} - -type StatusResponse struct { - Status string `json:"status"` -} - -func (c *Client) GetPaymentStatus(ctx context.Context, paymentID string) (*StatusResponse, error) { - if err := c.ensureAccessTokenIsValid(ctx); err != nil { - return nil, err - } - - f := connectors.ClientMetrics(ctx, "bankingcircle", "get_payment_status") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/payments/singles/%s/status", c.endpoint, paymentID), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create payments request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payments: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read payments response body: %w", err) - } - - var res StatusResponse - if err = json.Unmarshal(responseBody, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal payments response: %w", err) - } - - return &res, nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/client/transfer_payouts.go b/cmd/connectors/internal/connectors/bankingcircle/client/transfer_payouts.go deleted file mode 100644 index 77ab1434..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/client/transfer_payouts.go +++ /dev/null @@ -1,82 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type PaymentAccount struct { - Account string `json:"account"` - FinancialInstitution string `json:"financialInstitution"` - Country string `json:"country"` -} - -type PaymentRequest struct { - IdempotencyKey string `json:"idempotencyKey"` - RequestedExecutionDate time.Time `json:"requestedExecutionDate"` - DebtorAccount PaymentAccount `json:"debtorAccount"` - DebtorReference string `json:"debtorReference"` - CurrencyOfTransfer string `json:"currencyOfTransfer"` - Amount struct { - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - } `json:"amount"` - ChargeBearer string `json:"chargeBearer"` - CreditorAccount *PaymentAccount `json:"creditorAccount"` -} - -type PaymentResponse struct { - PaymentID string `json:"paymentId"` - Status string `json:"status"` -} - -func (c *Client) InitiateTransferOrPayouts(ctx context.Context, transferRequest *PaymentRequest) (*PaymentResponse, error) { - if err := c.ensureAccessTokenIsValid(ctx); err != nil { - return nil, err - } - - f := connectors.ClientMetrics(ctx, "bankingcircle", "create_transfers_payouts") - now := time.Now() - defer f(ctx, now) - - body, err := json.Marshal(transferRequest) - if err != nil { - return nil, fmt.Errorf("failed to marshal transfer request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/api/v1/payments/singles", bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create payments request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make transfer: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("failed to make transfer: %w", err) - } - - var transferResponse PaymentResponse - if err := json.NewDecoder(resp.Body).Decode(&transferResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return &transferResponse, nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/config.go b/cmd/connectors/internal/connectors/bankingcircle/config.go deleted file mode 100644 index c80b79a6..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/config.go +++ /dev/null @@ -1,94 +0,0 @@ -package bankingcircle - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" -) - -const ( - defaultPollingPeriod = 2 * time.Minute -) - -// PFX is not handle very well in Go if we have more than one certificate -// in the pfx data. -// To be safe for every user to pass the right data to the connector, let's -// use two config parameters instead of one: the user certificate and the key -// associated. -// To extract them for a pfx file, you can use the following commands: -// openssl pkcs12 -in PC20230412293693.pfx -clcerts -nokeys | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > clientcert.cer -// openssl pkcs12 -in PC20230412293693.pfx -nocerts -nodes | sed -ne '/-BEGIN PRIVATE KEY-/,/-END PRIVATE KEY-/p' > clientcert.key -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - Username string `json:"username" yaml:"username" bson:"username"` - Password string `json:"password" yaml:"password" bson:"password"` - Endpoint string `json:"endpoint" yaml:"endpoint" bson:"endpoint"` - AuthorizationEndpoint string `json:"authorizationEndpoint" yaml:"authorizationEndpoint" bson:"authorizationEndpoint"` - UserCertificate string `json:"userCertificate" yaml:"userCertificate" bson:"userCertificate"` - UserCertificateKey string `json:"userCertificateKey" yaml:"userCertificateKey" bson:"userCertificateKey"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -// String obfuscates sensitive fields and returns a string representation of the config. -// This is used for logging. -func (c Config) String() string { - return fmt.Sprintf("username=%s, password=****, endpoint=%s, authorizationEndpoint=%s", c.Username, c.Endpoint, c.AuthorizationEndpoint) -} - -func (c Config) Validate() error { - if c.Username == "" { - return ErrMissingUsername - } - - if c.Password == "" { - return ErrMissingPassword - } - - if c.Endpoint == "" { - return ErrMissingEndpoint - } - - if c.AuthorizationEndpoint == "" { - return ErrMissingAuthorizationEndpoint - } - - if c.UserCertificate == "" { - return ErrMissingUserCertificate - } - - if c.UserCertificateKey == "" { - return ErrMissingUserCertificatePassphrase - } - - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("username", configtemplate.TypeString, "", true) - cfg.AddParameter("password", configtemplate.TypeString, "", true) - cfg.AddParameter("endpoint", configtemplate.TypeString, "", true) - cfg.AddParameter("authorizationEndpoint", configtemplate.TypeString, "", true) - cfg.AddParameter("userCertificate", configtemplate.TypeLongString, "", true) - cfg.AddParameter("userCertificateKey", configtemplate.TypeLongString, "", true) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/connector.go b/cmd/connectors/internal/connectors/bankingcircle/connector.go deleted file mode 100644 index 6491a4b0..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/connector.go +++ /dev/null @@ -1,152 +0,0 @@ -package bankingcircle - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" -) - -const name = models.ConnectorProviderBankingCircle - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch payments", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} -func (c *Connector) Install(ctx task.ConnectorContext) error { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.logger, c.cfg)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate bank account creation", - Key: taskNameCreateExternalAccount, - BankAccountID: bankAccount.ID, - }) - if err != nil { - return err - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW_SYNC, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/bankingcircle/currencies.go b/cmd/connectors/internal/connectors/bankingcircle/currencies.go deleted file mode 100644 index 9ffb3e4d..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/currencies.go +++ /dev/null @@ -1,38 +0,0 @@ -package bankingcircle - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - // All supported BankingCircle currencies and decimal are on par with - // ISO4217Currencies. - supportedCurrenciesWithDecimal = map[string]int{ - "AED": currency.ISO4217Currencies["AED"], // UAE Dirham - "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar - "CAD": currency.ISO4217Currencies["CAD"], // Canadian Dollar - "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc - "CNY": currency.ISO4217Currencies["CNY"], // China Yuan Renminbi - "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna - "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling - "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar - "ILS": currency.ISO4217Currencies["ILS"], // Israeli Shekel - "JPY": currency.ISO4217Currencies["JPY"], // Japanese Yen - "MXN": currency.ISO4217Currencies["MXN"], // Mexican Peso - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone - "NZD": currency.ISO4217Currencies["NZD"], // New Zealand Dollar - "PLN": currency.ISO4217Currencies["PLN"], // Polish Zloty - "RON": currency.ISO4217Currencies["RON"], // Romanian Leu - "SAR": currency.ISO4217Currencies["SAR"], // Saudi Riyal - "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona - "SGD": currency.ISO4217Currencies["SGD"], // Singapore Dollar - "TRY": currency.ISO4217Currencies["TRY"], // Turkish Lira - "USD": currency.ISO4217Currencies["USD"], // US Dollar - "ZAR": currency.ISO4217Currencies["ZAR"], // South African Rand - - // Unsupported currencies - // Since we're not sure about decimals for these currencies, we prefer - // to not support them for now. - // "HUF": 2, // Hungarian Forint - } -) diff --git a/cmd/connectors/internal/connectors/bankingcircle/errors.go b/cmd/connectors/internal/connectors/bankingcircle/errors.go deleted file mode 100644 index 64117100..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/errors.go +++ /dev/null @@ -1,29 +0,0 @@ -package bankingcircle - -import "github.com/pkg/errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingUsername is returned when the username is missing. - ErrMissingUsername = errors.New("missing username from config") - - // ErrMissingPassword is returned when the password is missing. - ErrMissingPassword = errors.New("missing password from config") - - // ErrMissingEndpoint is returned when the endpoint is missing. - ErrMissingEndpoint = errors.New("missing endpoint from config") - - // ErrMissingAuthorizationEndpoint is returned when the authorization endpoint is missing. - ErrMissingAuthorizationEndpoint = errors.New("missing authorization endpoint from config") - - // ErrMissingUserCertificate is returned when the user certificate is missing. - ErrMissingUserCertificate = errors.New("missing user certificate from config") - - // ErrMissingUserCertificatePassphrase is returned when the user certificate passphrase is missing. - ErrMissingUserCertificatePassphrase = errors.New("missing user certificate passphrase from config") - - // ErrMissingClientCertificate is returned when the client certificate is missing. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/bankingcircle/loader.go b/cmd/connectors/internal/connectors/bankingcircle/loader.go deleted file mode 100644 index 8df156b5..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/loader.go +++ /dev/null @@ -1,47 +0,0 @@ -package bankingcircle - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/task_create_external_account.go b/cmd/connectors/internal/connectors/bankingcircle/task_create_external_account.go deleted file mode 100644 index 46e175b3..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/task_create_external_account.go +++ /dev/null @@ -1,88 +0,0 @@ -package bankingcircle - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "go.opentelemetry.io/otel/attribute" -) - -// No need to call any API for banking circle since it does not support it. -// We will just create an external accounts on our side linked to the -// bank account object. -func taskCreateExternalAccount( - client *client.Client, - bankAccountID uuid.UUID, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - storageReader storage.Reader, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "bankingcircle.taskCreateExternalAccount", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("bankAccount.id", bankAccountID.String()), - ) - defer span.End() - - bankAccount, err := storageReader.GetBankAccount(ctx, bankAccountID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - span.SetAttributes(attribute.String("bankAccount.name", bankAccount.Name)) - - if err := createExternalAccount(ctx, connectorID, ingester, storageReader, bankAccount); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func createExternalAccount( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - storageReader storage.Reader, - bankAccount *models.BankAccount, -) error { - accountID := models.AccountID{ - Reference: bankAccount.ID.String(), - ConnectorID: connectorID, - } - - if err := ingester.IngestAccounts(ctx, ingestion.AccountBatch{ - { - ID: accountID, - CreatedAt: time.Now(), - Reference: bankAccount.ID.String(), - ConnectorID: connectorID, - AccountName: bankAccount.Name, - Type: models.AccountTypeExternalFormance, - }, - }); err != nil { - return err - } - - if err := ingester.LinkBankAccountWithAccount(ctx, bankAccount, &accountID); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/task_fetch_accounts.go b/cmd/connectors/internal/connectors/bankingcircle/task_fetch_accounts.go deleted file mode 100644 index e3959b52..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/task_fetch_accounts.go +++ /dev/null @@ -1,158 +0,0 @@ -package bankingcircle - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchAccounts( - client *client.Client, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "bankingcircle.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - if err := fetchAccount(ctx, client, connectorID, scheduler, ingester); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchAccount( - ctx context.Context, - client *client.Client, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, -) error { - for page := 1; ; page++ { - pagedAccounts, err := client.GetAccounts(ctx, page) - if err != nil { - return err - } - - if len(pagedAccounts) == 0 { - break - } - - if err := ingestAccountsBatch(ctx, connectorID, ingester, pagedAccounts); err != nil { - return err - } - } - - // We want to fetch payments after inserting all accounts in order to - // ling them correctly - taskPayments, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch payments from client", - Key: taskNameFetchPayments, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskPayments, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func ingestAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accounts []*client.Account, -) error { - accountsBatch := ingestion.AccountBatch{} - balanceBatch := ingestion.BalanceBatch{} - - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - openingDate, err := time.Parse("2006-01-02T15:04:05.999999999+00:00", account.OpeningDate) - if err != nil { - return fmt.Errorf("failed to parse opening date: %w", err) - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: account.AccountID, - ConnectorID: connectorID, - }, - CreatedAt: openingDate, - Reference: account.AccountID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency), - AccountName: account.AccountDescription, - Type: models.AccountTypeInternal, - RawData: raw, - }) - - for _, balance := range account.Balances { - // No need to check if the currency is supported for accounts and - // balances. - precision := supportedCurrenciesWithDecimal[balance.Currency] - - amount, err := currency.GetAmountWithPrecisionFromString(balance.IntraDayAmount.String(), precision) - if err != nil { - return err - } - - now := time.Now() - balanceBatch = append(balanceBatch, &models.Balance{ - AccountID: models.AccountID{ - Reference: account.AccountID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Currency), - Balance: amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - }) - } - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return err - } - - if err := ingester.IngestBalances(ctx, balanceBatch, false); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/task_fetch_payments.go b/cmd/connectors/internal/connectors/bankingcircle/task_fetch_payments.go deleted file mode 100644 index 3974e61f..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/task_fetch_payments.go +++ /dev/null @@ -1,165 +0,0 @@ -package bankingcircle - -import ( - "context" - "encoding/json" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchPayments( - client *client.Client, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "bankingcircle.taskFetchPayments", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - if err := fetchPayments(ctx, client, connectorID, ingester); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchPayments( - ctx context.Context, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, -) error { - for page := 1; ; page++ { - pagedPayments, err := client.GetPayments(ctx, page) - if err != nil { - return err - } - - if len(pagedPayments) == 0 { - break - } - - if err := ingestBatch(ctx, connectorID, ingester, pagedPayments); err != nil { - return err - } - } - - return nil -} - -func ingestBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - payments []*client.Payment, -) error { - batch := ingestion.PaymentBatch{} - - for _, paymentEl := range payments { - raw, err := json.Marshal(paymentEl) - if err != nil { - return err - } - - paymentType := matchPaymentType(paymentEl.Classification) - - precision, ok := supportedCurrenciesWithDecimal[paymentEl.Transfer.Amount.Currency] - if !ok { - continue - } - - amount, err := currency.GetAmountWithPrecisionFromString(paymentEl.Transfer.Amount.Amount.String(), precision) - if err != nil { - return err - } - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: paymentEl.PaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - Reference: paymentEl.PaymentID, - Type: paymentType, - ConnectorID: connectorID, - Status: matchPaymentStatus(paymentEl.Status), - Scheme: models.PaymentSchemeOther, - Amount: amount, - InitialAmount: amount, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, paymentEl.Transfer.Amount.Currency), - RawData: raw, - }, - } - - if paymentEl.DebtorInformation.AccountID != "" { - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: paymentEl.DebtorInformation.AccountID, - ConnectorID: connectorID, - } - } - - if paymentEl.CreditorInformation.AccountID != "" { - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: paymentEl.CreditorInformation.AccountID, - ConnectorID: connectorID, - } - } - - batch = append(batch, batchElement) - } - - if err := ingester.IngestPayments(ctx, batch); err != nil { - return err - } - - return nil -} - -func matchPaymentStatus(paymentStatus string) models.PaymentStatus { - switch paymentStatus { - case "Processed": - return models.PaymentStatusSucceeded - // On MissingFunding - the payment is still in progress. - // If there will be funds available within 10 days - the payment will be processed. - // Otherwise - it will be cancelled. - case "PendingProcessing", "MissingFunding": - return models.PaymentStatusPending - case "Rejected", "Cancelled", "Reversed", "Returned": - return models.PaymentStatusFailed - } - - return models.PaymentStatusOther -} - -func matchPaymentType(paymentType string) models.PaymentType { - switch paymentType { - case "Incoming": - return models.PaymentTypePayIn - case "Outgoing": - return models.PaymentTypePayOut - case "Own": - return models.PaymentTypeTransfer - } - - return models.PaymentTypeOther -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/task_main.go b/cmd/connectors/internal/connectors/bankingcircle/task_main.go deleted file mode 100644 index b4ea3fc8..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/task_main.go +++ /dev/null @@ -1,50 +0,0 @@ -package bankingcircle - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "bankingcircle.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/task_payments.go b/cmd/connectors/internal/connectors/bankingcircle/task_payments.go deleted file mode 100644 index 043217c1..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/task_payments.go +++ /dev/null @@ -1,377 +0,0 @@ -package bankingcircle - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle/client" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskInitiatePayment(bankingCircleClient *client.Client, transferID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "bankingcircle.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, bankingCircleClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - bankingCircleClient *client.Client, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount == nil { - err = errors.New("no source account provided") - return err - } - - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - - var curr string - var precision int - curr, precision, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - amount, err := currency.GetStringAmountFromBigIntWithPrecision(transfer.Amount, precision) - if err != nil { - return err - } - - var sourceAccount *client.Account - sourceAccount, err = bankingCircleClient.GetAccount(ctx, transfer.SourceAccountID.Reference) - if err != nil { - return err - } - if len(sourceAccount.AccountIdentifiers) == 0 { - err = errors.New("no source account identifiers provided") - return err - } - - var destinationAccount *client.Account - destinationAccount, err = bankingCircleClient.GetAccount(ctx, transfer.DestinationAccountID.Reference) - if err != nil { - return err - } - if len(destinationAccount.AccountIdentifiers) == 0 { - err = errors.New("no destination account identifiers provided") - return err - } - - var connectorPaymentID string - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - var resp *client.PaymentResponse - resp, err = bankingCircleClient.InitiateTransferOrPayouts(ctx, &client.PaymentRequest{ - IdempotencyKey: transfer.ID.Reference, - RequestedExecutionDate: transfer.ScheduledAt, - DebtorAccount: client.PaymentAccount{ - Account: sourceAccount.AccountIdentifiers[0].Account, - FinancialInstitution: sourceAccount.AccountIdentifiers[0].FinancialInstitution, - Country: sourceAccount.AccountIdentifiers[0].Country, - }, - DebtorReference: transfer.Description, - CurrencyOfTransfer: curr, - Amount: struct { - Currency string "json:\"currency\"" - Amount json.Number "json:\"amount\"" - }{ - Currency: curr, - Amount: json.Number(amount), - }, - ChargeBearer: "SHA", - CreditorAccount: &client.PaymentAccount{ - Account: destinationAccount.AccountIdentifiers[0].Account, - FinancialInstitution: destinationAccount.AccountIdentifiers[0].FinancialInstitution, - Country: destinationAccount.AccountIdentifiers[0].Country, - }, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.PaymentID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - var resp *client.PaymentResponse - resp, err = bankingCircleClient.InitiateTransferOrPayouts(ctx, &client.PaymentRequest{ - IdempotencyKey: transfer.ID.Reference, - RequestedExecutionDate: transfer.ScheduledAt, - DebtorAccount: client.PaymentAccount{ - Account: sourceAccount.AccountIdentifiers[0].Account, - FinancialInstitution: sourceAccount.AccountIdentifiers[0].FinancialInstitution, - Country: sourceAccount.AccountIdentifiers[0].Country, - }, - DebtorReference: transfer.Description, - CurrencyOfTransfer: curr, - Amount: struct { - Currency string "json:\"currency\"" - Amount json.Number "json:\"amount\"" - }{ - Currency: curr, - Amount: json.Number(amount), - }, - ChargeBearer: "SHA", - CreditorAccount: &client.PaymentAccount{ - Account: destinationAccount.AccountIdentifiers[0].Account, - FinancialInstitution: destinationAccount.AccountIdentifiers[0].FinancialInstitution, - Country: destinationAccount.AccountIdentifiers[0].Country, - }, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.PaymentID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: connectorPaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - var taskDescriptor models.TaskDescriptor - taskDescriptor, err = models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func taskUpdatePaymentStatus( - bankingCircleClient *client.Client, - transferID string, - pID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "bankingcircle.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", pID), - attribute.Int("attempt", attempt), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := updatePaymentStatus(ctx, bankingCircleClient, transfer, paymentID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func updatePaymentStatus( - ctx context.Context, - bankingCircleClient *client.Client, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var status string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - var resp *client.StatusResponse - resp, err = bankingCircleClient.GetPaymentStatus(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - case models.TransferInitiationTypePayout: - var resp *client.StatusResponse - resp, err = bankingCircleClient.GetPaymentStatus(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - } - - switch status { - case "PendingApproval", "PendingProcessing", "Hold", "Approved", "ScaPending": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: 2 * time.Minute, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - case "Processed": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - case "Unknown", "ScaExpired", "ScaFailed", "MissingFunding", - "PendingCancellation", "PendingCancellationApproval", "DeclinedByApprover", - "Rejected", "Cancelled", "Reversed", "ScaDeclined": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, "", time.Now()) - if err != nil { - return err - } - - return nil - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/bankingcircle/task_resolve.go b/cmd/connectors/internal/connectors/bankingcircle/task_resolve.go deleted file mode 100644 index 7d82466f..00000000 --- a/cmd/connectors/internal/connectors/bankingcircle/task_resolve.go +++ /dev/null @@ -1,72 +0,0 @@ -package bankingcircle - -import ( - "fmt" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/bankingcircle/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/google/uuid" -) - -const ( - taskNameMain = "main" - taskNameFetchPayments = "fetch-payments" - taskNameFetchAccounts = "fetch-accounts" - taskNameInitiatePayment = "initiate-payment" - taskNameUpdatePaymentStatus = "update-payment-status" - taskNameCreateExternalAccount = "create-external-account" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - TransferID string `json:"transferID" yaml:"transferID" bson:"transferID"` - PaymentID string `json:"paymentID" yaml:"paymentID" bson:"paymentID"` - BankAccountID uuid.UUID `json:"bankAccountID" yaml:"bankAccountID" bson:"bankAccountID"` - Attempt int `json:"attempt" yaml:"attempt" bson:"attempt"` -} - -func resolveTasks(logger logging.Logger, config Config) func(taskDefinition TaskDescriptor) task.Task { - bankingCircleClient, err := client.NewClient( - config.Username, - config.Password, - config.Endpoint, - config.AuthorizationEndpoint, - config.UserCertificate, - config.UserCertificateKey, - logger, - ) - if err != nil { - logger.Error(err) - - return func(taskDescriptor TaskDescriptor) task.Task { - return func() error { - return fmt.Errorf("cannot build banking circle client: %w", err) - } - } - } - - return func(taskDescriptor TaskDescriptor) task.Task { - switch taskDescriptor.Key { - case taskNameMain: - return taskMain() - case taskNameFetchPayments: - return taskFetchPayments(bankingCircleClient) - case taskNameFetchAccounts: - return taskFetchAccounts(bankingCircleClient) - case taskNameInitiatePayment: - return taskInitiatePayment(bankingCircleClient, taskDescriptor.TransferID) - case taskNameUpdatePaymentStatus: - return taskUpdatePaymentStatus(bankingCircleClient, taskDescriptor.TransferID, taskDescriptor.PaymentID, taskDescriptor.Attempt) - case taskNameCreateExternalAccount: - return taskCreateExternalAccount(bankingCircleClient, taskDescriptor.BankAccountID) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDescriptor.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/configtemplate/template.go b/cmd/connectors/internal/connectors/configtemplate/template.go deleted file mode 100644 index 188dca93..00000000 --- a/cmd/connectors/internal/connectors/configtemplate/template.go +++ /dev/null @@ -1,37 +0,0 @@ -package configtemplate - -type Configs map[string]Config - -type Config map[string]Parameter - -type Parameter struct { - DataType Type `json:"dataType"` - Required bool `json:"required"` - DefaultValue string `json:"defaultValue"` -} - -type TemplateBuilder interface { - BuildTemplate() (string, Config) -} - -func BuildConfigs(builders ...TemplateBuilder) Configs { - configs := make(map[string]Config) - for _, builder := range builders { - name, config := builder.BuildTemplate() - configs[name] = config - } - - return configs -} - -func NewConfig() Config { - return make(map[string]Parameter) -} - -func (c *Config) AddParameter(name string, dataType Type, defaultValue string, required bool) { - (*c)[name] = Parameter{ - DataType: dataType, - Required: required, - DefaultValue: defaultValue, - } -} diff --git a/cmd/connectors/internal/connectors/configtemplate/types.go b/cmd/connectors/internal/connectors/configtemplate/types.go deleted file mode 100644 index ba118145..00000000 --- a/cmd/connectors/internal/connectors/configtemplate/types.go +++ /dev/null @@ -1,11 +0,0 @@ -package configtemplate - -type Type string - -const ( - TypeLongString Type = "long string" - TypeString Type = "string" - TypeDurationNs Type = "duration ns" - TypeDurationUnsignedInteger Type = "unsigned integer" - TypeBoolean Type = "boolean" -) diff --git a/cmd/connectors/internal/connectors/connector.go b/cmd/connectors/internal/connectors/connector.go deleted file mode 100644 index 64f3332b..00000000 --- a/cmd/connectors/internal/connectors/connector.go +++ /dev/null @@ -1,28 +0,0 @@ -package connectors - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -// Connector provide entry point to a payment provider. -type Connector interface { - // Install is used to start the connector. The implementation if in charge of scheduling all required resources. - Install(ctx task.ConnectorContext) error - // Uninstall is used to uninstall the connector. It has to close all related resources opened by the connector. - Uninstall(ctx context.Context) error - // UpdateConfig is used to update the configuration of the connector. - UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error - // Resolve is used to recover state of a failed or restarted task - Resolve(descriptor models.TaskDescriptor) task.Task - // InitiateTransfer is used to initiate a transfer from the connector to a bank account. - InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error - // ReverssePayment is used to reverse a transfer from the connector. - ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error - // CreateExternalBankAccount is used to create a bank account on the connector side. - CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error - // GetSupportedCurrenciesAndDecimals returns a map of supported currencies and their decimals. - SupportedCurrenciesAndDecimals() map[string]int -} diff --git a/cmd/connectors/internal/connectors/context.go b/cmd/connectors/internal/connectors/context.go deleted file mode 100644 index f685b718..00000000 --- a/cmd/connectors/internal/connectors/context.go +++ /dev/null @@ -1,19 +0,0 @@ -package connectors - -import ( - "context" - - "github.com/google/uuid" -) - -type webhookIDKey struct{} - -var _webhookIDKey = webhookIDKey{} - -func ContextWithWebhookID(ctx context.Context, id uuid.UUID) context.Context { - return context.WithValue(ctx, _webhookIDKey, id) -} - -func WebhookIDFromContext(ctx context.Context) uuid.UUID { - return ctx.Value(_webhookIDKey).(uuid.UUID) -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/accounts.go b/cmd/connectors/internal/connectors/currencycloud/client/accounts.go deleted file mode 100644 index 0d2a2ae6..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/accounts.go +++ /dev/null @@ -1,64 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Account struct { - ID string `json:"id"` - AccountName string `json:"account_name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (c *Client) GetAccounts(ctx context.Context, page int) ([]*Account, int, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "list_accounts") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.buildEndpoint("v2/accounts/find"), http.NoBody) - if err != nil { - return nil, 0, fmt.Errorf("failed to create request: %w", err) - } - - q := req.URL.Query() - q.Add("per_page", "25") - q.Add("page", fmt.Sprint(page)) - q.Add("order", "updated_at") - q.Add("order_asc_desc", "asc") - req.URL.RawQuery = q.Encode() - - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, 0, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - //nolint:tagliatelle // allow for client code - type response struct { - Accounts []*Account `json:"accounts"` - Pagination struct { - NextPage int `json:"next_page"` - } `json:"pagination"` - } - - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err - } - - return res.Accounts, res.Pagination.NextPage, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/auth.go b/cmd/connectors/internal/connectors/currencycloud/client/auth.go deleted file mode 100644 index e846f678..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/auth.go +++ /dev/null @@ -1,57 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -func (c *Client) authenticate(ctx context.Context) (string, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "authenticate") - now := time.Now() - defer f(ctx, now) - - form := make(url.Values) - - form.Add("login_id", c.loginID) - form.Add("api_key", c.apiKey) - - req, err := http.NewRequest(http.MethodPost, - c.buildEndpoint("v2/authenticate/api"), strings.NewReader(form.Encode())) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to do get request: %w", err) - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", unmarshalError(resp.StatusCode, resp.Body).Error() - } - - //nolint:tagliatelle // allow for client code - type response struct { - AuthToken string `json:"auth_token"` - } - - var res response - - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return "", fmt.Errorf("failed to decode response body: %w", err) - } - - return res.AuthToken, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/balances.go b/cmd/connectors/internal/connectors/currencycloud/client/balances.go deleted file mode 100644 index fc052751..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/balances.go +++ /dev/null @@ -1,66 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Balance struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (c *Client) GetBalances(ctx context.Context, page int) ([]*Balance, int, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "list_balances") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - c.buildEndpoint("v2/balances/find"), http.NoBody) - if err != nil { - return nil, 0, fmt.Errorf("failed to create request: %w", err) - } - - q := req.URL.Query() - q.Add("page", fmt.Sprint(page)) - q.Add("per_page", "25") - q.Add("order", "created_at") - q.Add("order_asc_desc", "asc") - req.URL.RawQuery = q.Encode() - - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, 0, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - //nolint:tagliatelle // allow for client code - type response struct { - Balances []*Balance `json:"balances"` - Pagination struct { - NextPage int `json:"next_page"` - } `json:"pagination"` - } - - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err - } - - return res.Balances, res.Pagination.NextPage, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/beneficiaries.go b/cmd/connectors/internal/connectors/currencycloud/client/beneficiaries.go deleted file mode 100644 index ea498f5f..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/beneficiaries.go +++ /dev/null @@ -1,66 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Beneficiary struct { - ID string `json:"id"` - BankAccountHolderName string `json:"bank_account_holder_name"` - Name string `json:"name"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` - // Contains a lot more fields that will be not used on our side for now -} - -func (c *Client) GetBeneficiaries(ctx context.Context, page int) ([]*Beneficiary, int, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "list_beneficiaries") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.buildEndpoint("v2/beneficiaries/find"), http.NoBody) - if err != nil { - return nil, 0, fmt.Errorf("failed to create request: %w", err) - } - - q := req.URL.Query() - q.Add("page", fmt.Sprint(page)) - q.Add("per_page", "25") - q.Add("order", "created_at") - q.Add("order_asc_desc", "asc") - req.URL.RawQuery = q.Encode() - - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, 0, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - //nolint:tagliatelle // allow for client code - type response struct { - Beneficiaries []*Beneficiary `json:"beneficiaries"` - Pagination struct { - NextPage int `json:"next_page"` - } `json:"pagination"` - } - - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err - } - - return res.Beneficiaries, res.Pagination.NextPage, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/client.go b/cmd/connectors/internal/connectors/currencycloud/client/client.go deleted file mode 100644 index 4b11e340..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/client.go +++ /dev/null @@ -1,74 +0,0 @@ -package client - -import ( - "context" - "fmt" - "net/http" - "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -type apiTransport struct { - authToken string - underlying *otelhttp.Transport -} - -func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Add("X-Auth-Token", t.authToken) - - return t.underlying.RoundTrip(req) -} - -type Client struct { - httpClient *http.Client - endpoint string - loginID string - apiKey string -} - -func (c *Client) buildEndpoint(path string, args ...interface{}) string { - return fmt.Sprintf("%s/%s", c.endpoint, fmt.Sprintf(path, args...)) -} - -const DevAPIEndpoint = "https://devapi.currencycloud.com" - -func newAuthenticatedHTTPClient(authToken string) *http.Client { - return &http.Client{ - Transport: &apiTransport{ - authToken: authToken, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - } -} - -func newHTTPClient() *http.Client { - return &http.Client{ - Transport: otelhttp.NewTransport(http.DefaultTransport), - } -} - -// NewClient creates a new client for the CurrencyCloud API. -func NewClient(loginID, apiKey, endpoint string) (*Client, error) { - if endpoint == "" { - endpoint = DevAPIEndpoint - } - - c := &Client{ - httpClient: newHTTPClient(), - endpoint: endpoint, - loginID: loginID, - apiKey: apiKey, - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - authToken, err := c.authenticate(ctx) - if err != nil { - return nil, err - } - - c.httpClient = newAuthenticatedHTTPClient(authToken) - - return c, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/contact.go b/cmd/connectors/internal/connectors/currencycloud/client/contact.go deleted file mode 100644 index e888bac6..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/contact.go +++ /dev/null @@ -1,57 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Contact struct { - ID string `json:"id"` -} - -func (c *Client) GetContactID(ctx context.Context, accountID string) (*Contact, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "list_contacts") - now := time.Now() - defer f(ctx, now) - - form := url.Values{} - form.Set("account_id", accountID) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.buildEndpoint("/v2/contacts/find"), strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - type Contacts struct { - Contacts []*Contact `json:"contacts"` - } - - var res Contacts - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if len(res.Contacts) == 0 { - return nil, fmt.Errorf("no contact found for account %s", accountID) - } - - return res.Contacts[0], nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/error.go b/cmd/connectors/internal/connectors/currencycloud/client/error.go deleted file mode 100644 index 93461a14..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/error.go +++ /dev/null @@ -1,45 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "io" -) - -type currencyCloudError struct { - StatusCode int `json:"status_code"` - ErrorCode string `json:"error_code"` - ErrorMessages map[string][]*errorMessage `json:"error_messages"` -} - -type errorMessage struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func (ce *currencyCloudError) Error() error { - var errorMessage string - if len(ce.ErrorMessages) > 0 { - for _, message := range ce.ErrorMessages { - if len(message) > 0 { - errorMessage = message[0].Message - break - } - } - } - - if errorMessage == "" { - return fmt.Errorf("unexpected status code: %d", ce.StatusCode) - } - - return fmt.Errorf("%s: %s", ce.ErrorCode, errorMessage) -} - -func unmarshalError(statusCode int, body io.ReadCloser) *currencyCloudError { - var ce currencyCloudError - _ = json.NewDecoder(body).Decode(&ce) - - ce.StatusCode = statusCode - - return &ce -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/payout.go b/cmd/connectors/internal/connectors/currencycloud/client/payout.go deleted file mode 100644 index abcd640b..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/payout.go +++ /dev/null @@ -1,116 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type PayoutRequest struct { - OnBehalfOf string `json:"on_behalf_of"` - BeneficiaryID string `json:"beneficiary_id"` - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - Reference string `json:"reference"` - UniqueRequestID string `json:"unique_request_id"` -} - -func (pr *PayoutRequest) ToFormData() url.Values { - form := url.Values{} - form.Set("on_behalf_of", pr.OnBehalfOf) - form.Set("beneficiary_id", pr.BeneficiaryID) - form.Set("currency", pr.Currency) - form.Set("amount", pr.Amount.String()) - form.Set("reference", pr.Reference) - if pr.UniqueRequestID != "" { - form.Set("unique_request_id", pr.UniqueRequestID) - } - - return form -} - -type PayoutResponse struct { - ID string `json:"id"` - Amount string `json:"amount"` - BeneficiaryID string `json:"beneficiary_id"` - Currency string `json:"currency"` - Reference string `json:"reference"` - Status string `json:"status"` - Reason string `json:"reason"` - CreatorContactID string `json:"creator_contact_id"` - PaymentType string `json:"payment_type"` - TransferredAt string `json:"transferred_at"` - PaymentDate string `json:"payment_date"` - FailureReason string `json:"failure_reason"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - UniqueRequestID string `json:"unique_request_id"` -} - -func (c *Client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "initiate_payout") - now := time.Now() - defer f(ctx, now) - - form := payoutRequest.ToFormData() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.buildEndpoint("v2/payments/create"), strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - var payoutResponse PayoutResponse - if err = json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, err - } - - return &payoutResponse, nil -} - -func (c *Client) GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "get_payment") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - c.buildEndpoint("v2/payments/%s", payoutID), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - var payoutResponse PayoutResponse - if err = json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, err - } - - return &payoutResponse, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/transactions.go b/cmd/connectors/internal/connectors/currencycloud/client/transactions.go deleted file mode 100644 index 6b86b23d..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/transactions.go +++ /dev/null @@ -1,79 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -//nolint:tagliatelle // allow different styled tags in client -type Transaction struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Currency string `json:"currency"` - Type string `json:"type"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Action string `json:"action"` - - Amount json.Number `json:"amount"` -} - -func (c *Client) GetTransactions(ctx context.Context, page int, updatedAtFrom time.Time) ([]Transaction, int, error) { - if page < 1 { - return nil, 0, fmt.Errorf("page must be greater than 0") - } - - f := connectors.ClientMetrics(ctx, "currencycloud", "list_transactions") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - c.buildEndpoint("v2/transactions/find"), http.NoBody) - if err != nil { - return nil, 0, fmt.Errorf("failed to create request: %w", err) - } - - q := req.URL.Query() - q.Add("page", fmt.Sprint(page)) - q.Add("per_page", "25") - if !updatedAtFrom.IsZero() { - q.Add("updated_at_from", updatedAtFrom.Format(time.DateOnly)) - } - q.Add("order", "updated_at") - q.Add("order_asc_desc", "asc") - req.URL.RawQuery = q.Encode() - - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, 0, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, 0, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - //nolint:tagliatelle // allow for client code - type response struct { - Transactions []Transaction `json:"transactions"` - Pagination struct { - NextPage int `json:"next_page"` - } `json:"pagination"` - } - - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err - } - - return res.Transactions, res.Pagination.NextPage, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/client/transfer.go b/cmd/connectors/internal/connectors/currencycloud/client/transfer.go deleted file mode 100644 index 6f75ae68..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/client/transfer.go +++ /dev/null @@ -1,116 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type TransferRequest struct { - SourceAccountID string `json:"source_account_id"` - DestinationAccountID string `json:"destination_account_id"` - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - Reason string `json:"reason,omitempty"` - UniqueRequestID string `json:"unique_request_id,omitempty"` -} - -func (tr *TransferRequest) ToFormData() url.Values { - form := url.Values{} - form.Set("source_account_id", tr.SourceAccountID) - form.Set("destination_account_id", tr.DestinationAccountID) - form.Set("currency", tr.Currency) - form.Set("amount", fmt.Sprintf("%v", tr.Amount)) - if tr.Reason != "" { - form.Set("reason", tr.Reason) - } - if tr.UniqueRequestID != "" { - form.Set("unique_request_id", tr.UniqueRequestID) - } - - return form -} - -type TransferResponse struct { - ID string `json:"id"` - ShortReference string `json:"short_reference"` - SourceAccountID string `json:"source_account_id"` - DestinationAccountID string `json:"destination_account_id"` - Currency string `json:"currency"` - Amount string `json:"amount"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - CompletedAt string `json:"completed_at"` - CreatorAccountID string `json:"creator_account_id"` - CreatorContactID string `json:"creator_contact_id"` - Reason string `json:"reason"` - UniqueRequestID string `json:"unique_request_id"` -} - -func (c *Client) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "initiate_transfer") - now := time.Now() - defer f(ctx, now) - - form := transferRequest.ToFormData() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.buildEndpoint("v2/transfers/create"), strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - var res TransferResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} - -func (c *Client) GetTransfer(ctx context.Context, transferID string) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "currencycloud", "get_transfer") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - c.buildEndpoint("v2/transfers/%s", transferID), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Add("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - var res TransferResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/config.go b/cmd/connectors/internal/connectors/currencycloud/config.go deleted file mode 100644 index 1512801a..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/config.go +++ /dev/null @@ -1,65 +0,0 @@ -package currencycloud - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" -) - -const ( - defaultPollingDuration = 2 * time.Minute -) - -type Config struct { - Name string `json:"name" bson:"name"` - LoginID string `json:"loginID" bson:"loginID"` - APIKey string `json:"apiKey" bson:"apiKey"` - Endpoint string `json:"endpoint" bson:"endpoint"` - PollingPeriod connectors.Duration `json:"pollingPeriod" bson:"pollingPeriod"` -} - -// String obfuscates sensitive fields and returns a string representation of the config. -// This is used for logging. -func (c Config) String() string { - return fmt.Sprintf("loginID=%s, endpoint=%s, pollingPeriod=%s, apiKey=****", c.LoginID, c.Endpoint, c.PollingPeriod.String()) -} - -func (c Config) Validate() error { - if c.APIKey == "" { - return ErrMissingAPIKey - } - - if c.LoginID == "" { - return ErrMissingLoginID - } - - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("loginID", configtemplate.TypeString, "", true) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("endpoint", configtemplate.TypeString, client.DevAPIEndpoint, false) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingDuration.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/currencycloud/connector.go b/cmd/connectors/internal/connectors/currencycloud/connector.go deleted file mode 100644 index afa1d45f..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/connector.go +++ /dev/null @@ -1,136 +0,0 @@ -package currencycloud - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" -) - -const name = models.ConnectorProviderCurrencyCloud - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch users and transactions", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.cfg)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transfer *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/currencycloud/currencies.go b/cmd/connectors/internal/connectors/currencycloud/currencies.go deleted file mode 100644 index dd05ae31..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/currencies.go +++ /dev/null @@ -1,51 +0,0 @@ -package currencycloud - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - // c.f.: https://support.currencycloud.com/hc/en-gb/articles/7840216562972-Currency-Decimal-Places - supportedCurrenciesWithDecimal = map[string]int{ - "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar - "CAD": currency.ISO4217Currencies["CAD"], // Canadian Dollar - "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna - "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar - "INR": currency.ISO4217Currencies["INR"], // Indian Rupee - "IDR": currency.ISO4217Currencies["IDR"], // Indonesia, Rupiah - "ILS": currency.ISO4217Currencies["ILS"], // New Israeli Shekel - "JPY": currency.ISO4217Currencies["JPY"], // Japan, Yen - "KES": currency.ISO4217Currencies["KES"], // Kenyan Shilling - "MYR": currency.ISO4217Currencies["MYR"], // Malaysian Ringgit - "MXN": currency.ISO4217Currencies["MXN"], // Mexican Peso - "NZD": currency.ISO4217Currencies["NZD"], // New Zealand Dollar - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone - "PHP": currency.ISO4217Currencies["PHP"], // Philippine Peso - "PLN": currency.ISO4217Currencies["PLN"], // Poland, Zloty - "RON": currency.ISO4217Currencies["RON"], // Romania, New Leu - "SAR": currency.ISO4217Currencies["SAR"], // Saudi Riyal - "SGD": currency.ISO4217Currencies["SGD"], // Singapore Dollar - "ZAR": currency.ISO4217Currencies["ZAR"], // South Africa, Rand - "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona - "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc - "THB": currency.ISO4217Currencies["THB"], // Thailand, Baht - "TRY": currency.ISO4217Currencies["TRY"], // New Turkish Lira - "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling - "AED": currency.ISO4217Currencies["AED"], // UAE Dirham - "USD": currency.ISO4217Currencies["USD"], // US Dollar - "UGX": currency.ISO4217Currencies["UGX"], // Uganda Shilling - "QAR": currency.ISO4217Currencies["QAR"], // Qatari Riyal - - // Unsupported currencies - // the following currencies are not existing in ISO 4217, so we prefer - // not to support them for now. - // "CNH": 2, // Chinese Yuan - - // The following currencies have a different value in ISO 4217, so we - // prefer to not support them for now. - // "HUF": 0, // Hungarian Forint - // "KWD": 2, // Kuwaiti Dinar - // "OMR": 2, // Rial Omani - // "BHD": 2, // Bahraini Dinar - } -) diff --git a/cmd/connectors/internal/connectors/currencycloud/errors.go b/cmd/connectors/internal/connectors/currencycloud/errors.go deleted file mode 100644 index 4c4b5b96..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/errors.go +++ /dev/null @@ -1,23 +0,0 @@ -package currencycloud - -import "github.com/pkg/errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingAPIKey is returned when the api key is missing from config. - ErrMissingAPIKey = errors.New("missing apiKey from config") - - // ErrMissingLoginID is returned when the login id is missing from config. - ErrMissingLoginID = errors.New("missing loginID from config") - - // ErrMissingPollingPeriod is returned when the polling period is missing from config. - ErrMissingPollingPeriod = errors.New("missing pollingPeriod from config") - - // ErrDurationInvalid is returned when the duration is invalid. - ErrDurationInvalid = errors.New("duration is invalid") - - // ErrMissingName is returned when the name is missing from config. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/currencycloud/loader.go b/cmd/connectors/internal/connectors/currencycloud/loader.go deleted file mode 100644 index e42c2fa6..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/loader.go +++ /dev/null @@ -1,49 +0,0 @@ -package currencycloud - -import ( - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = 2 * time.Minute - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_fetch_accounts.go b/cmd/connectors/internal/connectors/currencycloud/task_fetch_accounts.go deleted file mode 100644 index 012fe993..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_fetch_accounts.go +++ /dev/null @@ -1,160 +0,0 @@ -package currencycloud - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -type fetchAccountsState struct { - LastPage int - LastCreatedAt time.Time -} - -func taskFetchAccounts( - client *client.Client, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchAccountsState{}) - if state.LastPage == 0 { - // First run, the first page for currencycloud starts at 1 and not 0 - state.LastPage = 1 - } - - newState, err := fetchAccount(ctx, client, connectorID, ingester, scheduler, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchAccount( - ctx context.Context, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - state fetchAccountsState, -) (fetchAccountsState, error) { - newState := fetchAccountsState{ - LastPage: state.LastPage, - LastCreatedAt: state.LastCreatedAt, - } - - page := state.LastPage - for { - if page < 0 { - break - } - - pagedAccounts, nextPage, err := client.GetAccounts(ctx, page) - if err != nil { - return fetchAccountsState{}, err - } - - page = nextPage - - batch := ingestion.AccountBatch{} - for _, account := range pagedAccounts { - switch account.CreatedAt.Compare(state.LastCreatedAt) { - case -1, 0: - // Account already ingested, skip - continue - default: - } - - raw, err := json.Marshal(account) - if err != nil { - return fetchAccountsState{}, err - } - - batch = append(batch, &models.Account{ - ID: models.AccountID{ - Reference: account.ID, - ConnectorID: connectorID, - }, - // Moneycorp does not send the opening date of the account - CreatedAt: account.CreatedAt, - Reference: account.ID, - ConnectorID: connectorID, - AccountName: account.AccountName, - Type: models.AccountTypeInternal, - RawData: raw, - }) - - newState.LastCreatedAt = account.CreatedAt - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return fetchAccountsState{}, err - } - } - - newState.LastPage = page - - taskTransactions, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transactions from client", - Key: taskNameFetchTransactions, - }) - if err != nil { - return fetchAccountsState{}, err - } - - err = scheduler.Schedule(ctx, taskTransactions, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchAccountsState{}, err - } - - taskBalances, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch balances from client", - Key: taskNameFetchBalances, - }) - if err != nil { - return fetchAccountsState{}, err - } - - err = scheduler.Schedule(ctx, taskBalances, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchAccountsState{}, err - } - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_fetch_balances.go b/cmd/connectors/internal/connectors/currencycloud/task_fetch_balances.go deleted file mode 100644 index f7ab4f5b..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_fetch_balances.go +++ /dev/null @@ -1,105 +0,0 @@ -package currencycloud - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchBalances( - client *client.Client, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskFetchBalances", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - if err := fetchBalances(ctx, client, connectorID, ingester); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchBalances( - ctx context.Context, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, -) error { - page := 1 - for { - if page < 0 { - break - } - - pagedBalances, nextPage, err := client.GetBalances(ctx, page) - if err != nil { - return err - } - - page = nextPage - - if err := ingestBalancesBatch(ctx, connectorID, ingester, pagedBalances); err != nil { - return err - } - } - - return nil -} - -func ingestBalancesBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - balances []*client.Balance, -) error { - batch := ingestion.BalanceBatch{} - for _, balance := range balances { - // No need to check if the currency is supported for accounts and balances. - precision := supportedCurrenciesWithDecimal[balance.Currency] - - amount, err := currency.GetAmountWithPrecisionFromString(balance.Amount.String(), precision) - if err != nil { - return err - } - - now := time.Now() - batch = append(batch, &models.Balance{ - AccountID: models.AccountID{ - Reference: balance.AccountID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Currency), - Balance: amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestBalances(ctx, batch, true); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_fetch_beneficiaries.go b/cmd/connectors/internal/connectors/currencycloud/task_fetch_beneficiaries.go deleted file mode 100644 index 451c2653..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_fetch_beneficiaries.go +++ /dev/null @@ -1,127 +0,0 @@ -package currencycloud - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -type fetchBeneficiariesState struct { - LastPage int - LastCreatedAt time.Time -} - -func taskFetchBeneficiaries( - client *client.Client, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskFetchBeneficiaries", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchBeneficiariesState{}) - if state.LastPage == 0 { - // First run, the first page for currencycloud starts at 1 and not 0 - state.LastPage = 1 - } - - newState, err := fetchBeneficiaries(ctx, client, connectorID, ingester, scheduler, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchBeneficiaries( - ctx context.Context, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - state fetchBeneficiariesState, -) (fetchBeneficiariesState, error) { - newState := fetchBeneficiariesState{ - LastPage: state.LastPage, - LastCreatedAt: state.LastCreatedAt, - } - - page := state.LastPage - for { - if page < 0 { - break - } - - pagedBeneficiaries, nextPage, err := client.GetBeneficiaries(ctx, page) - if err != nil { - return fetchBeneficiariesState{}, err - } - - page = nextPage - - batch := ingestion.AccountBatch{} - for _, beneficiary := range pagedBeneficiaries { - switch beneficiary.CreatedAt.Compare(state.LastCreatedAt) { - case -1, 0: - // Account already ingested, skip - continue - default: - } - - raw, err := json.Marshal(beneficiary) - if err != nil { - return fetchBeneficiariesState{}, err - } - - batch = append(batch, &models.Account{ - ID: models.AccountID{ - Reference: beneficiary.ID, - ConnectorID: connectorID, - }, - // Moneycorp does not send the opening date of the account - CreatedAt: beneficiary.CreatedAt, - Reference: beneficiary.ID, - ConnectorID: connectorID, - AccountName: beneficiary.Name, - Type: models.AccountTypeExternal, - RawData: raw, - }) - - newState.LastCreatedAt = beneficiary.CreatedAt - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return fetchBeneficiariesState{}, err - } - } - - newState.LastPage = page - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_fetch_transactions.go b/cmd/connectors/internal/connectors/currencycloud/task_fetch_transactions.go deleted file mode 100644 index e1fbb5d9..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_fetch_transactions.go +++ /dev/null @@ -1,179 +0,0 @@ -package currencycloud - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -type fetchTransactionsState struct { - LastUpdatedAt time.Time -} - -func taskFetchTransactions(client *client.Client, config Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskFetchTransactions", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchTransactionsState{}) - - newState, err := ingestTransactions(ctx, connectorID, client, ingester, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestTransactions( - ctx context.Context, - connectorID models.ConnectorID, - client *client.Client, - ingester ingestion.Ingester, - state fetchTransactionsState, -) (fetchTransactionsState, error) { - newState := fetchTransactionsState{ - LastUpdatedAt: state.LastUpdatedAt, - } - - page := 1 - for { - if page < 0 { - break - } - - transactions, nextPage, err := client.GetTransactions(ctx, page, state.LastUpdatedAt) - if err != nil { - return fetchTransactionsState{}, err - } - - page = nextPage - - batch := ingestion.PaymentBatch{} - - for _, transaction := range transactions { - switch transaction.UpdatedAt.Compare(state.LastUpdatedAt) { - case -1, 0: - continue - default: - } - - precision, ok := supportedCurrenciesWithDecimal[transaction.Currency] - if !ok { - continue - } - - amount, err := currency.GetAmountWithPrecisionFromString(transaction.Amount.String(), precision) - if err != nil { - return fetchTransactionsState{}, err - } - - var rawData json.RawMessage - - rawData, err = json.Marshal(transaction) - if err != nil { - return fetchTransactionsState{}, fmt.Errorf("failed to marshal transaction: %w", err) - } - - paymentType := matchTransactionType(transaction.Type) - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: transaction.ID, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - Reference: transaction.ID, - CreatedAt: transaction.CreatedAt, - Type: paymentType, - ConnectorID: connectorID, - Status: matchTransactionStatus(transaction.Status), - Scheme: models.PaymentSchemeOther, - Amount: amount, - InitialAmount: amount, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Currency), - RawData: rawData, - }, - } - - switch paymentType { - case models.PaymentTypePayOut: - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: transaction.AccountID, - ConnectorID: connectorID, - } - default: - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: transaction.AccountID, - ConnectorID: connectorID, - } - } - - batch = append(batch, batchElement) - - newState.LastUpdatedAt = transaction.UpdatedAt - } - - err = ingester.IngestPayments(ctx, batch) - if err != nil { - return fetchTransactionsState{}, err - } - } - - return newState, nil -} - -func matchTransactionType(transactionType string) models.PaymentType { - switch transactionType { - case "credit": - return models.PaymentTypePayIn - case "debit": - return models.PaymentTypePayOut - } - - return models.PaymentTypeOther -} - -func matchTransactionStatus(transactionStatus string) models.PaymentStatus { - switch transactionStatus { - case "completed": - return models.PaymentStatusSucceeded - case "pending": - return models.PaymentStatusPending - case "deleted": - return models.PaymentStatusFailed - } - - return models.PaymentStatusOther -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_main.go b/cmd/connectors/internal/connectors/currencycloud/task_main.go deleted file mode 100644 index 2c994ea8..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_main.go +++ /dev/null @@ -1,68 +0,0 @@ -package currencycloud - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - taskBeneficiaries, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch beneficiaries from client", - Key: taskNameFetchBeneficiaries, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskBeneficiaries, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_payments.go b/cmd/connectors/internal/connectors/currencycloud/task_payments.go deleted file mode 100644 index 30970e0a..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_payments.go +++ /dev/null @@ -1,328 +0,0 @@ -package currencycloud - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -func taskInitiatePayment(currencyCloudClient *client.Client, transferID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferInitiationID", transferInitiationID.String()), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, currencyCloudClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - currencyCloudClient *client.Client, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount == nil { - err = errors.New("no source account provided") - return err - } - - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - - var curr string - var precision int - curr, precision, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - amount, err := currency.GetStringAmountFromBigIntWithPrecision(transfer.Amount, precision) - if err != nil { - return err - } - - var connectorPaymentID string - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - var resp *client.TransferResponse - resp, err = currencyCloudClient.InitiateTransfer(ctx, &client.TransferRequest{ - SourceAccountID: transfer.SourceAccountID.Reference, - DestinationAccountID: transfer.DestinationAccountID.Reference, - Currency: curr, - Amount: json.Number(amount), - Reason: transfer.Description, - UniqueRequestID: fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments)), - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - contact, err := currencyCloudClient.GetContactID(ctx, transfer.SourceAccount.ID.Reference) - if err != nil { - return err - } - - var resp *client.PayoutResponse - resp, err = currencyCloudClient.InitiatePayout(ctx, &client.PayoutRequest{ - OnBehalfOf: contact.ID, - BeneficiaryID: transfer.DestinationAccount.Reference, - Currency: curr, - Amount: json.Number(amount), - Reference: transfer.Description, - UniqueRequestID: fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments)), - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: connectorPaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func taskUpdatePaymentStatus( - currencyCloudClient *client.Client, - transferID string, - pID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "currencycloud.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferInitiationID", transferInitiationID.String()), - attribute.String("paymentID", paymentID.String()), - attribute.Int("attempt", attempt), - attribute.String("reference", paymentID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := updatePaymentStatus(ctx, currencyCloudClient, transfer, paymentID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func updatePaymentStatus( - ctx context.Context, - currencyCloudClient *client.Client, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var status string - var resultMessage string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - resp, err := currencyCloudClient.GetTransfer(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - resultMessage = resp.Reason - case models.TransferInitiationTypePayout: - resp, err := currencyCloudClient.GetPayout(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - resultMessage = resp.Reason - } - - switch status { - case "pending": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: 2 * time.Minute, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - case "completed": - err := ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - case "cancelled": - err := ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, resultMessage, time.Now()) - if err != nil { - return err - } - - return nil - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/currencycloud/task_resolve.go b/cmd/connectors/internal/connectors/currencycloud/task_resolve.go deleted file mode 100644 index f4cdbad5..00000000 --- a/cmd/connectors/internal/connectors/currencycloud/task_resolve.go +++ /dev/null @@ -1,68 +0,0 @@ -package currencycloud - -import ( - "fmt" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currencycloud/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const ( - taskNameMain = "main" - taskNameFetchTransactions = "fetch-transactions" - taskNameFetchAccounts = "fetch-accounts" - taskNameFetchBeneficiaries = "fetch-beneficiaries" - taskNameFetchBalances = "fetch-balances" - taskNameInitiatePayment = "initiate-payment" - taskNameUpdatePaymentStatus = "update-payment-status" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - TransferID string `json:"transferID" yaml:"transferID" bson:"transferID"` - PaymentID string `json:"paymentID" yaml:"paymentID" bson:"paymentID"` - Attempt int `json:"attempt" yaml:"attempt" bson:"attempt"` -} - -func resolveTasks(config Config) func(taskDefinition TaskDescriptor) task.Task { - currencyCloudClient, err := client.NewClient(config.LoginID, config.APIKey, config.Endpoint) - if err != nil { - return func(taskDefinition TaskDescriptor) task.Task { - return func() error { - return fmt.Errorf("failed to initiate client: %w", err) - } - } - } - - return func(taskDescriptor TaskDescriptor) task.Task { - if taskDescriptor.Key == "" { - // Keep the compatibility with previous version if the connector. - // If the key is empty, use the name as the key. - taskDescriptor.Key = taskDescriptor.Name - } - - switch taskDescriptor.Key { - case taskNameMain: - return taskMain() - case taskNameFetchAccounts: - return taskFetchAccounts(currencyCloudClient) - case taskNameFetchBeneficiaries: - return taskFetchBeneficiaries(currencyCloudClient) - case taskNameFetchTransactions: - return taskFetchTransactions(currencyCloudClient, config) - case taskNameFetchBalances: - return taskFetchBalances(currencyCloudClient) - case taskNameInitiatePayment: - return taskInitiatePayment(currencyCloudClient, taskDescriptor.TransferID) - case taskNameUpdatePaymentStatus: - return taskUpdatePaymentStatus(currencyCloudClient, taskDescriptor.TransferID, taskDescriptor.PaymentID, taskDescriptor.Attempt) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDescriptor.Name, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/dummypay/config.go b/cmd/connectors/internal/connectors/dummypay/config.go deleted file mode 100644 index 148dad4f..00000000 --- a/cmd/connectors/internal/connectors/dummypay/config.go +++ /dev/null @@ -1,76 +0,0 @@ -package dummypay - -import ( - "encoding/json" - "fmt" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -// Config is the configuration for the dummy payment connector. -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - - // Directory is the directory where the files are stored. - Directory string `json:"directory" yaml:"directory" bson:"directory"` - - // FilePollingPeriod is the period between file polling. - FilePollingPeriod connectors.Duration `json:"filePollingPeriod" yaml:"filePollingPeriod" bson:"filePollingPeriod"` - - // PrefixFileToIngest is the prefix of the file to ingest. - PrefixFileToIngest string `json:"prefixFileToIngest" yaml:"prefixFileToIngest" bson:"prefixFileToIngest"` - - // NumberOfAccountsPreGenerated is the number of accounts to pre-generate. - NumberOfAccountsPreGenerated int `json:"numberOfAccountsPreGenerated" yaml:"numberOfAccountsPreGenerated" bson:"numberOfAccountsPreGenerated"` - // NumberOfPaymentsPreGenerated is the number of payments to pre-generate. - NumberOfPaymentsPreGenerated int `json:"numberOfPaymentsPreGenerated" yaml:"numberOfPaymentsPreGenerated" bson:"numberOfPaymentsPreGenerated"` -} - -// String returns a string representation of the configuration. -func (c Config) String() string { - return fmt.Sprintf("directory=%s, filePollingPeriod=%s", - c.Directory, c.FilePollingPeriod.String()) -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -// Validate validates the configuration. -func (c Config) Validate() error { - // require directory path to be present - if c.Directory == "" { - return ErrMissingDirectory - } - - // check if file polling period is set properly - if c.FilePollingPeriod.Duration <= 0 { - return fmt.Errorf("filePollingPeriod must be greater than 0: %w", - ErrFilePollingPeriodInvalid) - } - - if c.Name == "" { - return fmt.Errorf("name must be set: %w", ErrMissingName) - } - - return nil -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("directory", configtemplate.TypeString, "", true) - cfg.AddParameter("filePollingPeriod", configtemplate.TypeDurationNs, "", true) - cfg.AddParameter("prefixFileToIngest", configtemplate.TypeString, "", false) - cfg.AddParameter("numberOfAccountsPreGenerated", configtemplate.TypeDurationUnsignedInteger, "0", false) - cfg.AddParameter("numberOfPaymentsPreGenerated", configtemplate.TypeDurationUnsignedInteger, "0", false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/dummypay/config_test.go b/cmd/connectors/internal/connectors/dummypay/config_test.go deleted file mode 100644 index 224746a0..00000000 --- a/cmd/connectors/internal/connectors/dummypay/config_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package dummypay - -import ( - "os" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/stretchr/testify/assert" -) - -// TestConfigString tests the string representation of the config. -func TestConfigString(t *testing.T) { - t.Parallel() - - config := Config{ - Directory: "test", - FilePollingPeriod: connectors.Duration{Duration: time.Second}, - } - - assert.Equal(t, "directory=test, filePollingPeriod=1s", config.String()) -} - -// TestConfigValidate tests the validation of the config. -func TestConfigValidate(t *testing.T) { - t.Parallel() - - var config Config - - config.Name = "test1" - - // fail on missing directory - assert.EqualError(t, config.Validate(), ErrMissingDirectory.Error()) - - // fail on missing RW access to directory - config.Directory = "/non-existing" - assert.Error(t, config.Validate()) - - // set directory with RW access - userHomeDir, err := os.UserHomeDir() - if err != nil { - t.Error(err) - } - - config.Directory = userHomeDir - - // fail on invalid file polling period - config.FilePollingPeriod.Duration = -1 - assert.ErrorIs(t, config.Validate(), ErrFilePollingPeriodInvalid) - - // success - config.FilePollingPeriod.Duration = 1 - assert.NoError(t, config.Validate()) -} diff --git a/cmd/connectors/internal/connectors/dummypay/connector.go b/cmd/connectors/internal/connectors/dummypay/connector.go deleted file mode 100644 index a1d201b5..00000000 --- a/cmd/connectors/internal/connectors/dummypay/connector.go +++ /dev/null @@ -1,121 +0,0 @@ -package dummypay - -import ( - "context" - "fmt" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -// name is the name of the connector. -const name = models.ConnectorProviderDummyPay - -// Connector is the connector for the dummy payment connector. -type Connector struct { - logger logging.Logger - cfg Config - fs fs -} - -func newConnector(logger logging.Logger, cfg Config, fs fs) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - fs: fs, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - c.cfg = cfg - - return nil -} - -// Install executes post-installation steps to read and generate files. -// It is called after the connector is installed. -func (c *Connector) Install(ctx task.ConnectorContext) error { - initDirectoryDescriptor, err := models.EncodeTaskDescriptor(newTaskGenerateFiles()) - if err != nil { - return fmt.Errorf("failed to create generate files task descriptor: %w", err) - } - - if err = ctx.Scheduler().Schedule(ctx.Context(), initDirectoryDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW_SYNC, - RestartOption: models.OPTIONS_RESTART_NEVER, - }); err != nil { - return fmt.Errorf("failed to schedule task to generate files: %w", err) - } - - readFilesDescriptor, err := models.EncodeTaskDescriptor(newTaskReadFiles()) - if err != nil { - return fmt.Errorf("failed to create read files task descriptor: %w", err) - } - - if err = ctx.Scheduler().Schedule(ctx.Context(), readFilesDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.FilePollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }); err != nil { - return fmt.Errorf("failed to schedule task to read files: %w", err) - } - - return nil -} - -// Uninstall executes pre-uninstallation steps to remove the generated files. -// It is called before the connector is uninstalled. -func (c *Connector) Uninstall(ctx context.Context) error { - c.logger.Infof("Removing generated files from '%s'...", c.cfg.Directory) - - err := removeFiles(c.cfg, c.fs) - if err != nil { - return fmt.Errorf("failed to remove generated files: %w", err) - } - - return nil -} - -// Resolve resolves a task execution request based on the task descriptor. -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - c.logger.Infof("Executing '%s' task...", taskDescriptor.Key) - - return handleResolve(c.cfg, taskDescriptor, c.fs) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - // TODO implement me - return connectors.ErrNotImplemented -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - // TODO implement me - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - // TODO implement me - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/dummypay/connector_test.go b/cmd/connectors/internal/connectors/dummypay/connector_test.go deleted file mode 100644 index bf685f53..00000000 --- a/cmd/connectors/internal/connectors/dummypay/connector_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package dummypay - -import ( - "context" - "reflect" - "testing" - "time" - - "github.com/formancehq/payments/internal/models" - - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - - "github.com/formancehq/go-libs/logging" - "github.com/stretchr/testify/assert" -) - -// Create a minimal mock for connector installation. -type ( - mockConnectorContext struct { - ctx context.Context - } - mockScheduler struct{} -) - -func (mcc *mockConnectorContext) Context() context.Context { - return mcc.ctx -} - -func (mcc mockScheduler) Schedule(ctx context.Context, p models.TaskDescriptor, options models.TaskSchedulerOptions) error { - return nil -} - -func (mcc *mockConnectorContext) Scheduler() task.Scheduler { - return mockScheduler{} -} - -func TestConnector(t *testing.T) { - t.Parallel() - - config := Config{} - logger := logging.FromContext(context.TODO()) - - fileSystem := newTestFS() - - connector := newConnector(logger, config, fileSystem) - - err := connector.Install(new(mockConnectorContext)) - assert.NoErrorf(t, err, "Install() failed") - - testCases := []struct { - key taskKey - task task.Task - }{ - {taskKeyReadFiles, taskReadFiles(config, fileSystem)}, - {taskKeyInitDirectory, taskGenerateFiles(config, fileSystem)}, - {taskKeyIngest, taskIngest(config, TaskDescriptor{}, fileSystem)}, - } - - for _, testCase := range testCases { - var taskDescriptor models.TaskDescriptor - - taskDescriptor, err = models.EncodeTaskDescriptor(TaskDescriptor{Key: testCase.key}) - assert.NoErrorf(t, err, "EncodeTaskDescriptor() failed") - - assert.EqualValues(t, - reflect.ValueOf(testCase.task).String(), - reflect.ValueOf(connector.Resolve(taskDescriptor)).String(), - ) - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{Key: "test"}) - assert.NoErrorf(t, err, "EncodeTaskDescriptor() failed") - - assert.EqualValues(t, - reflect.ValueOf(func() error { return nil }).String(), - reflect.ValueOf(connector.Resolve(taskDescriptor)).String(), - ) - - assert.NoError(t, connector.Uninstall(context.TODO())) -} - -type MockIngester struct{} - -func (m *MockIngester) IngestAccounts(ctx context.Context, batch ingestion.AccountBatch) error { - return nil -} - -func (m *MockIngester) IngestPayments(ctx context.Context, batch ingestion.PaymentBatch) error { - return nil -} - -func (m *MockIngester) IngestBalances(ctx context.Context, batch ingestion.BalanceBatch, checkIfAccountExists bool) error { - return nil -} - -func (m *MockIngester) UpdateTaskState(ctx context.Context, state any) error { - return nil -} - -func (m *MockIngester) UpdateTransferInitiationPayment(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, status models.TransferInitiationStatus, errorMessage string, updatedAt time.Time) error { - return nil -} - -func (m *MockIngester) UpdateTransferInitiationPaymentsStatus(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, status models.TransferInitiationStatus, errorMessage string, updatedAt time.Time) error { - return nil -} - -func (m *MockIngester) AddTransferInitiationPaymentID(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, updatedAt time.Time) error { - return nil -} - -func (m *MockIngester) UpdateTransferReversalStatus(ctx context.Context, transfer *models.TransferInitiation, transferReversal *models.TransferReversal) error { - return nil -} - -func (m *MockIngester) LinkBankAccountWithAccount(ctx context.Context, bankAccount *models.BankAccount, accountID *models.AccountID) error { - return nil -} - -var _ ingestion.Ingester = (*MockIngester)(nil) diff --git a/cmd/connectors/internal/connectors/dummypay/currencies.go b/cmd/connectors/internal/connectors/dummypay/currencies.go deleted file mode 100644 index 75b0ef3d..00000000 --- a/cmd/connectors/internal/connectors/dummypay/currencies.go +++ /dev/null @@ -1,7 +0,0 @@ -package dummypay - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - supportedCurrenciesWithDecimal = currency.ISO4217Currencies -) diff --git a/cmd/connectors/internal/connectors/dummypay/errors.go b/cmd/connectors/internal/connectors/dummypay/errors.go deleted file mode 100644 index 9529b82c..00000000 --- a/cmd/connectors/internal/connectors/dummypay/errors.go +++ /dev/null @@ -1,23 +0,0 @@ -package dummypay - -import "github.com/pkg/errors" - -var ( - // ErrMissingDirectory is returned when the directory is missing. - ErrMissingDirectory = errors.New("missing directory to watch") - - // ErrFilePollingPeriodInvalid is returned when the file polling period is invalid. - ErrFilePollingPeriodInvalid = errors.New("file polling period is invalid") - - // ErrFileGenerationPeriodInvalid is returned when the file generation period is invalid. - ErrFileGenerationPeriodInvalid = errors.New("file generation period is invalid") - - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrDurationInvalid is returned when the duration is invalid. - ErrDurationInvalid = errors.New("duration is invalid") - - // ErrMissingName is returned when the name is missing. - ErrMissingName = errors.New("missing name") -) diff --git a/cmd/connectors/internal/connectors/dummypay/fs.go b/cmd/connectors/internal/connectors/dummypay/fs.go deleted file mode 100644 index 5224ccda..00000000 --- a/cmd/connectors/internal/connectors/dummypay/fs.go +++ /dev/null @@ -1,12 +0,0 @@ -package dummypay - -import ( - "github.com/spf13/afero" -) - -type fs afero.Fs - -// newFS creates a new file system access point. -func newFS() fs { - return afero.NewOsFs() -} diff --git a/cmd/connectors/internal/connectors/dummypay/fs_test.go b/cmd/connectors/internal/connectors/dummypay/fs_test.go deleted file mode 100644 index 36d374a5..00000000 --- a/cmd/connectors/internal/connectors/dummypay/fs_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package dummypay - -import "github.com/spf13/afero" - -func newTestFS() fs { - return afero.NewMemMapFs() -} diff --git a/cmd/connectors/internal/connectors/dummypay/loader.go b/cmd/connectors/internal/connectors/dummypay/loader.go deleted file mode 100644 index 20702fd3..00000000 --- a/cmd/connectors/internal/connectors/dummypay/loader.go +++ /dev/null @@ -1,57 +0,0 @@ -package dummypay - -import ( - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -// Name returns the name of the connector. -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -// AllowTasks returns the amount of tasks that are allowed to be scheduled. -func (l *Loader) AllowTasks() int { - return 10 -} - -const ( - // defaultFilePollingPeriod is the default period between file polling. - defaultFilePollingPeriod = 10 * time.Second -) - -// ApplyDefaults applies default values to the configuration. -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.FilePollingPeriod.Duration == 0 { - cfg.FilePollingPeriod = connectors.Duration{Duration: defaultFilePollingPeriod} - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -// Load returns the connector. -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config, newFS()) -} - -// Router returns the router. -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/dummypay/loader_test.go b/cmd/connectors/internal/connectors/dummypay/loader_test.go deleted file mode 100644 index c8128a1b..00000000 --- a/cmd/connectors/internal/connectors/dummypay/loader_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package dummypay - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/stretchr/testify/assert" -) - -// TestLoader tests the loader. -func TestLoader(t *testing.T) { - t.Parallel() - - config := Config{} - logger := logging.FromContext(context.TODO()) - - loader := NewLoader() - - assert.Equal(t, name, loader.Name()) - assert.Equal(t, 10, loader.AllowTasks()) - assert.Equal(t, Config{ - Name: "DUMMY-PAY", - FilePollingPeriod: connectors.Duration{Duration: 10 * time.Second}, - }, loader.ApplyDefaults(config)) - - assert.EqualValues(t, newConnector(logger, config, newFS()), loader.Load(logger, config)) -} diff --git a/cmd/connectors/internal/connectors/dummypay/models.go b/cmd/connectors/internal/connectors/dummypay/models.go deleted file mode 100644 index 34fe8a93..00000000 --- a/cmd/connectors/internal/connectors/dummypay/models.go +++ /dev/null @@ -1,42 +0,0 @@ -package dummypay - -import ( - "math/big" - "time" -) - -type Kind string - -const ( - KindPayment Kind = "payment" - KindAccount Kind = "account" -) - -type object struct { - Kind Kind `json:"kind"` - Payment *payment `json:"payment,omitempty"` - Account *account `json:"account,omitempty"` -} - -// payment represents a payment structure used in the generated files. -type payment struct { - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Type string `json:"type"` - Status string `json:"status"` - Scheme string `json:"scheme"` - SourceAccountID string `json:"sourceAccountId"` - DestinationAccountID string `json:"destinationAccountId"` - Metadata map[string]string `json:"metadata"` -} - -type account struct { - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - DefaultAsset string `json:"defaultAsset"` - AccountName string `json:"accountName"` - Type string `json:"type"` - Metadata map[string]string `json:"metadata"` -} diff --git a/cmd/connectors/internal/connectors/dummypay/remove_files.go b/cmd/connectors/internal/connectors/dummypay/remove_files.go deleted file mode 100644 index 06e2eb1a..00000000 --- a/cmd/connectors/internal/connectors/dummypay/remove_files.go +++ /dev/null @@ -1,39 +0,0 @@ -package dummypay - -import ( - "fmt" - "os" - "strings" - - "github.com/spf13/afero" -) - -// removeFiles removes all files from the given directory. -// Only removes files that has generatedFilePrefix in the name. -func removeFiles(config Config, fs fs) error { - dir, err := afero.ReadDir(fs, config.Directory) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("failed to open directory '%s': %w", config.Directory, err) - } - - // iterate over all files in the directory - for _, file := range dir { - // skip files that do not match the generatedFilePrefix - if config.PrefixFileToIngest != "" { - if !strings.HasPrefix(file.Name(), generatedFilePrefix) && !strings.HasPrefix(file.Name(), config.PrefixFileToIngest) { - continue - } - } - - // remove the file - err = fs.Remove(fmt.Sprintf("%s/%s", config.Directory, file.Name())) - if err != nil { - return fmt.Errorf("failed to remove file '%s': %w", file.Name(), err) - } - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/dummypay/task_descriptor.go b/cmd/connectors/internal/connectors/dummypay/task_descriptor.go deleted file mode 100644 index 20cb02be..00000000 --- a/cmd/connectors/internal/connectors/dummypay/task_descriptor.go +++ /dev/null @@ -1,34 +0,0 @@ -package dummypay - -import ( - "fmt" - - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -// taskKey defines a unique key of the task. -type taskKey string - -// TaskDescriptor represents a task descriptor. -type TaskDescriptor struct { - Name string `json:"name" bson:"name" yaml:"name"` - Key taskKey `json:"key" bson:"key" yaml:"key"` - FileName string `json:"fileName" bson:"fileName" yaml:"fileName"` -} - -// handleResolve resolves a task execution request based on the task descriptor. -func handleResolve(config Config, descriptor TaskDescriptor, fs fs) task.Task { - switch descriptor.Key { - case taskKeyReadFiles: - return taskReadFiles(config, fs) - case taskKeyIngest: - return taskIngest(config, descriptor, fs) - case taskKeyInitDirectory: - return taskGenerateFiles(config, fs) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", descriptor.Key, ErrMissingTask) - } -} diff --git a/cmd/connectors/internal/connectors/dummypay/task_ingest.go b/cmd/connectors/internal/connectors/dummypay/task_ingest.go deleted file mode 100644 index 34d20f44..00000000 --- a/cmd/connectors/internal/connectors/dummypay/task_ingest.go +++ /dev/null @@ -1,172 +0,0 @@ -package dummypay - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const ( - taskKeyIngest = "ingest" -) - -// newTaskIngest returns a new task descriptor for the ingest task. -func newTaskIngest(filePath string) TaskDescriptor { - return TaskDescriptor{ - Name: "Ingest accounts and payments from read files", - Key: taskKeyIngest, - FileName: filePath, - } -} - -// taskIngest ingests a payment file. -func taskIngest(config Config, descriptor TaskDescriptor, fs fs) task.Task { - return func( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - ) error { - err := handleFile(ctx, connectorID, ingester, config, descriptor, fs) - if err != nil { - return fmt.Errorf("failed to handle file '%s': %w", descriptor.FileName, err) - } - - return nil - } -} - -func handleFile( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - config Config, - descriptor TaskDescriptor, - fs fs, -) error { - object, err := getObject(config, descriptor, fs) - if err != nil { - return err - } - - switch object.Kind { - case KindAccount: - batch, err := handleAccount(connectorID, object.Account) - if err != nil { - return err - } - - err = ingester.IngestAccounts(ctx, batch) - if err != nil { - return fmt.Errorf("failed to ingest file '%s': %w", descriptor.FileName, err) - } - case KindPayment: - batch, err := handlePayment(connectorID, object.Payment) - if err != nil { - return err - } - - err = ingester.IngestPayments(ctx, batch) - if err != nil { - return fmt.Errorf("failed to ingest file '%s': %w", descriptor.FileName, err) - } - default: - return fmt.Errorf("unknown object kind '%s'", object.Kind) - } - - return nil -} - -func getObject(config Config, descriptor TaskDescriptor, fs fs) (*object, error) { - file, err := fs.Open(filepath.Join(config.Directory, descriptor.FileName)) - if err != nil { - return nil, fmt.Errorf("failed to open file '%s': %w", descriptor.FileName, err) - } - defer file.Close() - - var objectElement object - err = json.NewDecoder(file).Decode(&objectElement) - if err != nil { - return nil, fmt.Errorf("failed to decode file '%s': %w", descriptor.FileName, err) - } - - return &objectElement, nil -} - -func handleAccount(connectorID models.ConnectorID, accountElement *account) (ingestion.AccountBatch, error) { - accountType, err := models.AccountTypeFromString(accountElement.Type) - if err != nil { - return nil, fmt.Errorf("failed to parse account type: %w", err) - } - - raw, err := json.Marshal(accountElement) - if err != nil { - return nil, fmt.Errorf("failed to marshal payment: %w", err) - } - - ingestionPayload := ingestion.AccountBatch{&models.Account{ - ID: models.AccountID{ - Reference: accountElement.Reference, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: accountElement.CreatedAt, - Reference: accountElement.Reference, - DefaultAsset: models.Asset(accountElement.DefaultAsset), - AccountName: accountElement.AccountName, - Type: accountType, - Metadata: map[string]string{}, - RawData: raw, - }} - - return ingestionPayload, nil -} - -func handlePayment(connectorID models.ConnectorID, paymentElement *payment) (ingestion.PaymentBatch, error) { - paymentType, err := models.PaymentTypeFromString(paymentElement.Type) - if err != nil { - return nil, fmt.Errorf("failed to parse payment type '%s': %w", paymentElement.Type, err) - } - - paymentStatus, err := models.PaymentStatusFromString(paymentElement.Status) - if err != nil { - return nil, fmt.Errorf("failed to parse payment status '%s': %w", paymentElement.Status, err) - } - - paymentScheme, err := models.PaymentSchemeFromString(paymentElement.Scheme) - if err != nil { - return nil, fmt.Errorf("failed to parse payment scheme '%s': %w", paymentElement.Scheme, err) - } - - raw, err := json.Marshal(paymentElement) - if err != nil { - return nil, fmt.Errorf("failed to marshal payment: %w", err) - } - - ingestionPayload := ingestion.PaymentBatch{ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: paymentElement.Reference, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - Reference: paymentElement.Reference, - ConnectorID: connectorID, - Amount: paymentElement.Amount, - InitialAmount: paymentElement.Amount, - Type: paymentType, - Status: paymentStatus, - Scheme: paymentScheme, - Asset: models.Asset(paymentElement.Asset), - RawData: raw, - }, - }} - - return ingestionPayload, nil -} diff --git a/cmd/connectors/internal/connectors/dummypay/task_init_directory.go b/cmd/connectors/internal/connectors/dummypay/task_init_directory.go deleted file mode 100644 index a8c93782..00000000 --- a/cmd/connectors/internal/connectors/dummypay/task_init_directory.go +++ /dev/null @@ -1,302 +0,0 @@ -package dummypay - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "math/rand" - "os" - "time" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const ( - taskKeyInitDirectory = "init-directory" - asset = "DUMMYCOIN" - generatedFilePrefix = "dummypay-generated-file" -) - -// newTaskGenerateFiles returns a new task descriptor for the task that generates files. -func newTaskGenerateFiles() TaskDescriptor { - return TaskDescriptor{ - Name: "Generate files into a directory", - Key: taskKeyInitDirectory, - } -} - -// taskGenerateFiles generates payment files to a given directory. -func taskGenerateFiles(config Config, fs fs) task.Task { - return func(ctx context.Context, ingester ingestion.Ingester, connectorID models.ConnectorID) error { - err := fs.Mkdir(config.Directory, 0o777) //nolint:gomnd - if err != nil && !os.IsExist(err) { - return fmt.Errorf( - "failed to create dummypay config directory '%s': %w", config.Directory, err) - } - - var accountIDs []*models.AccountID - for i := 0; i < config.NumberOfAccountsPreGenerated; i++ { - accountID, err := generateAccountsFile(ctx, connectorID, ingester, config, fs) - if err != nil { - return fmt.Errorf("failed to generate accounts file: %w", err) - } - - accountIDs = append(accountIDs, accountID) - } - - for i := 0; i < config.NumberOfPaymentsPreGenerated; i++ { - if err := generatePaymentsFile(ctx, generatedFilePrefix, connectorID, ingester, accountIDs, config, fs); err != nil { - return fmt.Errorf("failed to generate payments files: %w", err) - } - } - - return nil - } -} - -func generateAccountsFile( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - config Config, - fs fs, -) (*models.AccountID, error) { - name := fmt.Sprintf("account-%d", time.Now().UnixNano()) - key := fmt.Sprintf("%s-%s", generatedFilePrefix, name) - fileKey := fmt.Sprintf("%s/%s.json", config.Directory, key) - - generatedAccount := account{ - Reference: uuid.New().String(), - CreatedAt: time.Now(), - DefaultAsset: asset, - AccountName: name, - Type: generateRandomAccountType().String(), - Metadata: map[string]string{}, - } - - file, err := fs.Create(fileKey) - if err != nil { - return nil, fmt.Errorf("failed to create file: %w", err) - } - defer file.Close() - - // Encode the payment object as JSON to a new file. - err = json.NewEncoder(file).Encode(&object{ - Kind: KindAccount, - Account: &generatedAccount, - }) - if err != nil { - return nil, fmt.Errorf("failed to encode json into file: %w", err) - } - - raw, err := json.Marshal(generatedAccount) - if err != nil { - return nil, fmt.Errorf("failed to marshal account: %w", err) - } - - accountID := models.AccountID{ - Reference: generatedAccount.Reference, - ConnectorID: connectorID, - } - if err := ingester.IngestAccounts(ctx, ingestion.AccountBatch{ - { - ID: accountID, - ConnectorID: connectorID, - CreatedAt: generatedAccount.CreatedAt, - Reference: generatedAccount.Reference, - DefaultAsset: asset, - AccountName: name, - Type: models.AccountType(generatedAccount.Type), - Metadata: map[string]string{}, - RawData: raw, - }, - }); err != nil { - return nil, fmt.Errorf("failed to ingest accounts: %w", err) - } - - return &accountID, nil -} - -func generatePaymentsFile( - ctx context.Context, - prefix string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accountIDs []*models.AccountID, - config Config, - fs fs, -) error { - name := fmt.Sprintf("payment-%d", time.Now().UnixNano()) - key := name - if prefix != "" { - key = fmt.Sprintf("%s-%s", generatedFilePrefix, name) - } - fileKey := fmt.Sprintf("%s/%s.json", config.Directory, key) - - generatedPayment := payment{ - Reference: uuid.New().String(), - CreatedAt: time.Now(), - Amount: big.NewInt(int64(rand.Intn(10000))), - Asset: asset, - Type: generateRandomPaymentType().String(), - Status: generateRandomStatus().String(), - Scheme: generateRandomScheme().String(), - Metadata: map[string]string{}, - } - - var sourceAccountID, destinationAccountID *models.AccountID - if len(accountIDs) != 0 { - if generateRandomNumber() > nMax/2 { - sourceAccountID = accountIDs[generateRandomNumber()%len(accountIDs)] - generatedPayment.SourceAccountID = sourceAccountID.String() - } else { - destinationAccountID = accountIDs[generateRandomNumber()%len(accountIDs)] - generatedPayment.DestinationAccountID = destinationAccountID.String() - } - } - - file, err := fs.Create(fileKey) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer file.Close() - - // Encode the payment object as JSON to a new file. - err = json.NewEncoder(file).Encode(&object{ - Kind: KindPayment, - Payment: &generatedPayment, - }) - if err != nil { - return fmt.Errorf("failed to encode json into file: %w", err) - } - - raw, err := json.Marshal(generatedPayment) - if err != nil { - return fmt.Errorf("failed to marshal payment: %w", err) - } - if err := ingester.IngestPayments(ctx, ingestion.PaymentBatch{ - { - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: generatedPayment.Reference, - Type: models.PaymentType(generatedPayment.Type), - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: generatedPayment.CreatedAt, - Reference: generatedPayment.Reference, - Amount: generatedPayment.Amount, - InitialAmount: generatedPayment.Amount, - Type: models.PaymentType(generatedPayment.Type), - Status: models.PaymentStatus(generatedPayment.Status), - Scheme: models.PaymentScheme(generatedPayment.Scheme), - Asset: asset, - RawData: raw, - SourceAccountID: sourceAccountID, - DestinationAccountID: destinationAccountID, - }, - }, - }); err != nil { - return fmt.Errorf("failed to ingest payments: %w", err) - } - - return nil -} - -// nMax is the maximum number that can be generated -// with the minimum being 0. -const nMax = 10000 - -func generateRandomAccountType() models.AccountType { - // 50% chance. - accountType := models.AccountTypeInternal - - // 50% chance. - if generateRandomNumber() > nMax/2 { - accountType = models.AccountTypeExternal - } - - return accountType -} - -// generateRandomNumber generates a random number between 0 and nMax. -func generateRandomNumber() int { - rand.Seed(time.Now().UnixNano()) - - //nolint:gosec // allow weak random number generator as it is not used for security - value := rand.Intn(nMax) - - return value -} - -// generateRandomType generates a random payment type. -func generateRandomPaymentType() models.PaymentType { - paymentType := models.PaymentTypePayIn - - num := generateRandomNumber() - switch { - case num < nMax/4: // 25% chance - paymentType = models.PaymentTypePayOut - case num < nMax/3: // ~9% chance - paymentType = models.PaymentTypeTransfer - } - - return paymentType -} - -// generateRandomStatus generates a random payment status. -func generateRandomStatus() models.PaymentStatus { - // ~50% chance. - paymentStatus := models.PaymentStatusSucceeded - - num := generateRandomNumber() - - switch { - case num < nMax/4: // 25% chance - paymentStatus = models.PaymentStatusPending - case num < nMax/3: // ~9% chance - paymentStatus = models.PaymentStatusFailed - case num < nMax/2: // ~16% chance - paymentStatus = models.PaymentStatusCancelled - } - - return paymentStatus -} - -// generateRandomScheme generates a random payment scheme. -func generateRandomScheme() models.PaymentScheme { - num := generateRandomNumber() / 1000 //nolint:gomnd // allow for random number - - paymentScheme := models.PaymentSchemeCardMasterCard - - availableSchemes := []models.PaymentScheme{ - models.PaymentSchemeCardMasterCard, - models.PaymentSchemeCardVisa, - models.PaymentSchemeCardDiscover, - models.PaymentSchemeCardJCB, - models.PaymentSchemeCardUnionPay, - models.PaymentSchemeCardAmex, - models.PaymentSchemeCardDiners, - models.PaymentSchemeSepaDebit, - models.PaymentSchemeSepaCredit, - models.PaymentSchemeApplePay, - models.PaymentSchemeGooglePay, - models.PaymentSchemeA2A, - models.PaymentSchemeACHDebit, - models.PaymentSchemeACH, - models.PaymentSchemeRTP, - } - - if num < len(availableSchemes) { - paymentScheme = availableSchemes[num] - } - - return paymentScheme -} diff --git a/cmd/connectors/internal/connectors/dummypay/task_read_files.go b/cmd/connectors/internal/connectors/dummypay/task_read_files.go deleted file mode 100644 index 88f30ca5..00000000 --- a/cmd/connectors/internal/connectors/dummypay/task_read_files.go +++ /dev/null @@ -1,92 +0,0 @@ -package dummypay - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/formancehq/payments/internal/models" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/spf13/afero" -) - -const ( - taskKeyReadFiles = "read-files" -) - -// newTaskReadFiles creates a new task descriptor for the taskReadFiles task. -func newTaskReadFiles() TaskDescriptor { - return TaskDescriptor{ - Name: "Read Files from directory", - Key: taskKeyReadFiles, - } -} - -// taskReadFiles creates a task that reads files from a given directory. -// Only reads files with the generatedFilePrefix in their name. -func taskReadFiles(config Config, fs fs) task.Task { - return func(ctx context.Context, logger logging.Logger, - scheduler task.Scheduler, - ) error { - err := fs.Mkdir(config.Directory, 0o777) //nolint:gomnd - if err != nil && !os.IsExist(err) { - return fmt.Errorf( - "failed to create dummypay config directory '%s': %w", config.Directory, err) - } - - files, err := parseFilesToIngest(config, fs) - if err != nil { - return fmt.Errorf("error parsing files to ingest: %w", err) - } - - for _, file := range files { - descriptor, err := models.EncodeTaskDescriptor(newTaskIngest(file)) - if err != nil { - return err - } - - // schedule a task to ingest the file into the payments system. - err = scheduler.Schedule(ctx, descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - }) - if err != nil { - return fmt.Errorf("failed to schedule task to ingest file '%s': %w", file, err) - } - - } - - return nil - } -} - -func parseFilesToIngest(config Config, fs fs) ([]string, error) { - dir, err := afero.ReadDir(fs, config.Directory) - if err != nil { - return nil, fmt.Errorf("error reading directory '%s': %w", config.Directory, err) - } - - var files []string //nolint:prealloc // length is unknown - - // iterate over all files in the directory. - for _, file := range dir { - // skip files that match the generatedFilePrefix because they were already ingested. - if strings.HasPrefix(file.Name(), generatedFilePrefix) { - continue - } - - if config.PrefixFileToIngest != "" { - // skip files that do not match the toIngestFilePrefix. - if !strings.HasPrefix(file.Name(), config.PrefixFileToIngest) { - continue - } - } - - files = append(files, file.Name()) - } - - return files, nil -} diff --git a/cmd/connectors/internal/connectors/dummypay/task_test.go b/cmd/connectors/internal/connectors/dummypay/task_test.go deleted file mode 100644 index f7b79a44..00000000 --- a/cmd/connectors/internal/connectors/dummypay/task_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package dummypay - -import ( - "context" - "testing" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -func TestTasks(t *testing.T) { - t.Parallel() - - config := Config{Directory: "/tmp"} - fs := newTestFS() - - // test generating files - err := generatePaymentsFile(context.Background(), "", models.ConnectorID{}, &MockIngester{}, []*models.AccountID{}, config, fs) - assert.NoError(t, err) - - files, err := afero.ReadDir(fs, config.Directory) - assert.NoError(t, err) - assert.Len(t, files, 1) - - // test reading files - filesList, err := parseFilesToIngest(config, fs) - assert.NoError(t, err) - assert.Len(t, filesList, 1) - - // test getting object - object, err := getObject(config, TaskDescriptor{Key: taskKeyIngest, FileName: files[0].Name()}, fs) - assert.NoError(t, err) - assert.NotNil(t, object) - assert.NotNil(t, object.Payment) - - // test ingesting files - payload, err := handlePayment(models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }, object.Payment) - assert.NoError(t, err) - assert.Len(t, payload, 1) - - // test removing files - err = removeFiles(config, fs) - assert.NoError(t, err) - - files, err = afero.ReadDir(fs, config.Directory) - assert.NoError(t, err) - assert.Len(t, files, 0) -} diff --git a/cmd/connectors/internal/connectors/duration.go b/cmd/connectors/internal/connectors/duration.go deleted file mode 100644 index c6e5ee31..00000000 --- a/cmd/connectors/internal/connectors/duration.go +++ /dev/null @@ -1,45 +0,0 @@ -package connectors - -import ( - "encoding/json" - "fmt" - "time" -) - -type Duration struct { - time.Duration `json:"duration"` -} - -func (d *Duration) MarshalJSON() ([]byte, error) { - return json.Marshal(d.Duration.String()) -} - -func (d *Duration) UnmarshalJSON(b []byte) error { - var rawValue any - - if err := json.Unmarshal(b, &rawValue); err != nil { - return fmt.Errorf("custom Duration UnmarshalJSON: %w", err) - } - - switch value := rawValue.(type) { - case string: - var err error - d.Duration, err = time.ParseDuration(value) - if err != nil { - return fmt.Errorf("custom Duration UnmarshalJSON: time.ParseDuration: %w", err) - } - - return nil - case map[string]interface{}: - switch val := value["duration"].(type) { - case float64: - d.Duration = time.Duration(int64(val)) - - return nil - default: - return fmt.Errorf("custom Duration UnmarshalJSON from map: invalid type: value:%v, type:%T", val, val) - } - default: - return fmt.Errorf("custom Duration UnmarshalJSON: invalid type: value:%v, type:%T", value, value) - } -} diff --git a/cmd/connectors/internal/connectors/errors.go b/cmd/connectors/internal/connectors/errors.go deleted file mode 100644 index 2f4b28d4..00000000 --- a/cmd/connectors/internal/connectors/errors.go +++ /dev/null @@ -1,8 +0,0 @@ -package connectors - -import "errors" - -var ( - ErrNotImplemented = errors.New("not implemented") - ErrInvalidConfig = errors.New("invalid config") -) diff --git a/cmd/connectors/internal/connectors/generic/client/accounts.go b/cmd/connectors/internal/connectors/generic/client/accounts.go deleted file mode 100644 index b2cc74ff..00000000 --- a/cmd/connectors/internal/connectors/generic/client/accounts.go +++ /dev/null @@ -1,27 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/genericclient" -) - -func (c *Client) ListAccounts(ctx context.Context, page, pageSize int64) ([]genericclient.Account, error) { - f := connectors.ClientMetrics(ctx, "generic", "list_accounts") - now := time.Now() - defer f(ctx, now) - - req := c.apiClient.DefaultApi. - GetAccounts(ctx). - Page(page). - PageSize(pageSize) - - accounts, _, err := req.Execute() - if err != nil { - return nil, err - } - - return accounts, nil -} diff --git a/cmd/connectors/internal/connectors/generic/client/balances.go b/cmd/connectors/internal/connectors/generic/client/balances.go deleted file mode 100644 index ce5f085b..00000000 --- a/cmd/connectors/internal/connectors/generic/client/balances.go +++ /dev/null @@ -1,24 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/genericclient" -) - -func (c *Client) GetBalances(ctx context.Context, accountID string) (*genericclient.Balances, error) { - f := connectors.ClientMetrics(ctx, "generic", "get_balance") - now := time.Now() - defer f(ctx, now) - - req := c.apiClient.DefaultApi.GetAccountBalances(ctx, accountID) - - balances, _, err := req.Execute() - if err != nil { - return nil, err - } - - return balances, nil -} diff --git a/cmd/connectors/internal/connectors/generic/client/beneficiaries.go b/cmd/connectors/internal/connectors/generic/client/beneficiaries.go deleted file mode 100644 index 5e3c2196..00000000 --- a/cmd/connectors/internal/connectors/generic/client/beneficiaries.go +++ /dev/null @@ -1,31 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/genericclient" -) - -func (c *Client) ListBeneficiaries(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Beneficiary, error) { - f := connectors.ClientMetrics(ctx, "generic", "list_beneficiaries") - now := time.Now() - defer f(ctx, now) - - req := c.apiClient.DefaultApi. - GetBeneficiaries(ctx). - Page(page). - PageSize(pageSize) - - if !createdAtFrom.IsZero() { - req = req.CreatedAtFrom(createdAtFrom) - } - - beneficiaries, _, err := req.Execute() - if err != nil { - return nil, err - } - - return beneficiaries, nil -} diff --git a/cmd/connectors/internal/connectors/generic/client/client.go b/cmd/connectors/internal/connectors/generic/client/client.go deleted file mode 100644 index 7388a96d..00000000 --- a/cmd/connectors/internal/connectors/generic/client/client.go +++ /dev/null @@ -1,44 +0,0 @@ -package client - -import ( - "fmt" - "net/http" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/genericclient" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -type apiTransport struct { - APIKey string - underlying http.RoundTripper -} - -func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", t.APIKey)) - - return t.underlying.RoundTrip(req) -} - -type Client struct { - apiClient *genericclient.APIClient -} - -func NewClient(apiKey, baseURL string, logger logging.Logger) *Client { - httpClient := &http.Client{ - Transport: &apiTransport{ - APIKey: apiKey, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - } - - configuration := genericclient.NewConfiguration() - configuration.HTTPClient = httpClient - configuration.Servers[0].URL = baseURL - - genericClient := genericclient.NewAPIClient(configuration) - - return &Client{ - apiClient: genericClient, - } -} diff --git a/cmd/connectors/internal/connectors/generic/client/transactions.go b/cmd/connectors/internal/connectors/generic/client/transactions.go deleted file mode 100644 index 2e0ca3c6..00000000 --- a/cmd/connectors/internal/connectors/generic/client/transactions.go +++ /dev/null @@ -1,30 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/genericclient" -) - -func (c *Client) ListTransactions(ctx context.Context, page, pageSize int64, updatedAtFrom time.Time) ([]genericclient.Transaction, error) { - f := connectors.ClientMetrics(ctx, "generic", "list_transactions") - now := time.Now() - defer f(ctx, now) - - req := c.apiClient.DefaultApi.GetTransactions(ctx). - Page(page). - PageSize(pageSize) - - if !updatedAtFrom.IsZero() { - req = req.UpdatedAtFrom(updatedAtFrom) - } - - transactions, _, err := req.Execute() - if err != nil { - return nil, err - } - - return transactions, nil -} diff --git a/cmd/connectors/internal/connectors/generic/config.go b/cmd/connectors/internal/connectors/generic/config.go deleted file mode 100644 index a74b1125..00000000 --- a/cmd/connectors/internal/connectors/generic/config.go +++ /dev/null @@ -1,53 +0,0 @@ -package generic - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" -) - -const ( - pageSize = 100 - defaultPollingPeriod = 2 * time.Minute -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - APIKey string `json:"apiKey" yaml:"apiKey" bson:"apiKey"` - Endpoint string `json:"endpoint" yaml:"endpoint" bson:"endpoint"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -func (c Config) String() string { - return fmt.Sprintf("endpoint=%s", c.Endpoint) -} - -func (c Config) Validate() error { - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("endpoint", configtemplate.TypeString, "", false) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", false) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/generic/connector.go b/cmd/connectors/internal/connectors/generic/connector.go deleted file mode 100644 index cfd5d0aa..00000000 --- a/cmd/connectors/internal/connectors/generic/connector.go +++ /dev/null @@ -1,119 +0,0 @@ -package generic - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const name = models.ConnectorProviderGeneric - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch users and transactions", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - c.cfg = cfg - - if c.cfg.Endpoint == "" { - // Nothing more to do - return nil - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - if restartTask { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - if c.cfg.Endpoint == "" { - // Nothing to do - return nil - } - - mainDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), mainDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.logger, c.cfg)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/generic/currencies.go b/cmd/connectors/internal/connectors/generic/currencies.go deleted file mode 100644 index 501980dc..00000000 --- a/cmd/connectors/internal/connectors/generic/currencies.go +++ /dev/null @@ -1,7 +0,0 @@ -package generic - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - supportedCurrenciesWithDecimal = currency.ISO4217Currencies -) diff --git a/cmd/connectors/internal/connectors/generic/errors.go b/cmd/connectors/internal/connectors/generic/errors.go deleted file mode 100644 index 707e9c68..00000000 --- a/cmd/connectors/internal/connectors/generic/errors.go +++ /dev/null @@ -1,14 +0,0 @@ -package generic - -import "errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingEndpoint is returned when the endpoint is missing. - ErrMissingEndpoint = errors.New("missing endpoint from config") - - // ErrMissingName is returned when the name is missing. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/generic/loader.go b/cmd/connectors/internal/connectors/generic/loader.go deleted file mode 100644 index 29342d85..00000000 --- a/cmd/connectors/internal/connectors/generic/loader.go +++ /dev/null @@ -1,46 +0,0 @@ -package generic - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(store *storage.Storage) *mux.Router { - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/generic/task_fetch_accounts.go b/cmd/connectors/internal/connectors/generic/task_fetch_accounts.go deleted file mode 100644 index 73bf267b..00000000 --- a/cmd/connectors/internal/connectors/generic/task_fetch_accounts.go +++ /dev/null @@ -1,130 +0,0 @@ -package generic - -import ( - "context" - "encoding/json" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchAccounts(client *client.Client, config *Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "generic.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - err := ingestAccounts(ctx, connectorID, client, ingester, scheduler) - if err != nil { - otel.RecordError(span, err) - return err - } - - taskTransactions, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transactions from client", - Key: taskNameFetchTransactions, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskTransactions, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func ingestAccounts( - ctx context.Context, - connectorID models.ConnectorID, - client *client.Client, - ingester ingestion.Ingester, - scheduler task.Scheduler, -) error { - - balancesTasks := make([]models.TaskDescriptor, 0) - for page := 1; ; page++ { - accounts, err := client.ListAccounts(ctx, int64(page), pageSize) - if err != nil { - return err - } - - if len(accounts) == 0 { - break - } - - accountsBatch := make([]*models.Account, 0, len(accounts)) - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: account.Id, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: account.CreatedAt, - Reference: account.Id, - AccountName: account.AccountName, - Type: models.AccountTypeInternal, - Metadata: account.Metadata, - RawData: raw, - }) - - balanceTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch balances from client", - Key: taskNameFetchBalances, - AccountID: account.Id, - }) - if err != nil { - return err - } - - balancesTasks = append(balancesTasks, balanceTask) - - } - - if err := ingester.IngestAccounts(ctx, ingestion.AccountBatch(accountsBatch)); err != nil { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - for _, balanceTask := range balancesTasks { - if err := scheduler.Schedule(ctx, balanceTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }); err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - } - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/generic/task_fetch_balances.go b/cmd/connectors/internal/connectors/generic/task_fetch_balances.go deleted file mode 100644 index 32ea2cf7..00000000 --- a/cmd/connectors/internal/connectors/generic/task_fetch_balances.go +++ /dev/null @@ -1,88 +0,0 @@ -package generic - -import ( - "context" - "fmt" - "math/big" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/genericclient" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchBalances(client *client.Client, config *Config, accountID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "generic.taskFetchBalances", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - balances, err := client.GetBalances(ctx, accountID) - if err != nil { - // retryable error already handled by the client - otel.RecordError(span, err) - return err - } - - if err := ingestBalancesBatch(ctx, connectorID, ingester, accountID, balances); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestBalancesBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accountID string, - balances *genericclient.Balances, -) error { - if balances == nil { - return nil - } - - balancesBatch := make([]*models.Balance, 0, len(balances.Balances)) - for _, balance := range balances.Balances { - var amount big.Int - _, ok := amount.SetString(balance.Amount, 10) - if !ok { - return fmt.Errorf("failed to parse amount: %s", balance.Amount) - } - - balancesBatch = append(balancesBatch, &models.Balance{ - AccountID: models.AccountID{ - Reference: accountID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Currency), - Balance: &amount, - CreatedAt: balances.At, - LastUpdatedAt: balances.At, - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestBalances(ctx, ingestion.BalanceBatch(balancesBatch), false); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/generic/task_fetch_beneficiaries.go b/cmd/connectors/internal/connectors/generic/task_fetch_beneficiaries.go deleted file mode 100644 index 7dadc85b..00000000 --- a/cmd/connectors/internal/connectors/generic/task_fetch_beneficiaries.go +++ /dev/null @@ -1,106 +0,0 @@ -package generic - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchBeneficiariesState struct { - LastCreatedAt time.Time -} - -func taskFetchBeneficiaries(client *client.Client, config *Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "generic.taskFetchBeneficiaries", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchBeneficiariesState{}) - - newState, err := ingestBeneficiaries(ctx, connectorID, client, ingester, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestBeneficiaries( - ctx context.Context, - connectorID models.ConnectorID, - client *client.Client, - ingester ingestion.Ingester, - state fetchBeneficiariesState, -) (fetchBeneficiariesState, error) { - newState := fetchBeneficiariesState{ - LastCreatedAt: state.LastCreatedAt, - } - - for page := 1; ; page++ { - beneficiaries, err := client.ListBeneficiaries(ctx, int64(page), pageSize, state.LastCreatedAt) - if err != nil { - return fetchBeneficiariesState{}, err - } - - if len(beneficiaries) == 0 { - break - } - - beneficiaryBatch := make([]*models.Account, 0, len(beneficiaries)) - for _, beneficiary := range beneficiaries { - raw, err := json.Marshal(beneficiary) - if err != nil { - return fetchBeneficiariesState{}, err - } - - beneficiaryBatch = append(beneficiaryBatch, &models.Account{ - ID: models.AccountID{ - Reference: beneficiary.Id, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: beneficiary.CreatedAt, - Reference: beneficiary.Id, - AccountName: beneficiary.OwnerName, - Type: models.AccountTypeExternal, - Metadata: beneficiary.Metadata, - RawData: raw, - }) - - newState.LastCreatedAt = beneficiary.CreatedAt - } - - if err := ingester.IngestAccounts(ctx, ingestion.AccountBatch(beneficiaryBatch)); err != nil { - return fetchBeneficiariesState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - } - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/generic/task_fetch_transactions.go b/cmd/connectors/internal/connectors/generic/task_fetch_transactions.go deleted file mode 100644 index 2da44466..00000000 --- a/cmd/connectors/internal/connectors/generic/task_fetch_transactions.go +++ /dev/null @@ -1,205 +0,0 @@ -package generic - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/genericclient" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchTransactionsState struct { - LastUpdatedAt time.Time `json:"last_updated_at"` -} - -func taskFetchTransactions(client *client.Client, config *Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "generic.taskFetchTransactions", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchTransactionsState{}) - - newState, err := ingestTransactions(ctx, connectorID, client, ingester, scheduler, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestTransactions( - ctx context.Context, - connectorID models.ConnectorID, - client *client.Client, - ingester ingestion.Ingester, - scheduler task.Scheduler, - state fetchTransactionsState, -) (fetchTransactionsState, error) { - newState := fetchTransactionsState{ - LastUpdatedAt: state.LastUpdatedAt, - } - - for page := 1; ; page++ { - transactions, err := client.ListTransactions(ctx, int64(page), pageSize, state.LastUpdatedAt) - if err != nil { - return fetchTransactionsState{}, err - } - - if len(transactions) == 0 { - break - } - - paymentBatch := make([]ingestion.PaymentBatchElement, 0, len(transactions)) - for _, transaction := range transactions { - elt, err := translate(ctx, connectorID, transaction) - if err != nil { - return fetchTransactionsState{}, err - } - - paymentBatch = append(paymentBatch, elt) - - newState.LastUpdatedAt = transaction.UpdatedAt - } - - if err := ingester.IngestPayments(ctx, ingestion.PaymentBatch(paymentBatch)); err != nil { - return fetchTransactionsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - } - - return newState, nil -} - -func translate( - ctx context.Context, - connectorID models.ConnectorID, - transaction genericclient.Transaction, -) (ingestion.PaymentBatchElement, error) { - paymentType := matchPaymentType(transaction.Type) - paymentStatus := matchPaymentStatus(transaction.Status) - - var amount big.Int - _, ok := amount.SetString(transaction.Amount, 10) - if !ok { - return ingestion.PaymentBatchElement{}, fmt.Errorf("failed to parse amount: %s", transaction.Amount) - } - - raw, err := json.Marshal(transaction) - if err != nil { - return ingestion.PaymentBatchElement{}, err - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: transaction.Id, - Type: paymentType, - }, - ConnectorID: connectorID, - } - elt := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: paymentID, - ConnectorID: connectorID, - CreatedAt: transaction.CreatedAt, - Reference: transaction.Id, - Amount: &amount, - InitialAmount: &amount, - Type: paymentType, - Status: paymentStatus, - Scheme: models.PaymentSchemeOther, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Currency), - RawData: raw, - }, - } - - if transaction.SourceAccountID != nil && *transaction.SourceAccountID != "" { - elt.Payment.SourceAccountID = &models.AccountID{ - Reference: *transaction.SourceAccountID, - ConnectorID: connectorID, - } - } - - if transaction.DestinationAccountID != nil && *transaction.DestinationAccountID != "" { - elt.Payment.DestinationAccountID = &models.AccountID{ - Reference: *transaction.DestinationAccountID, - ConnectorID: connectorID, - } - } - - for k, v := range transaction.Metadata { - elt.Payment.Metadata = append(elt.Payment.Metadata, &models.PaymentMetadata{ - PaymentID: paymentID, - CreatedAt: transaction.CreatedAt, - Key: k, - Value: v, - Changelog: []models.MetadataChangelog{ - { - CreatedAt: transaction.CreatedAt, - Value: v, - }, - }, - }) - - } - - return elt, nil -} - -func matchPaymentType( - transactionType genericclient.TransactionType, -) models.PaymentType { - switch transactionType { - case genericclient.PAYIN: - return models.PaymentTypePayIn - case genericclient.PAYOUT: - return models.PaymentTypePayOut - case genericclient.TRANSFER: - return models.PaymentTypeTransfer - default: - return models.PaymentTypeOther - } -} - -func matchPaymentStatus( - status genericclient.TransactionStatus, -) models.PaymentStatus { - switch status { - case genericclient.PENDING: - return models.PaymentStatusPending - case genericclient.FAILED: - return models.PaymentStatusFailed - case genericclient.SUCCEEDED: - return models.PaymentStatusSucceeded - default: - return models.PaymentStatusOther - } -} diff --git a/cmd/connectors/internal/connectors/generic/task_main.go b/cmd/connectors/internal/connectors/generic/task_main.go deleted file mode 100644 index 3b721a46..00000000 --- a/cmd/connectors/internal/connectors/generic/task_main.go +++ /dev/null @@ -1,68 +0,0 @@ -package generic - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "generoc.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return err - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - taskBeneficiaries, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch beneficiaries from client", - Key: taskNameFetchBeneficiaries, - }) - if err != nil { - otel.RecordError(span, err) - return err - } - - err = scheduler.Schedule(ctx, taskBeneficiaries, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/generic/task_resolve.go b/cmd/connectors/internal/connectors/generic/task_resolve.go deleted file mode 100644 index 16168167..00000000 --- a/cmd/connectors/internal/connectors/generic/task_resolve.go +++ /dev/null @@ -1,52 +0,0 @@ -package generic - -import ( - "fmt" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/generic/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const ( - taskNameMain = "main" - taskNameFetchAccounts = "fetch-accounts" - taskNameFetchBalances = "fetch-balances" - taskNameFetchBeneficiaries = "fetch-beneficiaries" - taskNameFetchTransactions = "fetch-transactions" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - AccountID string `json:"account_id" yaml:"account_id" bson:"account_id"` -} - -func resolveTasks(logger logging.Logger, config Config) func(taskDefinition TaskDescriptor) task.Task { - genericClient := client.NewClient( - config.APIKey, - config.Endpoint, - logger, - ) - - return func(taskDescriptor TaskDescriptor) task.Task { - switch taskDescriptor.Key { - case taskNameMain: - return taskMain() - case taskNameFetchAccounts: - return taskFetchAccounts(genericClient, &config) - case taskNameFetchBeneficiaries: - return taskFetchBeneficiaries(genericClient, &config) - case taskNameFetchBalances: - return taskFetchBalances(genericClient, &config, taskDescriptor.AccountID) - case taskNameFetchTransactions: - return taskFetchTransactions(genericClient, &config) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDescriptor.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/bank_accounts.go b/cmd/connectors/internal/connectors/mangopay/client/bank_accounts.go deleted file mode 100644 index 78e5eb26..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/bank_accounts.go +++ /dev/null @@ -1,198 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type OwnerAddress struct { - AddressLine1 string `json:"AddressLine1,omitempty"` - AddressLine2 string `json:"AddressLine2,omitempty"` - City string `json:"City,omitempty"` - // Region is needed if country is either US, CA or MX - Region string `json:"Region,omitempty"` - PostalCode string `json:"PostalCode,omitempty"` - // ISO 3166-1 alpha-2 format. - Country string `json:"Country,omitempty"` -} - -type CreateIBANBankAccountRequest struct { - OwnerName string `json:"OwnerName"` - OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` - IBAN string `json:"IBAN,omitempty"` - BIC string `json:"BIC,omitempty"` - // Metadata - Tag string `json:"Tag,omitempty"` -} - -func (c *Client) CreateIBANBankAccount(ctx context.Context, userID string, req *CreateIBANBankAccountRequest) (*BankAccount, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "create_iban_bank_account") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/iban", c.endpoint, c.clientID, userID) - return c.createBankAccount(ctx, endpoint, req) -} - -type CreateUSBankAccountRequest struct { - OwnerName string `json:"OwnerName"` - OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` - AccountNumber string `json:"AccountNumber"` - ABA string `json:"ABA"` - DepositAccountType string `json:"DepositAccountType,omitempty"` - Tag string `json:"Tag,omitempty"` -} - -func (c *Client) CreateUSBankAccount(ctx context.Context, userID string, req *CreateUSBankAccountRequest) (*BankAccount, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "create_us_bank_account") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/us", c.endpoint, c.clientID, userID) - return c.createBankAccount(ctx, endpoint, req) -} - -type CreateCABankAccountRequest struct { - OwnerName string `json:"OwnerName"` - OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` - AccountNumber string `json:"AccountNumber"` - InstitutionNumber string `json:"InstitutionNumber"` - BranchCode string `json:"BranchCode"` - BankName string `json:"BankName"` - Tag string `json:"Tag,omitempty"` -} - -func (c *Client) CreateCABankAccount(ctx context.Context, userID string, req *CreateCABankAccountRequest) (*BankAccount, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "create_ca_bank_account") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/ca", c.endpoint, c.clientID, userID) - return c.createBankAccount(ctx, endpoint, req) -} - -type CreateGBBankAccountRequest struct { - OwnerName string `json:"OwnerName"` - OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` - AccountNumber string `json:"AccountNumber"` - SortCode string `json:"SortCode"` - Tag string `json:"Tag,omitempty"` -} - -func (c *Client) CreateGBBankAccount(ctx context.Context, userID string, req *CreateGBBankAccountRequest) (*BankAccount, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "create_gb_bank_account") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/gb", c.endpoint, c.clientID, userID) - return c.createBankAccount(ctx, endpoint, req) -} - -type CreateOtherBankAccountRequest struct { - OwnerName string `json:"OwnerName"` - OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` - AccountNumber string `json:"AccountNumber"` - BIC string `json:"BIC,omitempty"` - Country string `json:"Country,omitempty"` - Tag string `json:"Tag,omitempty"` -} - -func (c *Client) CreateOtherBankAccount(ctx context.Context, userID string, req *CreateOtherBankAccountRequest) (*BankAccount, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "create_other_bank_account") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/other", c.endpoint, c.clientID, userID) - return c.createBankAccount(ctx, endpoint, req) -} - -func (c *Client) createBankAccount(ctx context.Context, endpoint string, req any) (*BankAccount, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("failed to marshal bank account request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create bank account request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("failed to create bank account: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - // Never retry bank account creation - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var bankAccount BankAccount - if err := json.NewDecoder(resp.Body).Decode(&bankAccount); err != nil { - return nil, fmt.Errorf("failed to unmarshal bank account response body: %w", err) - } - - return &bankAccount, nil -} - -type BankAccount struct { - ID string `json:"Id"` - OwnerName string `json:"OwnerName"` - CreationDate int64 `json:"CreationDate"` -} - -func (c *Client) GetBankAccounts(ctx context.Context, userID string, page, pageSize int) ([]*BankAccount, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "list_bank_accounts") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts", c.endpoint, c.clientID, userID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - - q := req.URL.Query() - q.Add("per_page", strconv.Itoa(pageSize)) - q.Add("page", fmt.Sprint(page)) - q.Add("Sort", "CreationDate:ASC") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var bankAccounts []*BankAccount - if err := json.NewDecoder(resp.Body).Decode(&bankAccounts); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return bankAccounts, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/client.go b/cmd/connectors/internal/connectors/mangopay/client/client.go deleted file mode 100644 index 8beec103..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/client.go +++ /dev/null @@ -1,52 +0,0 @@ -package client - -import ( - "context" - "net/http" - "strings" - "time" - - "github.com/formancehq/go-libs/logging" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "golang.org/x/oauth2/clientcredentials" -) - -// TODO(polo): Fetch Client wallets (FEES, ...) in the future -type Client struct { - httpClient *http.Client - - clientID string - endpoint string - - logger logging.Logger -} - -func newHTTPClient(clientID, apiKey, endpoint string) *http.Client { - config := clientcredentials.Config{ - ClientID: clientID, - ClientSecret: apiKey, - TokenURL: endpoint + "/v2.01/oauth/token", - } - - httpClient := config.Client(context.Background()) - - return &http.Client{ - Timeout: 10 * time.Second, - Transport: otelhttp.NewTransport(httpClient.Transport), - } -} - -func NewClient(clientID, apiKey, endpoint string, logger logging.Logger) (*Client, error) { - endpoint = strings.TrimSuffix(endpoint, "/") - - c := &Client{ - httpClient: newHTTPClient(clientID, apiKey, endpoint), - - clientID: clientID, - endpoint: endpoint, - - logger: logger, - } - - return c, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/dispute.go b/cmd/connectors/internal/connectors/mangopay/client/dispute.go deleted file mode 100644 index 92a17b3e..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/dispute.go +++ /dev/null @@ -1,66 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Dispute struct { - Id string `json:"Id"` - Tag string `json:"Tag"` - InitialTransactionId string `json:"InitialTransactionId"` - InitialTransactionType string `json:"InitialTransactionType"` - InitialTransactionNature string `json:"InitialTransactionNature"` - DisputeType string `json:"DisputeType"` - ContestDeadlineDate int64 `json:"ContestDeadlineDate"` - DisputedFunds Funds `json:"DisputedFunds"` - ContestedFunds Funds `json:"ContestedFunds"` - Status string `json:"Status"` - StatusMessage string `json:"StatusMessage"` - DisputeReason string `json:"DisputeReason"` - ResultCode string `json:"ResultCode"` - ResultMessage string `json:"ResultMessage"` - CreationDate int64 `json:"CreationDate"` - ClosedDate int64 `json:"ClosedDate"` -} - -func (c *Client) GetDispute(ctx context.Context, disputeID string) (*Dispute, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "get_dispute") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/disputes/%s", c.endpoint, c.clientID, disputeID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("failed to create get dispute request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get dispute: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var dispute Dispute - if err := json.NewDecoder(resp.Body).Decode(&dispute); err != nil { - return nil, fmt.Errorf("failed to unmarshal dispute response body: %w", err) - } - - return &dispute, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/error.go b/cmd/connectors/internal/connectors/mangopay/client/error.go deleted file mode 100644 index b57fa36f..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/error.go +++ /dev/null @@ -1,79 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/pkg/errors" -) - -type mangopayError struct { - StatusCode int `json:"-"` - Message string `json:"Message"` - Type string `json:"Type"` - Errors map[string]string `json:"Errors"` - WithRetry bool `json:"-"` -} - -func (me *mangopayError) Error() error { - var errorMessage string - if len(me.Errors) > 0 { - for _, message := range me.Errors { - errorMessage = message - break - } - } - - var err error - if errorMessage == "" { - err = fmt.Errorf("unexpected status code: %d", me.StatusCode) - } else { - err = fmt.Errorf("%d: %s", me.StatusCode, errorMessage) - } - - if me.WithRetry { - return checkStatusCodeError(me.StatusCode, err) - } else { - return errors.Wrap(task.ErrNonRetryable, err.Error()) - } -} - -func unmarshalErrorWithRetry(statusCode int, body io.ReadCloser) *mangopayError { - var ce mangopayError - _ = json.NewDecoder(body).Decode(&ce) - - ce.StatusCode = statusCode - ce.WithRetry = true - - return &ce -} - -func unmarshalErrorWithoutRetry(statusCode int, body io.ReadCloser) *mangopayError { - var ce mangopayError - _ = json.NewDecoder(body).Decode(&ce) - - ce.StatusCode = statusCode - ce.WithRetry = false - - return &ce -} - -func checkStatusCodeError(statusCode int, err error) error { - switch statusCode { - case http.StatusTooEarly, http.StatusRequestTimeout: - return errors.Wrap(task.ErrRetryable, err.Error()) - case http.StatusTooManyRequests: - // Retry rate limit errors - // TODO(polo): add rate limit handling - return errors.Wrap(task.ErrRetryable, err.Error()) - case http.StatusInternalServerError, http.StatusBadGateway, - http.StatusServiceUnavailable, http.StatusGatewayTimeout: - // Retry internal errors - return errors.Wrap(task.ErrRetryable, err.Error()) - default: - return errors.Wrap(task.ErrNonRetryable, err.Error()) - } -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/payin.go b/cmd/connectors/internal/connectors/mangopay/client/payin.go deleted file mode 100644 index 4f220945..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/payin.go +++ /dev/null @@ -1,66 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type PayinResponse struct { - ID string `json:"Id"` - Tag string `json:"Tag"` - CreationDate int64 `json:"CreationDate"` - ResultCode string `json:"ResultCode"` - ResultMessage string `json:"ResultMessage"` - AuthorId string `json:"AuthorId"` - CreditedUserId string `json:"CreditedUserId"` - DebitedFunds Funds `json:"DebitedFunds"` - CreditedFunds Funds `json:"CreditedFunds"` - Fees Funds `json:"Fees"` - Status string `json:"Status"` - ExecutionDate int64 `json:"ExecutionDate"` - Type string `json:"Type"` - CreditedWalletID string `json:"CreditedWalletId"` - PaymentType string `json:"PaymentType"` - ExecutionType string `json:"ExecutionType"` -} - -func (c *Client) GetPayin(ctx context.Context, payinID string) (*PayinResponse, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "get_payin") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/payins/%s", c.endpoint, c.clientID, payinID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("failed to create get payin request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payin: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var payinResponse PayinResponse - if err := json.NewDecoder(resp.Body).Decode(&payinResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal payin response body: %w", err) - } - - return &payinResponse, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/payout.go b/cmd/connectors/internal/connectors/mangopay/client/payout.go deleted file mode 100644 index c83a1a46..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/payout.go +++ /dev/null @@ -1,123 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type PayoutRequest struct { - AuthorID string `json:"AuthorId"` - DebitedFunds Funds `json:"DebitedFunds"` - Fees Funds `json:"Fees"` - DebitedWalletID string `json:"DebitedWalletId"` - BankAccountID string `json:"BankAccountId"` - BankWireRef string `json:"BankWireRef,omitempty"` - PayoutModeRequested string `json:"PayoutModeRequested,omitempty"` -} - -type PayoutResponse struct { - ID string `json:"Id"` - ModeRequest string `json:"ModeRequested"` - ModeApplied string `json:"ModeApplied"` - FallbackReason string `json:"FallbackReason"` - CreationDate int64 `json:"CreationDate"` - AuthorID string `json:"AuthorId"` - DebitedFunds Funds `json:"DebitedFunds"` - Fees Funds `json:"Fees"` - CreditedFunds Funds `json:"CreditedFunds"` - Status string `json:"Status"` - ResultCode string `json:"ResultCode"` - ResultMessage string `json:"ResultMessage"` - Type string `json:"Type"` - Nature string `json:"Nature"` - ExecutionDate int64 `json:"ExecutionDate"` - BankAccountID string `json:"BankAccountId"` - DebitedWalletID string `json:"DebitedWalletId"` - PaymentType string `json:"PaymentType"` - BankWireRef string `json:"BankWireRef"` -} - -func (c *Client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "initiate_payout") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/payouts/bankwire", c.endpoint, c.clientID) - - body, err := json.Marshal(payoutRequest) - if err != nil { - return nil, fmt.Errorf("failed to marshal transfer request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - // Never retry payout initiation - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var payoutResponse PayoutResponse - if err := json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return &payoutResponse, nil -} - -func (c *Client) GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "get_payout") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/payouts/%s", c.endpoint, c.clientID, payoutID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("failed to create get payout request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payout: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var payoutResponse PayoutResponse - if err := json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal payout response body: %w", err) - } - - return &payoutResponse, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/refund.go b/cmd/connectors/internal/connectors/mangopay/client/refund.go deleted file mode 100644 index 293f3e3c..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/refund.go +++ /dev/null @@ -1,67 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Refund struct { - ID string `json:"Id"` - Tag string `json:"Tag"` - CreationDate int64 `json:"CreationDate"` - AuthorId string `json:"AuthorId"` - CreditedUserId string `json:"CreditedUserId"` - DebitedFunds Funds `json:"DebitedFunds"` - CreditedFunds Funds `json:"CreditedFunds"` - Fees Funds `json:"Fees"` - Status string `json:"Status"` - ResultCode string `json:"ResultCode"` - ResultMessage string `json:"ResultMessage"` - ExecutionDate int64 `json:"ExecutionDate"` - Type string `json:"Type"` - DebitedWalletId string `json:"DebitedWalletId"` - CreditedWalletId string `json:"CreditedWalletId"` - InitialTransactionID string `json:"InitialTransactionId"` - InitialTransactionType string `json:"InitialTransactionType"` -} - -func (c *Client) GetRefund(ctx context.Context, refundID string) (*Refund, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "get_refund") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/refunds/%s", c.endpoint, c.clientID, refundID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("failed to create get refund request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get refund: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var refund Refund - if err := json.NewDecoder(resp.Body).Decode(&refund); err != nil { - return nil, fmt.Errorf("failed to unmarshal refund response body: %w", err) - } - - return &refund, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/transactions.go b/cmd/connectors/internal/connectors/mangopay/client/transactions.go deleted file mode 100644 index 82fe31ec..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/transactions.go +++ /dev/null @@ -1,84 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Payment struct { - Id string `json:"Id"` - Tag string `json:"Tag"` - CreationDate int64 `json:"CreationDate"` - AuthorId string `json:"AuthorId"` - CreditedUserId string `json:"CreditedUserId"` - DebitedFunds struct { - Currency string `json:"Currency"` - Amount json.Number `json:"Amount"` - } `json:"DebitedFunds"` - CreditedFunds struct { - Currency string `json:"Currency"` - Amount json.Number `json:"Amount"` - } `json:"CreditedFunds"` - Fees struct { - Currency string `json:"Currency"` - Amount json.Number `json:"Amount"` - } `json:"Fees"` - Status string `json:"Status"` - ResultCode string `json:"ResultCode"` - ResultMessage string `json:"ResultMessage"` - ExecutionDate int64 `json:"ExecutionDate"` - Type string `json:"Type"` - Nature string `json:"Nature"` - CreditedWalletID string `json:"CreditedWalletId"` - DebitedWalletID string `json:"DebitedWalletId"` -} - -func (c *Client) GetTransactions(ctx context.Context, walletsID string, page, pageSize int, afterCreatedAt time.Time) ([]*Payment, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "list_transactions") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/wallets/%s/transactions", c.endpoint, c.clientID, walletsID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - - q := req.URL.Query() - q.Add("per_page", strconv.Itoa(pageSize)) - q.Add("page", fmt.Sprint(page)) - q.Add("Sort", "CreationDate:ASC") - if !afterCreatedAt.IsZero() { - q.Add("AfterDate", strconv.FormatInt(afterCreatedAt.UTC().Unix(), 10)) - } - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get transactions: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var payments []*Payment - if err := json.NewDecoder(resp.Body).Decode(&payments); err != nil { - return nil, fmt.Errorf("failed to unmarshal transactions response body: %w", err) - } - - return payments, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/transfer.go b/cmd/connectors/internal/connectors/mangopay/client/transfer.go deleted file mode 100644 index 31172b18..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/transfer.go +++ /dev/null @@ -1,122 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Funds struct { - Currency string `json:"Currency"` - Amount json.Number `json:"Amount"` -} - -type TransferRequest struct { - AuthorID string `json:"AuthorId"` - CreditedUserID string `json:"CreditedUserId,omitempty"` - DebitedFunds Funds `json:"DebitedFunds"` - Fees Funds `json:"Fees"` - DebitedWalletID string `json:"DebitedWalletId"` - CreditedWalletID string `json:"CreditedWalletId"` -} - -type TransferResponse struct { - ID string `json:"Id"` - CreationDate int64 `json:"CreationDate"` - AuthorID string `json:"AuthorId"` - CreditedUserID string `json:"CreditedUserId"` - DebitedFunds Funds `json:"DebitedFunds"` - Fees Funds `json:"Fees"` - CreditedFunds Funds `json:"CreditedFunds"` - Status string `json:"Status"` - ResultCode string `json:"ResultCode"` - ResultMessage string `json:"ResultMessage"` - Type string `json:"Type"` - ExecutionDate int64 `json:"ExecutionDate"` - Nature string `json:"Nature"` - DebitedWalletID string `json:"DebitedWalletId"` - CreditedWalletID string `json:"CreditedWalletId"` -} - -func (c *Client) InitiateWalletTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "initiate_transfer") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/transfers", c.endpoint, c.clientID) - - body, err := json.Marshal(transferRequest) - if err != nil { - return nil, fmt.Errorf("failed to marshal transfer request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create transfer request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - // Never retry transfer initiation - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var transferResponse TransferResponse - if err := json.NewDecoder(resp.Body).Decode(&transferResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return &transferResponse, nil -} - -func (c *Client) GetWalletTransfer(ctx context.Context, transferID string) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "get_transfer") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/transfers/%s", c.endpoint, c.clientID, transferID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var transfer TransferResponse - if err := json.NewDecoder(resp.Body).Decode(&transfer); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return &transfer, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/users.go b/cmd/connectors/internal/connectors/mangopay/client/users.go deleted file mode 100644 index 7e782e83..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/users.go +++ /dev/null @@ -1,82 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type user struct { - ID string `json:"Id"` - CreationDate int64 `json:"CreationDate"` -} - -func (c *Client) GetAllUsers(ctx context.Context, lastPage int, pageSize int) ([]*user, int, error) { - var users []*user - var currentPage int - - for currentPage = lastPage; ; currentPage++ { - pagedUsers, err := c.getUsers(ctx, currentPage, pageSize) - if err != nil { - return nil, lastPage, err - } - - if len(pagedUsers) == 0 { - break - } - - users = append(users, pagedUsers...) - - if len(pagedUsers) < pageSize { - break - } - } - - return users, currentPage, nil -} - -func (c *Client) getUsers(ctx context.Context, page int, pageSize int) ([]*user, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "list_users") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users", c.endpoint, c.clientID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - - q := req.URL.Query() - q.Add("per_page", strconv.Itoa(pageSize)) - q.Add("page", fmt.Sprint(page)) - q.Add("Sort", "CreationDate:ASC") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get users: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var users []*user - if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { - return nil, fmt.Errorf("failed to unmarshal users response body: %w", err) - } - - return users, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/wallets.go b/cmd/connectors/internal/connectors/mangopay/client/wallets.go deleted file mode 100644 index cbc0bb82..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/wallets.go +++ /dev/null @@ -1,100 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Wallet struct { - ID string `json:"Id"` - Owners []string `json:"Owners"` - Description string `json:"Description"` - CreationDate int64 `json:"CreationDate"` - Currency string `json:"Currency"` - Balance struct { - Currency string `json:"Currency"` - Amount json.Number `json:"Amount"` - } `json:"Balance"` -} - -func (c *Client) GetWallets(ctx context.Context, userID string, page, pageSize int) ([]*Wallet, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "list_wallets") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/wallets", c.endpoint, c.clientID, userID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - - q := req.URL.Query() - q.Add("per_page", strconv.Itoa(pageSize)) - q.Add("page", fmt.Sprint(page)) - q.Add("Sort", "CreationDate:ASC") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var wallets []*Wallet - if err := json.NewDecoder(resp.Body).Decode(&wallets); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return wallets, nil -} - -func (c *Client) GetWallet(ctx context.Context, walletID string) (*Wallet, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "get_wallets") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/wallets/%s", c.endpoint, c.clientID, walletID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create wallet request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallet: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var wallet Wallet - if err := json.NewDecoder(resp.Body).Decode(&wallet); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallet response body: %w", err) - } - - return &wallet, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/client/webhooks.go b/cmd/connectors/internal/connectors/mangopay/client/webhooks.go deleted file mode 100644 index dc89a279..00000000 --- a/cmd/connectors/internal/connectors/mangopay/client/webhooks.go +++ /dev/null @@ -1,219 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type EventType string - -const ( - // Transfer - EventTypeTransferNormalCreated EventType = "TRANSFER_NORMAL_CREATED" - EventTypeTransferNormalFailed EventType = "TRANSFER_NORMAL_FAILED" - EventTypeTransferNormalSucceeded EventType = "TRANSFER_NORMAL_SUCCEEDED" - - // PayOut - EventTypePayoutNormalCreated EventType = "PAYOUT_NORMAL_CREATED" - EventTypePayoutNormalFailed EventType = "PAYOUT_NORMAL_FAILED" - EventTypePayoutNormalSucceeded EventType = "PAYOUT_NORMAL_SUCCEEDED" - EventTypePayoutInstantFailed EventType = "INSTANT_PAYOUT_FAILED" - EventTypePayoutInstantSucceeded EventType = "INSTANT_PAYOUT_SUCCEEDED" - - // PayIn - EventTypePayinNormalCreated EventType = "PAYIN_NORMAL_CREATED" - EventTypePayinNormalFailed EventType = "PAYIN_NORMAL_FAILED" - EventTypePayinNormalSucceeded EventType = "PAYIN_NORMAL_SUCCEEDED" - - // Refund - EventTypeTransferRefundCreated EventType = "TRANSFER_REFUND_CREATED" - EventTypeTransferRefundFailed EventType = "TRANSFER_REFUND_FAILED" - EventTypeTransferRefundSucceeded EventType = "TRANSFER_REFUND_SUCCEEDED" - EventTypePayinRefundCreated EventType = "PAYIN_REFUND_CREATED" - EventTypePayinRefundFailed EventType = "PAYIN_REFUND_FAILED" - EventTypePayinRefundSucceeded EventType = "PAYIN_REFUND_SUCCEEDED" - EventTypePayOutRefundCreated EventType = "PAYOUT_REFUND_CREATED" - EventTypePayOutRefundFailed EventType = "PAYOUT_REFUND_FAILED" - EventTypePayOutRefundSucceeded EventType = "PAYOUT_REFUND_SUCCEEDED" -) - -var ( - AllEventTypes = []EventType{ - EventTypeTransferNormalCreated, - EventTypeTransferNormalFailed, - EventTypeTransferNormalSucceeded, - EventTypePayoutNormalCreated, - EventTypePayoutNormalFailed, - EventTypePayoutNormalSucceeded, - EventTypePayoutInstantFailed, - EventTypePayoutInstantSucceeded, - EventTypePayinNormalCreated, - EventTypePayinNormalFailed, - EventTypePayinNormalSucceeded, - EventTypeTransferRefundCreated, - EventTypeTransferRefundFailed, - EventTypeTransferRefundSucceeded, - EventTypePayinRefundCreated, - EventTypePayinRefundFailed, - EventTypePayinRefundSucceeded, - EventTypePayOutRefundCreated, - EventTypePayOutRefundFailed, - EventTypePayOutRefundSucceeded, - } -) - -type Webhook struct { - ResourceID string `json:"ResourceId"` - EventType EventType `json:"EventType"` -} - -func (c *Client) UnmarshalWebhooks(req string) (*Webhook, error) { - res := Webhook{} - err := json.Unmarshal([]byte(req), &res) - if err != nil { - return nil, err - } - return &res, nil -} - -type Hook struct { - ID string `json:"Id"` - URL string `json:"Url"` - Status string `json:"Status"` - Validity string `json:"Validity"` - EventType EventType `json:"EventType"` -} - -func (c *Client) ListAllHooks(ctx context.Context) ([]*Hook, error) { - f := connectors.ClientMetrics(ctx, "mangopay", "list_hooks") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/v2.01/%s/hooks", c.endpoint, c.clientID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create hooks request: %w", err) - } - - q := req.URL.Query() - q.Add("per_page", "100") // Should be enough, since we're creating only a few - q.Add("Sort", "CreationDate:ASC") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallet: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var hooks []*Hook - if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil { - return nil, fmt.Errorf("failed to unmarshal hooks response body: %w", err) - } - - return hooks, nil -} - -type CreateHookRequest struct { - EventType EventType `json:"EventType"` - URL string `json:"Url"` -} - -func (c *Client) CreateHook(ctx context.Context, eventType EventType, URL string) error { - f := connectors.ClientMetrics(ctx, "mangopay", "create_hook") - now := time.Now() - defer f(ctx, now) - - body, err := json.Marshal(&CreateHookRequest{ - EventType: eventType, - URL: URL, - }) - if err != nil { - return fmt.Errorf("failed to marshal create hook request: %w", err) - } - - endpoint := fmt.Sprintf("%s/v2.01/%s/hooks", c.endpoint, c.clientID) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) - if err != nil { - return fmt.Errorf("failed to create hooks request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to create hook: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - return nil -} - -type UpdateHookRequest struct { - URL string `json:"Url"` - Status string `json:"Status"` -} - -func (c *Client) UpdateHook(ctx context.Context, hookID string, URL string) error { - f := connectors.ClientMetrics(ctx, "mangopay", "udpate_hook") - now := time.Now() - defer f(ctx, now) - - body, err := json.Marshal(&UpdateHookRequest{ - URL: URL, - Status: "ENABLED", - }) - if err != nil { - return fmt.Errorf("failed to marshal udpate hook request: %w", err) - } - - endpoint := fmt.Sprintf("%s/v2.01/%s/hooks/%s", c.endpoint, c.clientID, hookID) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewBuffer(body)) - if err != nil { - return fmt.Errorf("failed to create update hooks request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to update hook: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/config.go b/cmd/connectors/internal/connectors/mangopay/config.go deleted file mode 100644 index b0101206..00000000 --- a/cmd/connectors/internal/connectors/mangopay/config.go +++ /dev/null @@ -1,67 +0,0 @@ -package mangopay - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" -) - -const ( - pageSize = 100 - defaultPollingPeriod = 2 * time.Minute -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - ClientID string `json:"clientID" yaml:"clientID" bson:"clientID"` - APIKey string `json:"apiKey" yaml:"apiKey" bson:"apiKey"` - Endpoint string `json:"endpoint" yaml:"endpoint" bson:"endpoint"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -func (c Config) String() string { - return fmt.Sprintf("clientID=%s, apiKey=****", c.ClientID) -} - -func (c Config) Validate() error { - if c.ClientID == "" { - return ErrMissingClientID - } - - if c.APIKey == "" { - return ErrMissingAPIKey - } - - if c.Endpoint == "" { - return ErrMissingEndpoint - } - - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("clientID", configtemplate.TypeString, "", true) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("endpoint", configtemplate.TypeString, "", true) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/mangopay/connector.go b/cmd/connectors/internal/connectors/mangopay/connector.go deleted file mode 100644 index b509c436..00000000 --- a/cmd/connectors/internal/connectors/mangopay/connector.go +++ /dev/null @@ -1,166 +0,0 @@ -package mangopay - -import ( - "context" - "errors" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const name = models.ConnectorProviderMangopay - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch users and transactions", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config - - taskMemoryState taskMemoryState -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - // Create hooks on mangopay sync - createWebhookDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "First task to create webhooks", - Key: taskNameCreateWebhook, - }) - if err != nil { - return err - } - - if err := ctx.Scheduler().Schedule(ctx.Context(), createWebhookDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW_SYNC, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }); err != nil { - return err - } - - mainDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), mainDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.logger, c.cfg, &c.taskMemoryState)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - descriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Create external bank account", - Key: taskNameCreateExternalBankAccount, - BankAccountID: bankAccount.ID, - }) - if err != nil { - return err - } - if err := ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW_SYNC, - }); err != nil { - return err - } - - return nil -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/mangopay/currencies.go b/cmd/connectors/internal/connectors/mangopay/currencies.go deleted file mode 100644 index 261e1b78..00000000 --- a/cmd/connectors/internal/connectors/mangopay/currencies.go +++ /dev/null @@ -1,24 +0,0 @@ -package mangopay - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - // c.f. https://mangopay.com/docs/api-basics/data-formats - supportedCurrenciesWithDecimal = map[string]int{ - "AED": currency.ISO4217Currencies["AED"], // UAE Dirham - "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar - "CAD": currency.ISO4217Currencies["CAD"], // Canadian Dollar - "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc - "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna - "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling - "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar - "JPY": currency.ISO4217Currencies["JPY"], // Japan, Yen - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone - "PLN": currency.ISO4217Currencies["PLN"], // Poland, Zloty - "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona - "USD": currency.ISO4217Currencies["USD"], // US Dollar - "ZAR": currency.ISO4217Currencies["ZAR"], // South Africa, Rand - } -) diff --git a/cmd/connectors/internal/connectors/mangopay/errors.go b/cmd/connectors/internal/connectors/mangopay/errors.go deleted file mode 100644 index 9328b7c6..00000000 --- a/cmd/connectors/internal/connectors/mangopay/errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package mangopay - -import "errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingClientID is returned when the clientID is missing. - ErrMissingClientID = errors.New("missing clientID from config") - - // ErrMissingAPIKey is returned when the apiKey is missing. - ErrMissingAPIKey = errors.New("missing apiKey from config") - - // ErrMissingEndpoint is returned when the endpoint is missing. - ErrMissingEndpoint = errors.New("missing endpoint from config") - - // ErrMissingName is returned when the name is missing. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/mangopay/loader.go b/cmd/connectors/internal/connectors/mangopay/loader.go deleted file mode 100644 index 67ee43ba..00000000 --- a/cmd/connectors/internal/connectors/mangopay/loader.go +++ /dev/null @@ -1,52 +0,0 @@ -package mangopay - -import ( - "net/http" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(store *storage.Storage) *mux.Router { - r := mux.NewRouter() - - r.Path("/").Methods(http.MethodPost).Handler(handleWebhooks(store)) - - return r -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_create_external_bank_account.go b/cmd/connectors/internal/connectors/mangopay/task_create_external_bank_account.go deleted file mode 100644 index e49106f8..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_create_external_bank_account.go +++ /dev/null @@ -1,188 +0,0 @@ -package mangopay - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "go.opentelemetry.io/otel/attribute" -) - -func taskCreateExternalBankAccount(mangopayClient *client.Client, bankAccountID uuid.UUID) task.Task { - return func( - ctx context.Context, - connectorID models.ConnectorID, - taskID models.TaskID, - storageReader storage.Reader, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskCreateExternalBankAccount", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("bankAccountID", bankAccountID.String()), - ) - defer span.End() - - bankAccount, err := storageReader.GetBankAccount(ctx, bankAccountID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := createExternalBankAccount(ctx, connectorID, mangopayClient, bankAccount, ingester); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func createExternalBankAccount( - ctx context.Context, - connectorID models.ConnectorID, - mangopayClient *client.Client, - bankAccount *models.BankAccount, - ingester ingestion.Ingester, -) error { - userID := models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayUserIDMetadataKey) - if userID == "" { - return fmt.Errorf("missing userID in bank account metadata") - } - - ownerAddress := client.OwnerAddress{ - AddressLine1: models.ExtractNamespacedMetadata(bankAccount.Metadata, models.BankAccountOwnerAddressLine1MetadataKey), - AddressLine2: models.ExtractNamespacedMetadata(bankAccount.Metadata, models.BankAccountOwnerAddressLine2MetadataKey), - City: models.ExtractNamespacedMetadata(bankAccount.Metadata, models.BankAccountOwnerCityMetadataKey), - Region: models.ExtractNamespacedMetadata(bankAccount.Metadata, models.BankAccountOwnerRegionMetadataKey), - PostalCode: models.ExtractNamespacedMetadata(bankAccount.Metadata, models.BankAccountOwnerPostalCodeMetadataKey), - Country: bankAccount.Country, - } - - var mangopayBankAccount *client.BankAccount - if bankAccount.IBAN != "" { - req := &client.CreateIBANBankAccountRequest{ - OwnerName: bankAccount.Name, - OwnerAddress: &ownerAddress, - IBAN: bankAccount.IBAN, - BIC: bankAccount.SwiftBicCode, - Tag: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayTagMetadataKey), - } - - var err error - mangopayBankAccount, err = mangopayClient.CreateIBANBankAccount(ctx, userID, req) - if err != nil { - return err - } - } else { - switch bankAccount.Country { - case "US": - req := &client.CreateUSBankAccountRequest{ - OwnerName: bankAccount.Name, - OwnerAddress: &ownerAddress, - AccountNumber: bankAccount.AccountNumber, - ABA: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayABAMetadataKey), - DepositAccountType: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayDepositAccountTypeMetadataKey), - Tag: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayTagMetadataKey), - } - - var err error - mangopayBankAccount, err = mangopayClient.CreateUSBankAccount(ctx, userID, req) - if err != nil { - return err - } - - case "CA": - req := &client.CreateCABankAccountRequest{ - OwnerName: bankAccount.Name, - OwnerAddress: &ownerAddress, - AccountNumber: bankAccount.AccountNumber, - InstitutionNumber: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayInstitutionNumberMetadataKey), - BranchCode: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayBranchCodeMetadataKey), - BankName: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayBankNameMetadataKey), - Tag: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayTagMetadataKey), - } - - var err error - mangopayBankAccount, err = mangopayClient.CreateCABankAccount(ctx, userID, req) - if err != nil { - return err - } - - case "GB": - req := &client.CreateGBBankAccountRequest{ - OwnerName: bankAccount.Name, - OwnerAddress: &ownerAddress, - AccountNumber: bankAccount.AccountNumber, - SortCode: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopaySortCodeMetadataKey), - Tag: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayTagMetadataKey), - } - - var err error - mangopayBankAccount, err = mangopayClient.CreateGBBankAccount(ctx, userID, req) - if err != nil { - return err - } - - default: - req := &client.CreateOtherBankAccountRequest{ - OwnerName: bankAccount.Name, - OwnerAddress: &ownerAddress, - AccountNumber: bankAccount.AccountNumber, - BIC: bankAccount.SwiftBicCode, - Country: bankAccount.Country, - Tag: models.ExtractNamespacedMetadata(bankAccount.Metadata, client.MangopayTagMetadataKey), - } - - var err error - mangopayBankAccount, err = mangopayClient.CreateOtherBankAccount(ctx, userID, req) - if err != nil { - return err - } - } - } - - if mangopayBankAccount != nil { - buf, err := json.Marshal(mangopayBankAccount) - if err != nil { - return err - } - - externalAccount := &models.Account{ - ID: models.AccountID{ - Reference: mangopayBankAccount.ID, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(mangopayBankAccount.CreationDate, 0), - Reference: mangopayBankAccount.ID, - ConnectorID: connectorID, - AccountName: mangopayBankAccount.OwnerName, - Type: models.AccountTypeExternal, - Metadata: map[string]string{ - "user_id": userID, - }, - RawData: buf, - } - - if err := ingester.IngestAccounts(ctx, ingestion.AccountBatch{externalAccount}); err != nil { - return err - } - - if err := ingester.LinkBankAccountWithAccount(ctx, bankAccount, &externalAccount.ID); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_fetch_bank_accounts.go b/cmd/connectors/internal/connectors/mangopay/task_fetch_bank_accounts.go deleted file mode 100644 index 1a46301b..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_fetch_bank_accounts.go +++ /dev/null @@ -1,138 +0,0 @@ -package mangopay - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchBankAccountsState struct { - LastPage int `json:"last_page"` - LastCreationDate time.Time `json:"last_creation_date"` -} - -func taskFetchBankAccounts(client *client.Client, config *Config, userID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskFetchBankAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("userID", userID), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchBankAccountsState{}) - if state.LastPage == 0 { - // If last page is 0, it means we haven't fetched any wallets yet. - // Mangopay pages starts at 1. - state.LastPage = 1 - } - - newState, err := ingestBankAccounts(ctx, client, userID, connectorID, scheduler, ingester, state) - if err != nil { - otel.RecordError(span, err) - // Retry is already handled by the function - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func ingestBankAccounts( - ctx context.Context, - client *client.Client, - userID string, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - state fetchBankAccountsState, -) (fetchBankAccountsState, error) { - var currentPage int - - newState := fetchBankAccountsState{ - LastCreationDate: state.LastCreationDate, - } - - for currentPage = state.LastPage; ; currentPage++ { - pagedBankAccounts, err := client.GetBankAccounts(ctx, userID, currentPage, pageSize) - if err != nil { - // The client is already deciding if the error is retryable or not. - return fetchBankAccountsState{}, err - } - - if len(pagedBankAccounts) == 0 { - break - } - - var accountBatch ingestion.AccountBatch - for _, bankAccount := range pagedBankAccounts { - creationDate := time.Unix(bankAccount.CreationDate, 0) - switch creationDate.Compare(state.LastCreationDate) { - case -1, 0: - // creationDate <= state.LastCreationDate, nothing to do, - // we already processed this bank account. - continue - default: - } - newState.LastCreationDate = creationDate - - buf, err := json.Marshal(bankAccount) - if err != nil { - return fetchBankAccountsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - accountBatch = append(accountBatch, &models.Account{ - ID: models.AccountID{ - Reference: bankAccount.ID, - ConnectorID: connectorID, - }, - CreatedAt: creationDate, - Reference: bankAccount.ID, - ConnectorID: connectorID, - AccountName: bankAccount.OwnerName, - Type: models.AccountTypeExternal, - Metadata: map[string]string{ - "user_id": userID, - }, - RawData: buf, - }) - - newState.LastCreationDate = creationDate - } - - if err := ingester.IngestAccounts(ctx, accountBatch); err != nil { - return fetchBankAccountsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedBankAccounts) < pageSize { - break - } - } - - newState.LastPage = currentPage - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_fetch_transactions.go b/cmd/connectors/internal/connectors/mangopay/task_fetch_transactions.go deleted file mode 100644 index 6b4b23df..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_fetch_transactions.go +++ /dev/null @@ -1,216 +0,0 @@ -package mangopay - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchTransactionsState struct { - // Mangopay only allows us to sort/filter by creation date. - // So in order to have every last updates of transactions, we need to - // keep track of the first transaction with created status in order to - // refetch all transactions created after this one. - // Example: - // - SUCCEEDED - // - FAILED - // - CREATED -> We want to keep track of the creation date of this transaction since we want its updates - // - SUCCEEDED - // - CREATED - // - SUCCEEDED - FirstCreatedTransactionCreationDate time.Time `json:"first_created_transaction_creation_date"` -} - -func taskFetchTransactions(client *client.Client, config *Config, walletsID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskFetchTransactions", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("walletsID", walletsID), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchTransactionsState{}) - - newState, err := fetchTransactions(ctx, client, walletsID, connectorID, ingester, state) - if err != nil { - otel.RecordError(span, err) - // Retry is already handled by the function - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func fetchTransactions( - ctx context.Context, - client *client.Client, - walletsID string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - state fetchTransactionsState, -) (fetchTransactionsState, error) { - newState := fetchTransactionsState{} - - var firstCreatedCreationDate time.Time - var lastCreationDate time.Time - for page := 1; ; page++ { - pagedPayments, err := client.GetTransactions(ctx, walletsID, page, pageSize, state.FirstCreatedTransactionCreationDate) - if err != nil { - // Client is already deciding if the error is retryable or not. - return fetchTransactionsState{}, err - } - - if len(pagedPayments) == 0 { - break - } - - batch := ingestion.PaymentBatch{} - for _, payment := range pagedPayments { - batchElement, err := processPayment(ctx, connectorID, payment) - if err != nil { - return fetchTransactionsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if batchElement.Payment != nil { - // State update - if firstCreatedCreationDate.IsZero() && - batchElement.Payment.Status == models.PaymentStatusPending { - firstCreatedCreationDate = batchElement.Payment.CreatedAt - } - - lastCreationDate = batchElement.Payment.CreatedAt - } - - batch = append(batch, batchElement) - } - - err = ingester.IngestPayments(ctx, batch) - if err != nil { - return fetchTransactionsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedPayments) < pageSize { - break - } - } - - newState.FirstCreatedTransactionCreationDate = firstCreatedCreationDate - if newState.FirstCreatedTransactionCreationDate.IsZero() { - // No new created payments, let's set the last creation date to the last - // transaction we fetched. - newState.FirstCreatedTransactionCreationDate = lastCreationDate - } - - return newState, nil -} - -func processPayment( - ctx context.Context, - connectorID models.ConnectorID, - payment *client.Payment, -) (ingestion.PaymentBatchElement, error) { - rawData, err := json.Marshal(payment) - if err != nil { - return ingestion.PaymentBatchElement{}, fmt.Errorf("failed to marshal transaction: %w", err) - } - - paymentType := matchPaymentType(payment.Type) - paymentStatus := matchPaymentStatus(payment.Status) - - var amount big.Int - _, ok := amount.SetString(payment.DebitedFunds.Amount.String(), 10) - if !ok { - return ingestion.PaymentBatchElement{}, fmt.Errorf("failed to parse amount %s", payment.DebitedFunds.Amount.String()) - } - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: payment.Id, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(payment.CreationDate, 0), - Reference: payment.Id, - Amount: &amount, - InitialAmount: &amount, - ConnectorID: connectorID, - Type: paymentType, - Status: paymentStatus, - Scheme: models.PaymentSchemeOther, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, payment.DebitedFunds.Currency), - RawData: rawData, - }, - } - - if payment.DebitedWalletID != "" { - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: payment.DebitedWalletID, - ConnectorID: connectorID, - } - } - - if payment.CreditedWalletID != "" { - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: payment.CreditedWalletID, - ConnectorID: connectorID, - } - } - - return batchElement, nil -} - -func matchPaymentType(paymentType string) models.PaymentType { - switch paymentType { - case "PAYIN": - return models.PaymentTypePayIn - case "PAYOUT": - return models.PaymentTypePayOut - case "TRANSFER": - return models.PaymentTypeTransfer - } - - return models.PaymentTypeOther -} - -func matchPaymentStatus(paymentStatus string) models.PaymentStatus { - switch paymentStatus { - case "CREATED": - return models.PaymentStatusPending - case "SUCCEEDED": - return models.PaymentStatusSucceeded - case "FAILED": - return models.PaymentStatusFailed - } - - return models.PaymentStatusOther -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_fetch_users.go b/cmd/connectors/internal/connectors/mangopay/task_fetch_users.go deleted file mode 100644 index 9eb26691..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_fetch_users.go +++ /dev/null @@ -1,133 +0,0 @@ -package mangopay - -import ( - "context" - "errors" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -type fetchUsersState struct { - LastPage int `json:"last_page"` - LastCreationDate time.Time `json:"last_creation_date"` - - FetchCount int `json:"-"` -} - -func taskFetchUsers(client *client.Client, config *Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskFetchUsers", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchUsersState{}) - if state.LastPage == 0 { - // If last page is 0, it means we haven't fetched any users yet. - // Mangopay pages starts at 1. - state.LastPage = 1 - } - - newState, err := ingestUsers(ctx, client, config, connectorID, scheduler, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestUsers( - ctx context.Context, - client *client.Client, - config *Config, - connectorID models.ConnectorID, - scheduler task.Scheduler, - state fetchUsersState, -) (fetchUsersState, error) { - users, lastPage, err := client.GetAllUsers(ctx, state.LastPage, pageSize) - if err != nil { - return fetchUsersState{}, err - } - - newState := fetchUsersState{ - LastPage: lastPage, - LastCreationDate: state.LastCreationDate, - } - - for _, user := range users { - userCreationDate := time.Unix(user.CreationDate, 0) - switch userCreationDate.Compare(state.LastCreationDate) { - case -1, 0: - // creationDate <= state.LastCreationDate, nothing to do, - // we already processed this user. - continue - default: - } - - walletsTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch wallets from client by user", - Key: taskNameFetchWallets, - UserID: user.ID, - }) - if err != nil { - return fetchUsersState{}, err - } - - err = scheduler.Schedule(ctx, walletsTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchUsersState{}, err - } - - // Bank accounts are never fetched using webhooks, so we need to keep - // polling them. - bankAccountsTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch bank accounts from client by user", - Key: taskNameFetchBankAccounts, - UserID: user.ID, - }) - if err != nil { - return fetchUsersState{}, err - } - - err = scheduler.Schedule(ctx, bankAccountsTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchUsersState{}, err - } - - newState.LastCreationDate = userCreationDate - } - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_fetch_wallets.go b/cmd/connectors/internal/connectors/mangopay/task_fetch_wallets.go deleted file mode 100644 index 96bd97e8..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_fetch_wallets.go +++ /dev/null @@ -1,226 +0,0 @@ -package mangopay - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "sync" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskFetchWallets in run inside a periodic task to fetch wallets from the client. -func taskFetchWallets( - client *client.Client, - config *Config, - taskMemoryState *taskMemoryState, - userID string, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskFetchWallets", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("userID", userID), - ) - defer span.End() - - err := ingestWallets(ctx, client, config, taskMemoryState, userID, connectorID, scheduler, ingester) - if err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestWallets( - ctx context.Context, - client *client.Client, - config *Config, - taskMemoryState *taskMemoryState, - userID string, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, -) error { - for currentPage := 1; ; currentPage++ { - pagedWallets, err := client.GetWallets(ctx, userID, currentPage, pageSize) - if err != nil { - // The client is already deciding if the error is retryable or not. - // Just return it. - return err - } - - if len(pagedWallets) == 0 { - break - } - - if err = handleWallets( - ctx, - config, - taskMemoryState, - userID, - connectorID, - ingester, - scheduler, - pagedWallets, - ); err != nil { - // Since we're just ingesting data, we can safely retry the task in - // case of error - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedWallets) < pageSize { - break - } - } - - return nil -} - -func handleWallets( - ctx context.Context, - config *Config, - taskMemoryState *taskMemoryState, - userID string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - pagedWallets []*client.Wallet, -) error { - var accountBatch ingestion.AccountBatch - var balanceBatch ingestion.BalanceBatch - var transactionTasks []models.TaskDescriptor - var err error - for _, wallet := range pagedWallets { - transactionTasks, err = appendTransactionTask( - taskMemoryState, - transactionTasks, - userID, - wallet, - ) - if err != nil { - return err - } - - buf, err := json.Marshal(wallet) - if err != nil { - return err - } - - accountBatch = append(accountBatch, &models.Account{ - ID: models.AccountID{ - Reference: wallet.ID, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(wallet.CreationDate, 0), - Reference: wallet.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, wallet.Currency), - AccountName: wallet.Description, - // Wallets are internal accounts on our side, since we - // can have their balances. - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "user_id": userID, - }, - RawData: buf, - }) - - var amount big.Int - _, ok := amount.SetString(wallet.Balance.Amount.String(), 10) - if !ok { - return fmt.Errorf("failed to parse amount: %s", wallet.Balance.Amount.String()) - } - - now := time.Now() - balanceBatch = append(balanceBatch, &models.Balance{ - AccountID: models.AccountID{ - Reference: wallet.ID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, wallet.Balance.Currency), - Balance: &amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestAccounts(ctx, accountBatch); err != nil { - return err - } - - if err := ingester.IngestBalances(ctx, balanceBatch, false); err != nil { - return err - } - - for _, transactionTask := range transactionTasks { - err := scheduler.Schedule(ctx, transactionTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - } - - return nil -} - -func appendTransactionTask( - taskMemoryState *taskMemoryState, - transactionTasks []models.TaskDescriptor, - userID string, - wallet *client.Wallet, -) ([]models.TaskDescriptor, error) { - if taskMemoryState.fetchTransactionsOnce == nil { - taskMemoryState.fetchTransactionsOnce = make(map[string]*sync.Once) - } - - key := userID + wallet.ID - _, ok := taskMemoryState.fetchTransactionsOnce[key] - if !ok { - taskMemoryState.fetchTransactionsOnce[key] = &sync.Once{} - } - - once := taskMemoryState.fetchTransactionsOnce[key] - - var err error - once.Do(func() { - var transactionTask models.TaskDescriptor - transactionTask, err = models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transactions from client by user and wallets", - Key: taskNameFetchTransactions, - UserID: userID, - WalletID: wallet.ID, - }) - if err != nil { - return - } - - transactionTasks = append(transactionTasks, transactionTask) - }) - - return transactionTasks, err -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_main.go b/cmd/connectors/internal/connectors/mangopay/task_main.go deleted file mode 100644 index 47cd8765..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_main.go +++ /dev/null @@ -1,50 +0,0 @@ -package mangopay - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskUsers, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch users from client", - Key: taskNameFetchUsers, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskUsers, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_payments.go b/cmd/connectors/internal/connectors/mangopay/task_payments.go deleted file mode 100644 index ee8ee08c..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_payments.go +++ /dev/null @@ -1,348 +0,0 @@ -package mangopay - -import ( - "context" - "encoding/json" - "errors" - "regexp" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -var ( - bankWireRefPatternRegexp = regexp.MustCompile("[a-zA-Z0-9 ]*") -) - -func taskInitiatePayment(mangopayClient *client.Client, transferID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, mangopayClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - mangopayClient *client.Client, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount == nil { - err = errors.New("no source account provided") - return err - } - - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - - userID, ok := transfer.SourceAccount.Metadata["user_id"] - if !ok || userID == "" { - err = errors.New("missing user_id in source account metadata") - return err - } - - // No need to modify the amount since it's already in the correct format - // and precision (checked before during API call) - var curr string - curr, _, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - bankWireRef := "" - if len(transfer.Description) <= 12 && bankWireRefPatternRegexp.MatchString(transfer.Description) { - bankWireRef = transfer.Description - } - - var connectorPaymentID string - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - var resp *client.TransferResponse - resp, err = mangopayClient.InitiateWalletTransfer(ctx, &client.TransferRequest{ - AuthorID: userID, - DebitedFunds: client.Funds{ - Currency: curr, - Amount: json.Number(transfer.Amount.String()), - }, - Fees: client.Funds{ - Currency: curr, - Amount: "0", - }, - DebitedWalletID: transfer.SourceAccountID.Reference, - CreditedWalletID: transfer.DestinationAccountID.Reference, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - var resp *client.PayoutResponse - resp, err = mangopayClient.InitiatePayout(ctx, &client.PayoutRequest{ - AuthorID: userID, - DebitedFunds: client.Funds{ - Currency: curr, - Amount: json.Number(transfer.Amount.String()), - }, - Fees: client.Funds{ - Currency: curr, - Amount: json.Number("0"), - }, - DebitedWalletID: transfer.SourceAccountID.Reference, - BankAccountID: transfer.DestinationAccountID.Reference, - BankWireRef: bankWireRef, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: connectorPaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func taskUpdatePaymentStatus( - mangopayClient *client.Client, - transferID string, - pID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", paymentID.String()), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := udpatePaymentStatus(ctx, mangopayClient, transfer, paymentID, connectorID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func udpatePaymentStatus( - ctx context.Context, - mangopayClient *client.Client, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - connectorID models.ConnectorID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var status string - var resultMessage string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - var resp *client.TransferResponse - resp, err = mangopayClient.GetWalletTransfer(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - resultMessage = resp.ResultMessage - case models.TransferInitiationTypePayout: - var resp *client.PayoutResponse - resp, err = mangopayClient.GetPayout(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - resultMessage = resp.ResultMessage - } - - switch status { - case "CREATED": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: 2 * time.Minute, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - case "SUCCEEDED": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - case "FAILED": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, resultMessage, time.Now()) - if err != nil { - return err - } - - return nil - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_resolve.go b/cmd/connectors/internal/connectors/mangopay/task_resolve.go deleted file mode 100644 index 86ecca0f..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_resolve.go +++ /dev/null @@ -1,94 +0,0 @@ -package mangopay - -import ( - "fmt" - "sync" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/google/uuid" -) - -const ( - taskNameMain = "main" - taskNameFetchUsers = "fetch-users" - taskNameFetchTransactions = "fetch-transactions" - taskNameFetchWallets = "fetch-wallets" - taskNameFetchBankAccounts = "fetch-bank-accounts" - taskNameInitiatePayment = "initiate-payment" - taskNameUpdatePaymentStatus = "update-payment-status" - taskNameCreateExternalBankAccount = "create-external-bank-account" - taskNameCreateWebhook = "create-webhook" - taskNameHandleWebhook = "handle-webhook" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - UserID string `json:"userID" yaml:"userID" bson:"userID"` - WalletID string `json:"walletID" yaml:"walletID" bson:"walletID"` - TransferID string `json:"transferID" yaml:"transferID" bson:"transferID"` - PaymentID string `json:"paymentID" yaml:"paymentID" bson:"paymentID"` - Attempt int `json:"attempt" yaml:"attempt" bson:"attempt"` - BankAccountID uuid.UUID `json:"bankAccountID,omitempty" yaml:"bankAccountID" bson:"bankAccountID"` - WebhookID uuid.UUID `json:"webhookId,omitempty" yaml:"webhookId" bson:"webhookId"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -// internal state not pushed in the database -type taskMemoryState struct { - // We want to fetch the transactions once per service start. - fetchTransactionsOnce map[string]*sync.Once -} - -// clientID, apiKey, endpoint string, logger logging -func resolveTasks(logger logging.Logger, config Config, taskMemoryState *taskMemoryState) func(taskDefinition TaskDescriptor) task.Task { - mangopayClient, err := client.NewClient( - config.ClientID, - config.APIKey, - config.Endpoint, - logger, - ) - if err != nil { - logger.Error(err) - - return func(taskDescriptor TaskDescriptor) task.Task { - return func() error { - return fmt.Errorf("cannot build mangopay client: %w", err) - } - } - } - - return func(taskDescriptor TaskDescriptor) task.Task { - switch taskDescriptor.Key { - case taskNameMain: - return taskMain() - case taskNameFetchUsers: - return taskFetchUsers(mangopayClient, &config) - case taskNameFetchBankAccounts: - return taskFetchBankAccounts(mangopayClient, &config, taskDescriptor.UserID) - case taskNameFetchTransactions: - return taskFetchTransactions(mangopayClient, &config, taskDescriptor.WalletID) - case taskNameFetchWallets: - return taskFetchWallets(mangopayClient, &config, taskMemoryState, taskDescriptor.UserID) - case taskNameCreateWebhook: - return taskCreateWebhooks(mangopayClient) - case taskNameHandleWebhook: - return taskHandleWebhooks(mangopayClient, taskDescriptor.WebhookID) - case taskNameInitiatePayment: - return taskInitiatePayment(mangopayClient, taskDescriptor.TransferID) - case taskNameUpdatePaymentStatus: - return taskUpdatePaymentStatus(mangopayClient, taskDescriptor.TransferID, taskDescriptor.PaymentID, taskDescriptor.Attempt) - case taskNameCreateExternalBankAccount: - return taskCreateExternalBankAccount(mangopayClient, taskDescriptor.BankAccountID) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDescriptor.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/mangopay/task_webhooks.go b/cmd/connectors/internal/connectors/mangopay/task_webhooks.go deleted file mode 100644 index a20d9eaa..00000000 --- a/cmd/connectors/internal/connectors/mangopay/task_webhooks.go +++ /dev/null @@ -1,607 +0,0 @@ -package mangopay - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "net/http" - "os" - "time" - - "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/mangopay/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/google/uuid" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -func handleWebhooks(store *storage.Storage) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - connectorContext := task.ConnectorContextFromContext(r.Context()) - webhookID := connectors.WebhookIDFromContext(r.Context()) - span := trace.SpanFromContext(r.Context()) - - // Mangopay does not send us the event inside the body, but using - // URL query. - eventType := r.URL.Query().Get("EventType") - resourceID := r.URL.Query().Get("RessourceId") - - hook := client.Webhook{ - ResourceID: resourceID, - EventType: client.EventType(eventType), - } - - body, err := json.Marshal(hook) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - if err := store.UpdateWebhookRequestBody(r.Context(), webhookID, body); err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - detachedCtx, _ := contextutil.DetachedWithTimeout(r.Context(), 30*time.Second) - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "handle webhook", - Key: taskNameHandleWebhook, - WebhookID: webhookID, - }) - if err != nil { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - - err = connectorContext.Scheduler().Schedule(detachedCtx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - api.InternalServerError(w, r, err) - return - } - } -} - -func taskCreateWebhooks(c *client.Client) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskCreateWebhooks", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - stackPublicURL := os.Getenv("STACK_PUBLIC_URL") - if stackPublicURL == "" { - err := errors.New("STACK_PUBLIC_URL is not set") - otel.RecordError(span, err) - return err - } - - webhookURL := fmt.Sprintf("%s/api/payments/connectors/webhooks/mangopay/%s/", stackPublicURL, &connectorID) - logger.Infof("creating webhook for mangopay with url %s", webhookURL) - - alreadyExistingHooks, err := c.ListAllHooks(ctx) - if err != nil { - otel.RecordError(span, err) - return err - } - - activeHooks := make(map[client.EventType]*client.Hook) - for _, hook := range alreadyExistingHooks { - // Mangopay allows only one active hook per event type. - if hook.Validity == "VALID" { - activeHooks[hook.EventType] = hook - } - } - - for _, eventType := range client.AllEventTypes { - if v, ok := activeHooks[eventType]; ok { - // Already created, continue - - if v.URL != webhookURL { - // If the URL is different, update it - err := c.UpdateHook(ctx, v.ID, webhookURL) - if err != nil { - otel.RecordError(span, err) - return err - } - - logger.Infof("updated webhook for mangopay with event type %s", eventType) - } - - continue - } - - // Otherwise, create it - err := c.CreateHook(ctx, eventType, webhookURL) - if err != nil { - otel.RecordError(span, err) - return err - } - - logger.Infof("created webhook for mangopay with event type %s", eventType) - } - - return nil - } -} - -func taskHandleWebhooks(c *client.Client, webhookID uuid.UUID) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - storageReader storage.Reader, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "mangopay.taskHandleWebhooks", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("webhookID", webhookID.String()), - ) - defer span.End() - - w, err := storageReader.GetWebhook(ctx, webhookID) - if err != nil { - otel.RecordError(span, err) - return err - } - - webhook, err := c.UnmarshalWebhooks((string(w.RequestBody))) - if err != nil { - otel.RecordError(span, err) - return err - } - - switch webhook.EventType { - case client.EventTypeTransferNormalCreated, - client.EventTypeTransferNormalFailed, - client.EventTypeTransferNormalSucceeded: - logger.WithField("webhook", webhook).Info("handling transfer webhook") - return handleTransfer( - ctx, - c, - connectorID, - webhook, - ingester, - ) - - case client.EventTypePayoutNormalCreated, - client.EventTypePayoutNormalFailed, - client.EventTypePayoutNormalSucceeded, - client.EventTypePayoutInstantFailed, - client.EventTypePayoutInstantSucceeded: - logger.WithField("webhook", webhook).Info("handling payout webhook") - return handlePayout( - ctx, - c, - connectorID, - webhook, - ingester, - ) - - case client.EventTypePayinNormalCreated, - client.EventTypePayinNormalSucceeded, - client.EventTypePayinNormalFailed: - logger.WithField("webhook", webhook).Info("handling payin webhook") - return handlePayIn( - ctx, - c, - connectorID, - webhook, - ingester, - ) - - case client.EventTypeTransferRefundFailed, - client.EventTypeTransferRefundSucceeded, - client.EventTypePayOutRefundFailed, - client.EventTypePayOutRefundSucceeded, - client.EventTypePayinRefundFailed, - client.EventTypePayinRefundSucceeded: - logger.WithField("webhook", webhook).Info("handling refunds webhook") - return handleRefunds( - ctx, - c, - connectorID, - webhook, - ingester, - storageReader, - ) - - case client.EventTypeTransferRefundCreated, - client.EventTypePayOutRefundCreated, - client.EventTypePayinRefundCreated: - // NOTE: we don't handle these events, as we are only interested in - // the refund successed or failures. - - default: - // ignore unknown events - logger.Errorf("unknown event type: %s", webhook.EventType) - return nil - } - - return nil - } -} - -func handleTransfer( - ctx context.Context, - c *client.Client, - connectorID models.ConnectorID, - webhook *client.Webhook, - ingester ingestion.Ingester, -) error { - transfer, err := c.GetWalletTransfer(ctx, webhook.ResourceID) - if err != nil { - return err - } - - if err := fetchWallet(ctx, c, connectorID, transfer.CreditedWalletID, ingester); err != nil { - return err - } - - if err := fetchWallet(ctx, c, connectorID, transfer.DebitedWalletID, ingester); err != nil { - return err - } - - var amount big.Int - _, ok := amount.SetString(transfer.DebitedFunds.Amount.String(), 10) - if !ok { - return fmt.Errorf("failed to parse amount %s", transfer.DebitedFunds.Amount.String()) - } - paymentStatus := matchPaymentStatus(transfer.Status) - raw, err := json.Marshal(transfer) - if err != nil { - return fmt.Errorf("failed to marshal transfer: %w", err) - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: transfer.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(transfer.CreationDate, 0), - Reference: transfer.ID, - Amount: &amount, - InitialAmount: &amount, - ConnectorID: connectorID, - Type: models.PaymentTypeTransfer, - Status: paymentStatus, - Scheme: models.PaymentSchemeOther, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transfer.DebitedFunds.Currency), - RawData: raw, - } - - if transfer.DebitedWalletID != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: transfer.DebitedWalletID, - ConnectorID: connectorID, - } - } - - if transfer.CreditedWalletID != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: transfer.CreditedWalletID, - ConnectorID: connectorID, - } - } - - err = ingester.IngestPayments(ctx, []ingestion.PaymentBatchElement{{ - Payment: payment, - }}) - if err != nil { - return err - } - - return nil -} - -func handlePayIn( - ctx context.Context, - c *client.Client, - connectorID models.ConnectorID, - webhook *client.Webhook, - ingester ingestion.Ingester, -) error { - payin, err := c.GetPayin(ctx, webhook.ResourceID) - if err != nil { - return err - } - - // In case of a payin, there is no debited wallet id, so we can only - // fetch the credited wallet. - if err := fetchWallet(ctx, c, connectorID, payin.CreditedWalletID, ingester); err != nil { - return err - } - - var amount big.Int - _, ok := amount.SetString(payin.DebitedFunds.Amount.String(), 10) - if !ok { - return fmt.Errorf("failed to parse amount %s", payin.DebitedFunds.Amount.String()) - } - paymentStatus := matchPaymentStatus(payin.Status) - raw, err := json.Marshal(payin) - if err != nil { - return fmt.Errorf("failed to marshal transfer: %w", err) - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: payin.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(payin.CreationDate, 0), - Reference: payin.ID, - Amount: &amount, - InitialAmount: &amount, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: paymentStatus, - Scheme: models.PaymentSchemeOther, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, payin.DebitedFunds.Currency), - RawData: raw, - } - - if payin.CreditedWalletID != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: payin.CreditedWalletID, - ConnectorID: connectorID, - } - } - - err = ingester.IngestPayments(ctx, []ingestion.PaymentBatchElement{{ - Payment: payment, - }}) - if err != nil { - return err - } - - return nil -} - -func handlePayout( - ctx context.Context, - c *client.Client, - connectorID models.ConnectorID, - webhook *client.Webhook, - ingester ingestion.Ingester, -) error { - payout, err := c.GetPayout(ctx, webhook.ResourceID) - if err != nil { - return err - } - - // In case of a payout, there is no credited wallet id, so we can only - // fetch the debited wallet. - if err := fetchWallet(ctx, c, connectorID, payout.DebitedWalletID, ingester); err != nil { - return err - } - - var amount big.Int - _, ok := amount.SetString(payout.DebitedFunds.Amount.String(), 10) - if !ok { - return fmt.Errorf("failed to parse amount %s", payout.DebitedFunds.Amount.String()) - } - paymentStatus := matchPaymentStatus(payout.Status) - raw, err := json.Marshal(payout) - if err != nil { - return fmt.Errorf("failed to marshal transfer: %w", err) - } - - payment := &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: payout.ID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(payout.CreationDate, 0), - Reference: payout.ID, - Amount: &amount, - InitialAmount: &amount, - ConnectorID: connectorID, - Type: models.PaymentTypePayOut, - Status: paymentStatus, - Scheme: models.PaymentSchemeOther, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, payout.DebitedFunds.Currency), - RawData: raw, - } - - if payout.DebitedWalletID != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: payout.DebitedWalletID, - ConnectorID: connectorID, - } - } - - err = ingester.IngestPayments(ctx, []ingestion.PaymentBatchElement{{ - Payment: payment, - }}) - if err != nil { - return err - } - - return nil -} - -func handleRefunds( - ctx context.Context, - c *client.Client, - connectorID models.ConnectorID, - webhook *client.Webhook, - ingester ingestion.Ingester, - storageReader storage.Reader, -) error { - refund, err := c.GetRefund(ctx, webhook.ResourceID) - if err != nil { - return err - } - - var amountRefunded big.Int - _, ok := amountRefunded.SetString(refund.DebitedFunds.Amount.String(), 10) - if !ok { - return fmt.Errorf("failed to parse amount %s", refund.DebitedFunds.Amount.String()) - } - paymentType := matchPaymentType(refund.InitialTransactionType) - - var payment *models.Payment - if webhook.EventType == client.EventTypePayOutRefundSucceeded { - payment, err = storageReader.GetPayment(ctx, models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: refund.InitialTransactionID, - Type: paymentType, - }, - ConnectorID: connectorID, - }.String()) - if err != nil { - return err - } - - payment.Amount = payment.Amount.Sub(payment.Amount, &amountRefunded) - } - - paymentStatus := models.PaymentStatusRefundedFailure - if webhook.EventType == client.EventTypePayOutRefundSucceeded { - paymentStatus = models.PaymentStatusRefunded - } - - raw, err := json.Marshal(refund) - if err != nil { - return fmt.Errorf("failed to marshal refund: %w", err) - } - - adjustment := &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: refund.InitialTransactionID, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(refund.CreationDate, 0), - Reference: refund.ID, - Amount: &amountRefunded, - Status: paymentStatus, - RawData: raw, - } - - if err := ingester.IngestPayments(ctx, []ingestion.PaymentBatchElement{{ - Payment: payment, - Adjustment: adjustment, - }}); err != nil { - return err - } - - return nil -} - -func fetchWallet( - ctx context.Context, - c *client.Client, - connectorID models.ConnectorID, - walletID string, - ingester ingestion.Ingester, -) error { - if walletID == "" { - return nil - } - - wallet, err := c.GetWallet(ctx, walletID) - if err != nil { - return err - } - - raw, err := json.Marshal(wallet) - if err != nil { - return err - } - - userID := "" - if len(wallet.Owners) > 0 { - userID = wallet.Owners[0] - } - - account := &models.Account{ - ID: models.AccountID{ - Reference: wallet.ID, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(wallet.CreationDate, 0), - Reference: wallet.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, wallet.Currency), - AccountName: wallet.Description, - // Wallets are internal accounts on our side, since we - // can have their balances. - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "user_id": userID, - }, - RawData: raw, - } - - var amount big.Int - _, ok := amount.SetString(wallet.Balance.Amount.String(), 10) - if !ok { - return fmt.Errorf("failed to parse amount: %s", wallet.Balance.Amount.String()) - } - - now := time.Now() - balance := &models.Balance{ - AccountID: models.AccountID{ - Reference: wallet.ID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, wallet.Balance.Currency), - Balance: &amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - } - - if err := ingester.IngestAccounts(ctx, []*models.Account{account}); err != nil { - return err - } - - if err := ingester.IngestBalances(ctx, []*models.Balance{balance}, false); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/modulr/client/accounts.go b/cmd/connectors/internal/connectors/modulr/client/accounts.go deleted file mode 100644 index 5e989213..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/accounts.go +++ /dev/null @@ -1,64 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -//nolint:tagliatelle // allow for clients -type Account struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Balance string `json:"balance"` - Currency string `json:"currency"` - CustomerID string `json:"customerId"` - Identifiers []struct { - AccountNumber string `json:"accountNumber"` - SortCode string `json:"sortCode"` - Type string `json:"type"` - } `json:"identifiers"` - DirectDebit bool `json:"directDebit"` - CreatedDate string `json:"createdDate"` -} - -func (m *Client) GetAccounts(ctx context.Context, page, pageSize int) (*responseWrapper[[]*Account], error) { - f := connectors.ClientMetrics(ctx, "modulr", "list_accounts") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.buildEndpoint("accounts"), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create accounts request: %w", err) - } - - q := req.URL.Query() - q.Add("page", strconv.Itoa(page)) - q.Add("size", strconv.Itoa(pageSize)) - q.Add("sortField", "createdDate") - q.Add("sortOrder", "asc") - req.URL.RawQuery = q.Encode() - - resp, err := m.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var res responseWrapper[[]*Account] - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} diff --git a/cmd/connectors/internal/connectors/modulr/client/beneficiaries.go b/cmd/connectors/internal/connectors/modulr/client/beneficiaries.go deleted file mode 100644 index da3d6933..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/beneficiaries.go +++ /dev/null @@ -1,55 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Beneficiary struct { - ID string `json:"id"` - Name string `json:"name"` - Created string `json:"created"` -} - -func (m *Client) GetBeneficiaries(ctx context.Context, page, pageSize int, modifiedSince string) (*responseWrapper[[]*Beneficiary], error) { - f := connectors.ClientMetrics(ctx, "modulr", "list_beneficiaries") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.buildEndpoint("beneficiaries"), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create accounts request: %w", err) - } - - q := req.URL.Query() - q.Add("page", strconv.Itoa(page)) - q.Add("size", strconv.Itoa(pageSize)) - if modifiedSince != "" { - q.Add("modifiedSince", modifiedSince) - } - req.URL.RawQuery = q.Encode() - - resp, err := m.httpClient.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var res responseWrapper[[]*Beneficiary] - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} diff --git a/cmd/connectors/internal/connectors/modulr/client/client.go b/cmd/connectors/internal/connectors/modulr/client/client.go deleted file mode 100644 index 7fd340c4..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/client.go +++ /dev/null @@ -1,72 +0,0 @@ -package client - -import ( - "fmt" - "net/http" - "strings" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/hmac" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -type apiTransport struct { - apiKey string - headers map[string]string - underlying http.RoundTripper -} - -func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Add("Authorization", t.apiKey) - - return t.underlying.RoundTrip(req) -} - -type responseWrapper[t any] struct { - Content t `json:"content"` - Size int `json:"size"` - TotalSize int `json:"totalSize"` - Page int `json:"page"` - TotalPages int `json:"totalPages"` -} - -type Client struct { - httpClient *http.Client - endpoint string -} - -func (m *Client) buildEndpoint(path string, args ...interface{}) string { - endpoint := strings.TrimSuffix(m.endpoint, "/") - return fmt.Sprintf("%s/%s", endpoint, fmt.Sprintf(path, args...)) -} - -const SandboxAPIEndpoint = "https://api-sandbox.modulrfinance.com/api-sandbox-token" - -func NewClient(apiKey, apiSecret, endpoint string) (*Client, error) { - if endpoint == "" { - endpoint = SandboxAPIEndpoint - } - - headers, err := hmac.GenerateHeaders(apiKey, apiSecret, "", false) - if err != nil { - return nil, fmt.Errorf("failed to generate headers: %w", err) - } - - return &Client{ - httpClient: &http.Client{ - Transport: &apiTransport{ - headers: headers, - apiKey: apiKey, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - }, - endpoint: endpoint, - }, nil -} - -type ErrorResponse struct { - Field string `json:"field"` - Code string `json:"code"` - Message string `json:"message"` - ErrorCode string `json:"errorCode"` - SourceService string `json:"sourceService"` -} diff --git a/cmd/connectors/internal/connectors/modulr/client/error.go b/cmd/connectors/internal/connectors/modulr/client/error.go deleted file mode 100644 index 1b4e1173..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/error.go +++ /dev/null @@ -1,83 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/pkg/errors" -) - -type modulrError struct { - StatusCode int `json:"-"` - Field string `json:"field"` - Code string `json:"code"` - Message string `json:"message"` - ErrorCode string `json:"errorCode"` - SourceService string `json:"sourceService"` - WithRetry bool `json:"-"` -} - -func (me *modulrError) Error() error { - var err error - if me.Message == "" { - err = fmt.Errorf("unexpected status code: %d", me.StatusCode) - } else { - err = fmt.Errorf("%d: %s", me.StatusCode, me.Message) - } - - if me.WithRetry { - return checkStatusCodeError(me.StatusCode, err) - } - - return errors.Wrap(task.ErrNonRetryable, err.Error()) -} - -func unmarshalError(statusCode int, body io.ReadCloser, withRetry bool) *modulrError { - var ces []modulrError - _ = json.NewDecoder(body).Decode(&ces) - - if len(ces) == 0 { - return &modulrError{ - StatusCode: statusCode, - WithRetry: withRetry, - } - } - - return &modulrError{ - StatusCode: statusCode, - Field: ces[0].Field, - Code: ces[0].Code, - Message: ces[0].Message, - ErrorCode: ces[0].ErrorCode, - SourceService: ces[0].SourceService, - WithRetry: withRetry, - } -} - -func unmarshalErrorWithRetry(statusCode int, body io.ReadCloser) *modulrError { - return unmarshalError(statusCode, body, true) -} - -func unmarshalErrorWithoutRetry(statusCode int, body io.ReadCloser) *modulrError { - return unmarshalError(statusCode, body, false) -} - -func checkStatusCodeError(statusCode int, err error) error { - switch statusCode { - case http.StatusTooEarly, http.StatusRequestTimeout: - return errors.Wrap(task.ErrRetryable, err.Error()) - case http.StatusTooManyRequests: - // Retry rate limit errors - // TODO(polo): add rate limit handling - return errors.Wrap(task.ErrRetryable, err.Error()) - case http.StatusInternalServerError, http.StatusBadGateway, - http.StatusServiceUnavailable, http.StatusGatewayTimeout: - // Retry internal errors - return errors.Wrap(task.ErrRetryable, err.Error()) - default: - return errors.Wrap(task.ErrNonRetryable, err.Error()) - } -} diff --git a/cmd/connectors/internal/connectors/modulr/client/payout.go b/cmd/connectors/internal/connectors/modulr/client/payout.go deleted file mode 100644 index 04ab6441..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/payout.go +++ /dev/null @@ -1,83 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type PayoutRequest struct { - SourceAccountID string `json:"sourceAccountId"` - Destination struct { - Type string `json:"type"` - ID string `json:"id"` - } `json:"destination"` - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - Reference string `json:"reference"` - ExternalReference string `json:"externalReference"` -} - -type PayoutResponse struct { - ID string `json:"id"` - Status string `json:"status"` - CreatedDate string `json:"createdDate"` - ExternalReference string `json:"externalReference"` - ApprovalStatus string `json:"approvalStatus"` - Message string `json:"message"` -} - -func (c *Client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "modulr", "initiate_payout") - now := time.Now() - defer f(ctx, now) - - body, err := json.Marshal(payoutRequest) - if err != nil { - return nil, err - } - - resp, err := c.httpClient.Post(c.buildEndpoint("payments"), "application/json", bytes.NewBuffer(body)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var res PayoutResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} - -func (c *Client) GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "modulr", "get_payout") - now := time.Now() - defer f(ctx, now) - - resp, err := c.httpClient.Get(c.buildEndpoint("payments?id=%s", payoutID)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var res PayoutResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} diff --git a/cmd/connectors/internal/connectors/modulr/client/transactions.go b/cmd/connectors/internal/connectors/modulr/client/transactions.go deleted file mode 100644 index 8a15baa8..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/transactions.go +++ /dev/null @@ -1,63 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -//nolint:tagliatelle // allow different styled tags in client -type Transaction struct { - ID string `json:"id"` - Type string `json:"type"` - Amount json.Number `json:"amount"` - Credit bool `json:"credit"` - SourceID string `json:"sourceId"` - Description string `json:"description"` - PostedDate string `json:"postedDate"` - TransactionDate string `json:"transactionDate"` - Account Account `json:"account"` - AdditionalInfo interface{} `json:"additionalInfo"` -} - -func (m *Client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, fromTransactionDate string) (*responseWrapper[[]*Transaction], error) { - f := connectors.ClientMetrics(ctx, "modulr", "list_transactions") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.buildEndpoint("accounts/%s/transactions", accountID), http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create accounts request: %w", err) - } - - q := req.URL.Query() - q.Add("page", strconv.Itoa(page)) - q.Add("size", strconv.Itoa(pageSize)) - if fromTransactionDate != "" { - q.Add("fromTransactionDate", fromTransactionDate) - } - req.URL.RawQuery = q.Encode() - - resp, err := m.httpClient.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var res responseWrapper[[]*Transaction] - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} diff --git a/cmd/connectors/internal/connectors/modulr/client/transfer.go b/cmd/connectors/internal/connectors/modulr/client/transfer.go deleted file mode 100644 index 435370ab..00000000 --- a/cmd/connectors/internal/connectors/modulr/client/transfer.go +++ /dev/null @@ -1,108 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type DestinationType string - -const ( - DestinationTypeAccount DestinationType = "ACCOUNT" - DestinationTypeBeneficiary DestinationType = "BENEFICIARY" -) - -type Destination struct { - Type string `json:"type"` - ID string `json:"id"` -} - -type TransferRequest struct { - SourceAccountID string `json:"sourceAccountId"` - Destination Destination `json:"destination"` - Currency string `json:"currency"` - Amount json.Number `json:"amount"` - Reference string `json:"reference"` - ExternalReference string `json:"externalReference"` - PaymentDate string `json:"paymentDate"` -} - -type getTransferResponse struct { - Content []*TransferResponse `json:"content"` -} - -type TransferResponse struct { - ID string `json:"id"` - Status string `json:"status"` - CreatedDate string `json:"createdDate"` - ExternalReference string `json:"externalReference"` - ApprovalStatus string `json:"approvalStatus"` - Message string `json:"message"` -} - -func (c *Client) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "modulr", "initiate_transfer") - now := time.Now() - defer f(ctx, now) - - body, err := json.Marshal(transferRequest) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, c.buildEndpoint("payments"), bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create transfer request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to initiate transfer: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var res TransferResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res, nil -} - -func (c *Client) GetTransfer(ctx context.Context, transferID string) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "modulr", "get_transfer") - now := time.Now() - defer f(ctx, now) - - resp, err := c.httpClient.Get(c.buildEndpoint("payments?id=%s", transferID)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var res getTransferResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if len(res.Content) == 0 { - return nil, fmt.Errorf("transfer not found") - } - - return res.Content[0], nil -} diff --git a/cmd/connectors/internal/connectors/modulr/config.go b/cmd/connectors/internal/connectors/modulr/config.go deleted file mode 100644 index 343e8b73..00000000 --- a/cmd/connectors/internal/connectors/modulr/config.go +++ /dev/null @@ -1,69 +0,0 @@ -package modulr - -import ( - "encoding/json" - "fmt" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/client" -) - -const ( - defaultPollingPeriod = 2 * time.Minute - defaultPageSize = 100 -) - -type Config struct { - Name string `json:"name" bson:"name"` - APIKey string `json:"apiKey" bson:"apiKey"` - APISecret string `json:"apiSecret" bson:"apiSecret"` - Endpoint string `json:"endpoint" bson:"endpoint"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` - PageSize int `json:"pageSize" yaml:"pageSize" bson:"pageSize"` -} - -// String obfuscates sensitive fields and returns a string representation of the config. -// This is used for logging. -func (c Config) String() string { - return fmt.Sprintf("endpoint=%s, apiSecret=***, apiKey=****", c.Endpoint) -} - -func (c Config) Validate() error { - if c.APIKey == "" { - return ErrMissingAPIKey - } - - if c.APISecret == "" { - return ErrMissingAPISecret - } - - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("apiSecret", configtemplate.TypeString, "", true) - cfg.AddParameter("endpoint", configtemplate.TypeString, client.SandboxAPIEndpoint, false) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - cfg.AddParameter("pageSize", configtemplate.TypeDurationUnsignedInteger, strconv.Itoa(defaultPageSize), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/modulr/connector.go b/cmd/connectors/internal/connectors/modulr/connector.go deleted file mode 100644 index 23f43232..00000000 --- a/cmd/connectors/internal/connectors/modulr/connector.go +++ /dev/null @@ -1,136 +0,0 @@ -package modulr - -import ( - "context" - - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const name = models.ConnectorProviderModulr - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch accounts and transactions", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_NOW, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - return nil - } - - return resolveTasks(c.logger, c.cfg)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/modulr/currencies.go b/cmd/connectors/internal/connectors/modulr/currencies.go deleted file mode 100644 index d54ca965..00000000 --- a/cmd/connectors/internal/connectors/modulr/currencies.go +++ /dev/null @@ -1,20 +0,0 @@ -package modulr - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - // c.f. https://modulr.readme.io/docs/international-payments - supportedCurrenciesWithDecimal = map[string]int{ - "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna - "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone - "PLN": currency.ISO4217Currencies["PLN"], // Poland, Zloty - "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona - "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc - "USD": currency.ISO4217Currencies["USD"], // US Dollar - "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar - "JPY": currency.ISO4217Currencies["JPY"], // Japan, Yen - } -) diff --git a/cmd/connectors/internal/connectors/modulr/errors.go b/cmd/connectors/internal/connectors/modulr/errors.go deleted file mode 100644 index f756d3c1..00000000 --- a/cmd/connectors/internal/connectors/modulr/errors.go +++ /dev/null @@ -1,17 +0,0 @@ -package modulr - -import "github.com/pkg/errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingAPIKey is returned when the api key is missing from config. - ErrMissingAPIKey = errors.New("missing apiKey from config") - - // ErrMissingAPISecret is returned when the api secret is missing from config. - ErrMissingAPISecret = errors.New("missing apiSecret from config") - - // ErrMissingName is returned when the name is missing from config. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/modulr/loader.go b/cmd/connectors/internal/connectors/modulr/loader.go deleted file mode 100644 index c8b7a88c..00000000 --- a/cmd/connectors/internal/connectors/modulr/loader.go +++ /dev/null @@ -1,51 +0,0 @@ -package modulr - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - if cfg.PageSize == 0 { - cfg.PageSize = defaultPageSize - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/modulr/task_fetch_accounts.go b/cmd/connectors/internal/connectors/modulr/task_fetch_accounts.go deleted file mode 100644 index b1e8fcff..00000000 --- a/cmd/connectors/internal/connectors/modulr/task_fetch_accounts.go +++ /dev/null @@ -1,179 +0,0 @@ -package modulr - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchAccounts(config Config, client *client.Client) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "modulr.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - err := fetchAccounts( - ctx, - config, - client, - connectorID, - ingester, - scheduler, - ) - if err != nil { - otel.RecordError(span, err) - // Retry errors are handled by the function - return err - } - - return nil - } -} - -func fetchAccounts( - ctx context.Context, - config Config, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, -) error { - for page := 0; ; page++ { - pagedAccounts, err := client.GetAccounts( - ctx, - page, - config.PageSize, - ) - if err != nil { - // Retry errors are handled by the client - return err - } - - if len(pagedAccounts.Content) == 0 { - break - } - - if err := ingestAccountsBatch(ctx, connectorID, ingester, pagedAccounts.Content); err != nil { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - for _, account := range pagedAccounts.Content { - transactionsTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transactions from client by account", - Key: taskNameFetchTransactions, - AccountID: account.ID, - }) - if err != nil { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, transactionsTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - } - - if len(pagedAccounts.Content) < config.PageSize { - break - } - - if page+1 >= pagedAccounts.TotalPages { - // Modulr paging starts at 0, so the last page is TotalPages - 1. - break - } - } - - return nil -} - -func ingestAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accounts []*client.Account, -) error { - accountsBatch := ingestion.AccountBatch{} - balancesBatch := ingestion.BalanceBatch{} - - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - openingDate, err := time.Parse(timeTemplate, account.CreatedDate) - if err != nil { - return err - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: account.ID, - ConnectorID: connectorID, - }, - CreatedAt: openingDate, - Reference: account.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency), - AccountName: account.Name, - Type: models.AccountTypeInternal, - RawData: raw, - }) - - // No need to check if the currency is supported for accounts and - // balances. - precision := supportedCurrenciesWithDecimal[account.Currency] - - amount, err := currency.GetAmountWithPrecisionFromString(account.Balance, precision) - if err != nil { - return fmt.Errorf("failed to parse amount %s: %w", account.Balance, err) - } - - now := time.Now() - balancesBatch = append(balancesBatch, &models.Balance{ - AccountID: models.AccountID{ - Reference: account.ID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency), - Balance: amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return err - } - - if err := ingester.IngestBalances(ctx, balancesBatch, false); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/modulr/task_fetch_beneficiaries.go b/cmd/connectors/internal/connectors/modulr/task_fetch_beneficiaries.go deleted file mode 100644 index 6bcc7d95..00000000 --- a/cmd/connectors/internal/connectors/modulr/task_fetch_beneficiaries.go +++ /dev/null @@ -1,198 +0,0 @@ -package modulr - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchBeneficiariesState struct { - LastCreated time.Time `json:"last_created"` -} - -func (s *fetchBeneficiariesState) UpdateLatest(latest *client.Beneficiary) error { - createdTime, err := time.Parse("2006-01-02T15:04:05.999-0700", latest.Created) - if err != nil { - return err - } - if createdTime.After(s.LastCreated) { - s.LastCreated = createdTime - } - return nil -} - -func (s *fetchBeneficiariesState) FindLatest(beneficiaries []*client.Beneficiary) error { - for _, beneficiary := range beneficiaries { - if err := s.UpdateLatest(beneficiary); err != nil { - return err - } - } - return nil -} - -func (s *fetchBeneficiariesState) IsNew(beneficiary *client.Beneficiary) (bool, error) { - createdTime, err := time.Parse("2006-01-02T15:04:05.999-0700", beneficiary.Created) - if err != nil { - return false, err - } - return createdTime.After(s.LastCreated), nil -} - -func (s *fetchBeneficiariesState) FilterNew(beneficiaries []*client.Beneficiary) ([]*client.Beneficiary, error) { - // beneficiaries are not assumed to be sorted by creation date. - result := make([]*client.Beneficiary, 0, len(beneficiaries)) - for _, beneficiary := range beneficiaries { - isNew, err := s.IsNew(beneficiary) - if err != nil { - return nil, err - } - if !isNew { - continue - } - result = append(result, beneficiary) - } - return result, nil -} - -func (s *fetchBeneficiariesState) GetFilterValue() string { - if s.LastCreated.IsZero() { - return "" - } - return s.LastCreated.Format("2006-01-02T15:04:05-0700") -} - -func taskFetchBeneficiaries(config Config, client *client.Client) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "modulr.taskFetchBeneficiaries", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state, err := fetchBeneficiaries( - ctx, - config, - client, - connectorID, - ingester, - scheduler, - task.MustResolveTo(ctx, resolver, fetchBeneficiariesState{}), - ) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, state); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func fetchBeneficiaries( - ctx context.Context, - config Config, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - state fetchBeneficiariesState, -) (fetchBeneficiariesState, error) { - newState := state - for page := 0; ; page++ { - pagedBeneficiaries, err := client.GetBeneficiaries( - ctx, - page, - config.PageSize, - state.GetFilterValue(), - ) - if err != nil { - // Retry errors are handled by the client - return newState, err - } - if len(pagedBeneficiaries.Content) == 0 { - break - } - beneficiaries, err := state.FilterNew(pagedBeneficiaries.Content) - if err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - if err := newState.FindLatest(beneficiaries); err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - if err := ingestBeneficiariesAccountsBatch(ctx, connectorID, ingester, beneficiaries); err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedBeneficiaries.Content) < config.PageSize { - break - } - - if page+1 >= pagedBeneficiaries.TotalPages { - // Modulr paging starts at 0, so the last page is TotalPages - 1. - break - } - } - - return newState, nil -} - -func ingestBeneficiariesAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - beneficiaries []*client.Beneficiary, -) error { - accountsBatch := ingestion.AccountBatch{} - for _, beneficiary := range beneficiaries { - raw, err := json.Marshal(beneficiary) - if err != nil { - return err - } - - openingDate, err := time.Parse(timeTemplate, beneficiary.Created) - if err != nil { - return err - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: beneficiary.ID, - ConnectorID: connectorID, - }, - CreatedAt: openingDate, - Reference: beneficiary.ID, - ConnectorID: connectorID, - AccountName: beneficiary.Name, - Type: models.AccountTypeExternal, - RawData: raw, - }) - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/modulr/task_fetch_transactions.go b/cmd/connectors/internal/connectors/modulr/task_fetch_transactions.go deleted file mode 100644 index 3d6b1d93..00000000 --- a/cmd/connectors/internal/connectors/modulr/task_fetch_transactions.go +++ /dev/null @@ -1,275 +0,0 @@ -package modulr - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchTransactionsState struct { - LastTransactionTime time.Time `json:"last_transaction_time"` -} - -func (s *fetchTransactionsState) UpdateLatest(latest *client.Transaction) error { - transactionTime, err := time.Parse("2006-01-02T15:04:05.999-0700", latest.TransactionDate) - if err != nil { - return err - } - if transactionTime.After(s.LastTransactionTime) { - s.LastTransactionTime = transactionTime - } - return nil -} - -func (s *fetchTransactionsState) FindLatest(transactions []*client.Transaction) error { - for _, transaction := range transactions { - if err := s.UpdateLatest(transaction); err != nil { - return err - } - } - return nil -} - -func (s *fetchTransactionsState) IsNew(transaction *client.Transaction) (bool, error) { - transactionTime, err := time.Parse("2006-01-02T15:04:05.999-0700", transaction.TransactionDate) - if err != nil { - return false, err - } - return transactionTime.After(s.LastTransactionTime), nil -} - -func (s *fetchTransactionsState) FilterNew(transactions []*client.Transaction) ([]*client.Transaction, error) { - result := make([]*client.Transaction, 0, len(transactions)) - for _, transaction := range transactions { - isNew, err := s.IsNew(transaction) - if err != nil { - return nil, err - } - if !isNew { - continue - } - result = append(result, transaction) - } - return result, nil -} - -func (s *fetchTransactionsState) GetFilterValue() string { - if s.LastTransactionTime.IsZero() { - return "" - } - return s.LastTransactionTime.Format("2006-01-02T15:04:05-0700") -} - -func taskFetchTransactions(config Config, client *client.Client, accountID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "modulr.taskFetchTransactions", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("accountID", accountID), - ) - defer span.End() - - state, err := fetchTransactions( - ctx, - config, - client, - accountID, - connectorID, - ingester, - task.MustResolveTo(ctx, resolver, fetchTransactionsState{}), - ) - if err != nil { - otel.RecordError(span, err) - // Retry errors are handled by the function - return err - } - - if err := ingester.UpdateTaskState(ctx, state); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func fetchTransactions( - ctx context.Context, - config Config, - client *client.Client, - accountID string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - state fetchTransactionsState, -) (fetchTransactionsState, error) { - newState := state - for page := 0; ; page++ { - pagedTransactions, err := client.GetTransactions( - ctx, - accountID, - page, - config.PageSize, - state.GetFilterValue(), - ) - if err != nil { - // Retry errors are handled by the client - return newState, err - } - - if len(pagedTransactions.Content) == 0 { - break - } - - transactions, err := state.FilterNew(pagedTransactions.Content) - if err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - - batch, err := toBatch(connectorID, accountID, transactions) - if err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if err := ingester.IngestPayments(ctx, batch); err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if err := newState.FindLatest(transactions); err != nil { - return newState, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedTransactions.Content) < config.PageSize { - break - } - - if page+1 >= pagedTransactions.TotalPages { - // Modulr paging starts at 0, so the last page is TotalPages - 1. - break - } - } - - return newState, nil -} - -func toBatch( - connectorID models.ConnectorID, - accountID string, - transactions []*client.Transaction, -) (ingestion.PaymentBatch, error) { - batch := ingestion.PaymentBatch{} - - for _, transaction := range transactions { - - rawData, err := json.Marshal(transaction) - if err != nil { - return nil, fmt.Errorf("failed to marshal transaction: %w", err) - } - - paymentType := matchTransactionType(transaction.Type) - - precision, ok := supportedCurrenciesWithDecimal[transaction.Account.Currency] - if !ok { - continue - } - - amount, err := currency.GetAmountWithPrecisionFromString(transaction.Amount.String(), precision) - if err != nil { - return nil, fmt.Errorf("failed to parse amount %s: %w", transaction.Amount, err) - } - - createdAt, err := time.Parse(timeTemplate, transaction.PostedDate) - if err != nil { - return nil, fmt.Errorf("failed to parse posted date %s: %w", transaction.PostedDate, err) - } - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: transaction.ID, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - CreatedAt: createdAt, - Reference: transaction.ID, - ConnectorID: connectorID, - Type: paymentType, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeOther, - Amount: amount, - InitialAmount: amount, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Account.Currency), - RawData: rawData, - }, - } - - switch paymentType { - case models.PaymentTypePayIn: - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: accountID, - ConnectorID: connectorID, - } - case models.PaymentTypePayOut: - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: accountID, - ConnectorID: connectorID, - } - default: - if transaction.Credit { - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: accountID, - ConnectorID: connectorID, - } - } else { - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: accountID, - ConnectorID: connectorID, - } - } - } - - batch = append(batch, batchElement) - } - - return batch, nil -} - -func matchTransactionType(transactionType string) models.PaymentType { - if transactionType == "PI_REV" || - transactionType == "PO_REV" || - transactionType == "ADHOC" || - transactionType == "INT_INTERC" { - return models.PaymentTypeOther - } - - if strings.HasPrefix(transactionType, "PI_") { - return models.PaymentTypePayIn - } - - if strings.HasPrefix(transactionType, "PO_") { - return models.PaymentTypePayOut - } - - return models.PaymentTypeOther -} diff --git a/cmd/connectors/internal/connectors/modulr/task_main.go b/cmd/connectors/internal/connectors/modulr/task_main.go deleted file mode 100644 index 87bf2d12..00000000 --- a/cmd/connectors/internal/connectors/modulr/task_main.go +++ /dev/null @@ -1,70 +0,0 @@ -package modulr - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain(config Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "modulr.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - taskBeneficiaries, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch beneficiaries from client", - Key: taskNameFetchBeneficiaries, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskBeneficiaries, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/modulr/task_payments.go b/cmd/connectors/internal/connectors/modulr/task_payments.go deleted file mode 100644 index c8568682..00000000 --- a/cmd/connectors/internal/connectors/modulr/task_payments.go +++ /dev/null @@ -1,341 +0,0 @@ -package modulr - -import ( - "context" - "encoding/json" - "regexp" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -var ( - ReferencePatternRegexp = regexp.MustCompile("[a-zA-Z0-9 ]*") -) - -func taskInitiatePayment(modulrClient *client.Client, transferID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "modulr.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, modulrClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - modulrClient *client.Client, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount == nil { - err = errors.New("no source account provided") - return err - } - - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - - var curr string - var precision int - curr, precision, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - amount, err := currency.GetStringAmountFromBigIntWithPrecision(transfer.Amount, precision) - if err != nil { - return err - } - - description := "" - if len(transfer.Description) <= 18 && ReferencePatternRegexp.MatchString(transfer.Description) { - description = transfer.Description - } - - var connectorPaymentID string - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - var resp *client.TransferResponse - resp, err = modulrClient.InitiateTransfer(ctx, &client.TransferRequest{ - SourceAccountID: transfer.SourceAccountID.Reference, - Destination: client.Destination{ - Type: string(client.DestinationTypeAccount), - ID: transfer.DestinationAccountID.Reference, - }, - Currency: curr, - Amount: json.Number(amount), - Reference: description, - ExternalReference: description, - PaymentDate: time.Now().Add(24 * time.Hour).Format("2006-01-02"), - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - var resp *client.PayoutResponse - resp, err = modulrClient.InitiatePayout(ctx, &client.PayoutRequest{ - SourceAccountID: transfer.SourceAccountID.Reference, - Destination: client.Destination{ - Type: string(client.DestinationTypeBeneficiary), - ID: transfer.DestinationAccountID.Reference, - }, - Currency: curr, - Amount: json.Number(amount), - Reference: description, - ExternalReference: description, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: connectorPaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func taskUpdatePaymentStatus( - modulrClient *client.Client, - transferID string, - pID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "modulr.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", pID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := updatePaymentStatus(ctx, modulrClient, transfer, paymentID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func updatePaymentStatus( - ctx context.Context, - modulrClient *client.Client, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var status string - var resultMessage string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - var resp *client.TransferResponse - resp, err = modulrClient.GetTransfer(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - resultMessage = resp.Message - case models.TransferInitiationTypePayout: - var resp *client.PayoutResponse - resp, err = modulrClient.GetPayout(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - resultMessage = resp.Message - } - - switch status { - case "SUBMITTED", "PENDING_FOR_DATE", "PENDING_FOR_FUNDS", "VALIDATED", "SCREENING_REQ": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: 2 * time.Minute, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - case "EXT_PROC", "PROCESSED", "RECONCILED": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - default: - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, resultMessage, time.Now()) - if err != nil { - return err - } - - return nil - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/modulr/task_resolve.go b/cmd/connectors/internal/connectors/modulr/task_resolve.go deleted file mode 100644 index 74b46314..00000000 --- a/cmd/connectors/internal/connectors/modulr/task_resolve.go +++ /dev/null @@ -1,66 +0,0 @@ -package modulr - -import ( - "fmt" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/modulr/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" - - "github.com/formancehq/go-libs/logging" -) - -const ( - taskNameMain = "main" - taskNameFetchTransactions = "fetch-transactions" - taskNameFetchAccounts = "fetch-accounts" - taskNameFetchBeneficiaries = "fetch-beneficiaries" - taskNameInitiatePayment = "initiate-payment" - taskNameUpdatePaymentStatus = "update-payment-status" -) - -const ( - timeTemplate = "2006-01-02T15:04:05-0700" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - AccountID string `json:"accountID" yaml:"accountID" bson:"accountID"` - TransferID string `json:"transferID" yaml:"transferID" bson:"transferID"` - PaymentID string `json:"paymentID" yaml:"paymentID" bson:"paymentID"` - Attempt int `json:"attempt" yaml:"attempt" bson:"attempt"` -} - -func resolveTasks(logger logging.Logger, config Config) func(taskDefinition TaskDescriptor) task.Task { - modulrClient, err := client.NewClient(config.APIKey, config.APISecret, config.Endpoint) - if err != nil { - return func(taskDefinition TaskDescriptor) task.Task { - return func() error { - return fmt.Errorf("key '%s': %w", taskDefinition.Key, ErrMissingTask) - } - } - } - - return func(taskDefinition TaskDescriptor) task.Task { - switch taskDefinition.Key { - case taskNameMain: - return taskMain(config) - case taskNameFetchAccounts: - return taskFetchAccounts(config, modulrClient) - case taskNameFetchBeneficiaries: - return taskFetchBeneficiaries(config, modulrClient) - case taskNameInitiatePayment: - return taskInitiatePayment(modulrClient, taskDefinition.TransferID) - case taskNameUpdatePaymentStatus: - return taskUpdatePaymentStatus(modulrClient, taskDefinition.TransferID, taskDefinition.PaymentID, taskDefinition.Attempt) - case taskNameFetchTransactions: - return taskFetchTransactions(config, modulrClient, taskDefinition.AccountID) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDefinition.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/accounts.go b/cmd/connectors/internal/connectors/moneycorp/client/accounts.go deleted file mode 100644 index c452a518..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/accounts.go +++ /dev/null @@ -1,70 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type accountsResponse struct { - Accounts []*Account `json:"data"` -} - -type Account struct { - ID string `json:"id"` - Attributes struct { - AccountName string `json:"accountName"` - } `json:"attributes"` -} - -func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "list_accounts") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts", c.endpoint) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create accounts request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - q := req.URL.Query() - q.Add("page[size]", strconv.Itoa(pageSize)) - q.Add("page[number]", fmt.Sprint(page)) - q.Add("sortBy", "id.asc") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Account{}, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var accounts accountsResponse - if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { - return nil, fmt.Errorf("failed to unmarshal accounts response body: %w", err) - } - - return accounts.Accounts, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/auth.go b/cmd/connectors/internal/connectors/moneycorp/client/auth.go deleted file mode 100644 index 2db18bc9..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/auth.go +++ /dev/null @@ -1,110 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -// Cannot use "golang.org/x/oauth2/clientcredentials" lib because moneycorp -// is only accepting request with "application/json" content type, and the lib -// sets it as application/x-www-form-urlencoded, giving us a 415 error. -type apiTransport struct { - logger logging.Logger - - clientID string - apiKey string - endpoint string - - accessToken string - accessTokenExpiresAt time.Time - - underlying *otelhttp.Transport -} - -func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if err := t.ensureAccessTokenIsValid(req.Context()); err != nil { - return nil, err - } - - req.Header.Add("Authorization", "Bearer "+t.accessToken) - - return t.underlying.RoundTrip(req) -} - -func (t *apiTransport) ensureAccessTokenIsValid(ctx context.Context) error { - if t.accessTokenExpiresAt.After(time.Now().Add(5 * time.Second)) { - return nil - } - - return t.login(ctx) -} - -type loginRequest struct { - ClientID string `json:"loginId"` - APIKey string `json:"apiKey"` -} - -type loginResponse struct { - Data struct { - AccessToken string `json:"accessToken"` - ExpiresIn int `json:"expiresIn"` - } `json:"data"` -} - -func (t *apiTransport) login(ctx context.Context) error { - lreq := loginRequest{ - ClientID: t.clientID, - APIKey: t.apiKey, - } - - requestBody, err := json.Marshal(lreq) - if err != nil { - return fmt.Errorf("failed to marshal login request: %w", err) - } - - f := connectors.ClientMetrics(ctx, "moneycorp", "login") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - t.endpoint+"/login", bytes.NewBuffer(requestBody)) - if err != nil { - return fmt.Errorf("failed to create login request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to login: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - t.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var res loginResponse - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return fmt.Errorf("failed to decode login response: %w", err) - } - - t.accessToken = res.Data.AccessToken - t.accessTokenExpiresAt = time.Now().Add(time.Duration(res.Data.ExpiresIn) * time.Second) - - return nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/balances.go b/cmd/connectors/internal/connectors/moneycorp/client/balances.go deleted file mode 100644 index 267193d8..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/balances.go +++ /dev/null @@ -1,67 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type balancesResponse struct { - Balances []*Balance `json:"data"` -} - -type Balance struct { - ID string `json:"id"` - Attributes struct { - CurrencyCode string `json:"currencyCode"` - OverallBalance json.Number `json:"overallBalance"` - AvailableBalance json.Number `json:"availableBalance"` - ClearedBalance json.Number `json:"clearedBalance"` - ReservedBalance json.Number `json:"reservedBalance"` - UnclearedBalance json.Number `json:"unclearedBalance"` - } `json:"attributes"` -} - -func (c *Client) GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "list_account_balances") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts/%s/balances", c.endpoint, accountID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get account balances: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Balance{}, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var balances balancesResponse - if err := json.NewDecoder(resp.Body).Decode(&balances); err != nil { - return nil, fmt.Errorf("failed to unmarshal balances response body: %w", err) - } - - return balances.Balances, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/client.go b/cmd/connectors/internal/connectors/moneycorp/client/client.go deleted file mode 100644 index 0e9f9094..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/client.go +++ /dev/null @@ -1,45 +0,0 @@ -package client - -import ( - "net/http" - "strings" - "time" - - "github.com/formancehq/go-libs/logging" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -type Client struct { - httpClient *http.Client - - endpoint string - - logger logging.Logger -} - -func newHTTPClient(clientID, apiKey, endpoint string, logger logging.Logger) *http.Client { - return &http.Client{ - Timeout: 10 * time.Second, - Transport: &apiTransport{ - logger: logger, - clientID: clientID, - apiKey: apiKey, - endpoint: endpoint, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - } -} - -func NewClient(clientID, apiKey, endpoint string, logger logging.Logger) (*Client, error) { - endpoint = strings.TrimSuffix(endpoint, "/") - - c := &Client{ - httpClient: newHTTPClient(clientID, apiKey, endpoint, logger), - - endpoint: endpoint, - - logger: logger, - } - - return c, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/error.go b/cmd/connectors/internal/connectors/moneycorp/client/error.go deleted file mode 100644 index eb6f3a46..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/error.go +++ /dev/null @@ -1,83 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/pkg/errors" -) - -type moneycorpErrors struct { - Errors []*moneycorpError `json:"errors"` -} - -type moneycorpError struct { - StatusCode int `json:"-"` - Code string `json:"code"` - Title string `json:"title"` - Detail string `json:"detail"` - WithRetry bool `json:"-"` -} - -func (me *moneycorpError) Error() error { - var err error - if me.Detail == "" { - err = fmt.Errorf("unexpected status code: %d", me.StatusCode) - } else { - err = fmt.Errorf("%d: %s", me.StatusCode, me.Detail) - } - - if me.WithRetry { - return checkStatusCodeError(me.StatusCode, err) - } - - return errors.Wrap(task.ErrNonRetryable, err.Error()) -} - -func unmarshalError(statusCode int, body io.ReadCloser, withRetry bool) *moneycorpError { - var ces moneycorpErrors - _ = json.NewDecoder(body).Decode(&ces) - - if len(ces.Errors) == 0 { - return &moneycorpError{ - StatusCode: statusCode, - WithRetry: withRetry, - } - } - - return &moneycorpError{ - StatusCode: statusCode, - Code: ces.Errors[0].Code, - Title: ces.Errors[0].Title, - Detail: ces.Errors[0].Detail, - WithRetry: withRetry, - } -} - -func unmarshalErrorWithoutRetry(statusCode int, body io.ReadCloser) *moneycorpError { - return unmarshalError(statusCode, body, false) -} - -func unmarshalErrorWithRetry(statusCode int, body io.ReadCloser) *moneycorpError { - return unmarshalError(statusCode, body, true) -} - -func checkStatusCodeError(statusCode int, err error) error { - switch statusCode { - case http.StatusTooEarly, http.StatusRequestTimeout: - return errors.Wrap(task.ErrRetryable, err.Error()) - case http.StatusTooManyRequests: - // Retry rate limit errors - // TODO(polo): add rate limit handling - return errors.Wrap(task.ErrRetryable, err.Error()) - case http.StatusInternalServerError, http.StatusBadGateway, - http.StatusServiceUnavailable, http.StatusGatewayTimeout: - // Retry internal errors - return errors.Wrap(task.ErrRetryable, err.Error()) - default: - return errors.Wrap(task.ErrNonRetryable, err.Error()) - } -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/payout.go b/cmd/connectors/internal/connectors/moneycorp/client/payout.go deleted file mode 100644 index adb31ea3..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/payout.go +++ /dev/null @@ -1,134 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type payoutRequest struct { - Payout struct { - Attributes *PayoutRequest `json:"attributes"` - } `json:"data"` -} - -type PayoutRequest struct { - SourceAccountID string `json:"-"` - IdempotencyKey string `json:"-"` - RecipientID string `json:"recipientId"` - PaymentDate string `json:"paymentDate"` - PaymentAmount json.Number `json:"paymentAmount"` - PaymentCurrency string `json:"paymentCurrency"` - PaymentMethgod string `json:"paymentMethod"` - PaymentReference string `json:"paymentReference"` - ClientReference string `json:"clientReference"` - PaymentPurpose string `json:"paymentPurpose"` -} - -type payoutResponse struct { - Payout *PayoutResponse `json:"data"` -} - -type PayoutResponse struct { - ID string `json:"id"` - Attributes struct { - AccountID string `json:"accountId"` - PaymentAmount json.Number `json:"paymentAmount"` - PaymentCurrency string `json:"paymentCurrency"` - PaymentApproved bool `json:"paymentApproved"` - PaymentStatus string `json:"paymentStatus"` - PaymentMethod string `json:"paymentMethod"` - PaymentDate string `json:"paymentDate"` - PaymentValueDate string `json:"paymentValueDate"` - RecipientDetails struct { - RecipientID int32 `json:"recipientId"` - } `json:"recipientDetails"` - PaymentReference string `json:"paymentReference"` - ClientReference string `json:"clientReference"` - CreatedAt string `json:"createdAt"` - CreatedBy string `json:"createdBy"` - UpdatedAt string `json:"updatedAt"` - PaymentPurpose string `json:"paymentPurpose"` - } `json:"attributes"` -} - -func (c *Client) InitiatePayout(ctx context.Context, pr *PayoutRequest) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "initiate_payout") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts/%s/payments", c.endpoint, pr.SourceAccountID) - - reqBody := &payoutRequest{} - reqBody.Payout.Attributes = pr - body, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal payout request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Idempotency-Key", pr.IdempotencyKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - // Never retry payout initiation - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var res payoutResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return res.Payout, nil -} - -func (c *Client) GetPayout(ctx context.Context, accountID string, payoutID string) (*PayoutResponse, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "get_payout") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts/%s/payments/%s", c.endpoint, accountID, payoutID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create get payout request request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get account balances: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var payoutResponse payoutResponse - if err := json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return payoutResponse.Payout, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/recipients.go b/cmd/connectors/internal/connectors/moneycorp/client/recipients.go deleted file mode 100644 index ace7da20..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/recipients.go +++ /dev/null @@ -1,72 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type recipientsResponse struct { - Recipients []*Recipient `json:"data"` -} - -type Recipient struct { - ID string `json:"id"` - Attributes struct { - BankAccountCurrency string `json:"bankAccountCurrency"` - CreatedAt string `json:"createdAt"` - BankAccountName string `json:"bankAccountName"` - } `json:"attributes"` -} - -func (c *Client) GetRecipients(ctx context.Context, accountID string, page int, pageSize int) ([]*Recipient, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "list_recipients") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts/%s/recipients", c.endpoint, accountID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create recipients request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - q := req.URL.Query() - q.Add("page[size]", strconv.Itoa(pageSize)) - q.Add("page[number]", fmt.Sprint(page)) - q.Add("sortBy", "createdAt.asc") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Recipient{}, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var recipients recipientsResponse - if err := json.NewDecoder(resp.Body).Decode(&recipients); err != nil { - return nil, fmt.Errorf("failed to unmarshal recipients response body: %w", err) - } - - return recipients.Recipients, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/transactions.go b/cmd/connectors/internal/connectors/moneycorp/client/transactions.go deleted file mode 100644 index 48b56ff4..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/transactions.go +++ /dev/null @@ -1,114 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type transactionsResponse struct { - Transactions []*Transaction `json:"data"` -} - -type fetchTransactionRequest struct { - Data struct { - Attributes struct { - TransactionDateTimeFrom string `json:"transactionDateTimeFrom"` - } `json:"attributes"` - } `json:"data"` -} - -type Transaction struct { - ID string `json:"id"` - Type string `json:"type"` - Attributes struct { - AccountID int32 `json:"accountId"` - CreatedAt string `json:"createdAt"` - Currency string `json:"transactionCurrency"` - Amount json.Number `json:"transactionAmount"` - Direction string `json:"transactionDirection"` - Type string `json:"transactionType"` - ClientReference string `json:"clientReference"` - TransactionReference string `json:"transactionReference"` - } `json:"attributes"` -} - -func (c *Client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "list_transactions") - now := time.Now() - defer f(ctx, now) - - var body io.Reader - if !lastCreatedAt.IsZero() { - reqBody := fetchTransactionRequest{ - Data: struct { - Attributes struct { - TransactionDateTimeFrom string "json:\"transactionDateTimeFrom\"" - } "json:\"attributes\"" - }{ - Attributes: struct { - TransactionDateTimeFrom string "json:\"transactionDateTimeFrom\"" - }{ - TransactionDateTimeFrom: lastCreatedAt.Format("2006-01-02T15:04:05.999999999"), - }, - }, - } - - raw, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal transfer request: %w", err) - } - - body = bytes.NewBuffer(raw) - } else { - body = http.NoBody - } - - endpoint := fmt.Sprintf("%s/accounts/%s/transactions/find", c.endpoint, accountID) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) - if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - q := req.URL.Query() - q.Add("page[size]", strconv.Itoa(pageSize)) - q.Add("page[number]", fmt.Sprint(page)) - q.Add("sortBy", "createdAt.asc") - req.URL.RawQuery = q.Encode() - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get transactions: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Transaction{}, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var transactions transactionsResponse - if err := json.NewDecoder(resp.Body).Decode(&transactions); err != nil { - return nil, fmt.Errorf("failed to unmarshal transactions response body: %w", err) - } - - return transactions.Transactions, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/client/transfer.go b/cmd/connectors/internal/connectors/moneycorp/client/transfer.go deleted file mode 100644 index 2badd511..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/client/transfer.go +++ /dev/null @@ -1,133 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type transferRequest struct { - Transfer struct { - Attributes *TransferRequest `json:"attributes"` - } `json:"data"` -} - -type TransferRequest struct { - SourceAccountID string `json:"-"` - IdempotencyKey string `json:"-"` - ReceivingAccountID string `json:"receivingAccountId"` - TransferAmount json.Number `json:"transferAmount"` - TransferCurrency string `json:"transferCurrency"` - TransferReference string `json:"transferReference,omitempty"` - ClientReference string `json:"clientReference,omitempty"` -} - -type transferResponse struct { - Transfer *TransferResponse `json:"data"` -} - -type TransferResponse struct { - ID string `json:"id"` - Attributes struct { - SendingAccountID int64 `json:"sendingAccountId"` - SendingAccountName string `json:"sendingAccountName"` - ReceivingAccountID int64 `json:"receivingAccountId"` - ReceivingAccountName string `json:"receivingAccountName"` - CreatedAt string `json:"createdAt"` - CreatedBy string `json:"createdBy"` - UpdatedAt string `json:"updatedAt"` - TransferReference string `json:"transferReference"` - ClientReference string `json:"clientReference"` - TransferDate string `json:"transferDate"` - TransferAmount json.Number `json:"transferAmount"` - TransferCurrency string `json:"transferCurrency"` - TransferStatus string `json:"transferStatus"` - } `json:"attributes"` -} - -func (c *Client) InitiateTransfer(ctx context.Context, tr *TransferRequest) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "initiate_transfer") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts/%s/transfers", c.endpoint, tr.SourceAccountID) - - reqBody := &transferRequest{} - reqBody.Transfer.Attributes = tr - body, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal transfer request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) - if err != nil { - return nil, fmt.Errorf("failed to create transfer request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Idempotency-Key", tr.IdempotencyKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get account balances: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusCreated { - // Never retry transfer initiation - return nil, unmarshalErrorWithoutRetry(resp.StatusCode, resp.Body).Error() - } - - var transferResponse transferResponse - if err := json.NewDecoder(resp.Body).Decode(&transferResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return transferResponse.Transfer, nil -} - -func (c *Client) GetTransfer(ctx context.Context, accountID string, transferID string) (*TransferResponse, error) { - f := connectors.ClientMetrics(ctx, "moneycorp", "get_transfer") - now := time.Now() - defer f(ctx, now) - - endpoint := fmt.Sprintf("%s/accounts/%s/transfers/%s", c.endpoint, accountID, transferID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) - if err != nil { - return nil, fmt.Errorf("failed to create get transfer request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get account balances: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - c.logger.Error(err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalErrorWithRetry(resp.StatusCode, resp.Body).Error() - } - - var transferResponse transferResponse - if err := json.NewDecoder(resp.Body).Decode(&transferResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) - } - - return transferResponse.Transfer, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/config.go b/cmd/connectors/internal/connectors/moneycorp/config.go deleted file mode 100644 index a59c08fa..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/config.go +++ /dev/null @@ -1,67 +0,0 @@ -package moneycorp - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" -) - -const ( - pageSize = 100 - defaultPollingPeriod = 2 * time.Minute -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - ClientID string `json:"clientID" yaml:"clientID" bson:"clientID"` - APIKey string `json:"apiKey" yaml:"apiKey" bson:"apiKey"` - Endpoint string `json:"endpoint" yaml:"endpoint" bson:"endpoint"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -func (c Config) String() string { - return fmt.Sprintf("clientID=%s, apiKey=****", c.ClientID) -} - -func (c Config) Validate() error { - if c.ClientID == "" { - return ErrMissingClientID - } - - if c.APIKey == "" { - return ErrMissingAPIKey - } - - if c.Endpoint == "" { - return ErrMissingEndpoint - } - - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("clientID", configtemplate.TypeString, "", true) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("endpoint", configtemplate.TypeString, "", true) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/moneycorp/connector.go b/cmd/connectors/internal/connectors/moneycorp/connector.go deleted file mode 100644 index 1d265477..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/connector.go +++ /dev/null @@ -1,136 +0,0 @@ -package moneycorp - -import ( - "context" - "errors" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const name = models.ConnectorProviderMoneycorp - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch accounts and transactions", - Key: taskNameMain, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - taskDescriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return resolveTasks(c.logger, c.cfg)(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/moneycorp/currencies.go b/cmd/connectors/internal/connectors/moneycorp/currencies.go deleted file mode 100644 index f0bff207..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/currencies.go +++ /dev/null @@ -1,63 +0,0 @@ -package moneycorp - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - supportedCurrenciesWithDecimal = map[string]int{ - "AED": currency.ISO4217Currencies["AED"], // UAE Dirham - "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar - "BBD": currency.ISO4217Currencies["BBD"], // Barbados Dollar - "BGN": currency.ISO4217Currencies["BGN"], // Bulgarian lev - "BHD": currency.ISO4217Currencies["BHD"], // Bahraini dinar - "BWP": currency.ISO4217Currencies["BWP"], // Botswana pula - "CAD": currency.ISO4217Currencies["CAD"], // Canadian dollar - "CHF": currency.ISO4217Currencies["CHF"], // Swiss franc - "CZK": currency.ISO4217Currencies["CZK"], // Czech koruna - "DKK": currency.ISO4217Currencies["DKK"], // Danish krone - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "GBP": currency.ISO4217Currencies["GBP"], // Pound sterling - "GHS": currency.ISO4217Currencies["GHS"], // Ghanaian cedi - "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong dollar - "ILS": currency.ISO4217Currencies["ILS"], // Israeli new shekel - "INR": currency.ISO4217Currencies["INR"], // Indian rupee - "JMD": currency.ISO4217Currencies["JMD"], // Jamaican dollar - "JPY": currency.ISO4217Currencies["JPY"], // Japanese yen - "KES": currency.ISO4217Currencies["KES"], // Kenyan shilling - "LKR": currency.ISO4217Currencies["LKR"], // Sri Lankan rupee - "MAD": currency.ISO4217Currencies["MAD"], // Moroccan dirham - "MUR": currency.ISO4217Currencies["MUR"], // Mauritian rupee - "MXN": currency.ISO4217Currencies["MXN"], // Mexican peso - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian krone - "NPR": currency.ISO4217Currencies["NPR"], // Nepalese rupee - "NZD": currency.ISO4217Currencies["NZD"], // New Zealand dollar - "OMR": currency.ISO4217Currencies["OMR"], // Omani rial - "PHP": currency.ISO4217Currencies["PHP"], // Philippine peso - "PKR": currency.ISO4217Currencies["PKR"], // Pakistani rupee - "PLN": currency.ISO4217Currencies["PLN"], // Polish złoty - "QAR": currency.ISO4217Currencies["QAR"], // Qatari riyal - "RON": currency.ISO4217Currencies["RON"], // Romanian leu - "RSD": currency.ISO4217Currencies["RSD"], // Serbian dinar - "SAR": currency.ISO4217Currencies["SAR"], // Saudi riyal - "SEK": currency.ISO4217Currencies["SEK"], // Swedish krona/kronor - "SGD": currency.ISO4217Currencies["SGD"], // Singapore dollar - "THB": currency.ISO4217Currencies["THB"], // Thai baht - "TRY": currency.ISO4217Currencies["TRY"], // Turkish lira - "TTD": currency.ISO4217Currencies["TTD"], // Trinidad and Tobago dollar - "UGX": currency.ISO4217Currencies["UGX"], // Ugandan shilling - "USD": currency.ISO4217Currencies["USD"], // United States dollar - "XCD": currency.ISO4217Currencies["XCD"], // East Caribbean dollar - "ZAR": currency.ISO4217Currencies["ZAR"], // South African rand - "ZMW": currency.ISO4217Currencies["ZMW"], // Zambian kwacha - - // All following currencies have not the same decimals given by - // Moneycorp compared to the ISO 4217 standard. - // Let's not handle them for now. - // "CNH": 2, // Chinese Yuan - // "IDR": 2, // Indonesian rupiah - // "ISK": 0, // Icelandic króna - // "HUF": 2, // Hungarian forint - // "JOD": 3, // Jordanian dinar - // "KWD": 3, // Kuwaiti dinar - // "TND": 3, // Tunisian dinar - } -) diff --git a/cmd/connectors/internal/connectors/moneycorp/errors.go b/cmd/connectors/internal/connectors/moneycorp/errors.go deleted file mode 100644 index 8e034ec9..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package moneycorp - -import "errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingClientID is returned when the clientID is missing. - ErrMissingClientID = errors.New("missing clientID from config") - - // ErrMissingAPIKey is returned when the apiKey is missing. - ErrMissingAPIKey = errors.New("missing apiKey from config") - - // ErrMissingEndpoint is returned when the endpoint is missing. - ErrMissingEndpoint = errors.New("missing endpoint from config") - - // ErrMissingName is returned when the name is missing. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/moneycorp/loader.go b/cmd/connectors/internal/connectors/moneycorp/loader.go deleted file mode 100644 index c44a03c5..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/loader.go +++ /dev/null @@ -1,47 +0,0 @@ -package moneycorp - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_fetch_accounts.go b/cmd/connectors/internal/connectors/moneycorp/task_fetch_accounts.go deleted file mode 100644 index 51bc589c..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_fetch_accounts.go +++ /dev/null @@ -1,186 +0,0 @@ -package moneycorp - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -type fetchAccountsState struct { - LastPage int `json:"last_page"` - // Moneycorp does not send the creation date for accounts, but we can still - // sort by ID created (which is incremental when creating accounts). - LastIDCreated string `json:"last_id_created"` -} - -func taskFetchAccounts(client *client.Client, config *Config) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskFetchAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchAccountsState{}) - - newState, err := fetchAccounts(ctx, config, client, connectorID, ingester, scheduler, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchAccounts( - ctx context.Context, - config *Config, - client *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - state fetchAccountsState, -) (fetchAccountsState, error) { - newState := fetchAccountsState{ - LastPage: state.LastPage, - LastIDCreated: state.LastIDCreated, - } - - for page := state.LastPage; ; page++ { - newState.LastPage = page - - pagedAccounts, err := client.GetAccounts(ctx, page, pageSize) - if err != nil { - return fetchAccountsState{}, err - } - - if len(pagedAccounts) == 0 { - break - } - - batch := ingestion.AccountBatch{} - transactionTasks := []models.TaskDescriptor{} - balanceTasks := []models.TaskDescriptor{} - recipientTasks := []models.TaskDescriptor{} - for _, account := range pagedAccounts { - if account.ID <= state.LastIDCreated { - continue - } - - raw, err := json.Marshal(account) - if err != nil { - return fetchAccountsState{}, err - } - - batch = append(batch, &models.Account{ - ID: models.AccountID{ - Reference: account.ID, - ConnectorID: connectorID, - }, - // Moneycorp does not send the opening date of the account - CreatedAt: time.Now().UTC(), - Reference: account.ID, - ConnectorID: connectorID, - AccountName: account.Attributes.AccountName, - Type: models.AccountTypeInternal, - RawData: raw, - }) - - transactionTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transactions from client by account", - Key: taskNameFetchTransactions, - AccountID: account.ID, - }) - if err != nil { - return fetchAccountsState{}, err - } - transactionTasks = append(transactionTasks, transactionTask) - - balanceTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch balances from client by account", - Key: taskNameFetchBalances, - AccountID: account.ID, - }) - if err != nil { - return fetchAccountsState{}, err - } - balanceTasks = append(balanceTasks, balanceTask) - - recipientTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch recipients from client", - Key: taskNameFetchRecipients, - AccountID: account.ID, - }) - if err != nil { - return fetchAccountsState{}, err - } - recipientTasks = append(recipientTasks, recipientTask) - - newState.LastIDCreated = account.ID - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return fetchAccountsState{}, err - } - - for _, transactionTask := range transactionTasks { - if err := scheduler.Schedule(ctx, transactionTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }); err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchAccountsState{}, err - } - } - - for _, balanceTask := range balanceTasks { - if err := scheduler.Schedule(ctx, balanceTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }); err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchAccountsState{}, err - } - } - - for _, recipientTask := range recipientTasks { - if err := scheduler.Schedule(ctx, recipientTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: config.PollingPeriod.Duration, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }); err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return fetchAccountsState{}, err - } - } - - if len(pagedAccounts) < pageSize { - break - } - } - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_fetch_balances.go b/cmd/connectors/internal/connectors/moneycorp/task_fetch_balances.go deleted file mode 100644 index 2f96463d..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_fetch_balances.go +++ /dev/null @@ -1,97 +0,0 @@ -package moneycorp - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchBalances(client *client.Client, accountID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskFetchBalances", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("accountID", accountID), - ) - defer span.End() - - if err := fetchBalances(ctx, client, accountID, connectorID, ingester); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchBalances( - ctx context.Context, - client *client.Client, - accountID string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, -) error { - balances, err := client.GetAccountBalances(ctx, accountID) - if err != nil { - // retryable error already handled by the client - return err - } - - if err := ingestBalancesBatch(ctx, connectorID, ingester, accountID, balances); err != nil { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil -} - -func ingestBalancesBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accountID string, - balances []*client.Balance, -) error { - batch := ingestion.BalanceBatch{} - for _, balance := range balances { - precision, err := currency.GetPrecision(supportedCurrenciesWithDecimal, balance.Attributes.CurrencyCode) - if err != nil { - return err - } - - amount, err := currency.GetAmountWithPrecisionFromString(balance.Attributes.AvailableBalance.String(), precision) - if err != nil { - return err - } - - now := time.Now() - batch = append(batch, &models.Balance{ - AccountID: models.AccountID{ - Reference: accountID, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Attributes.CurrencyCode), - Balance: amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - }) - } - - return ingester.IngestBalances(ctx, batch, false) -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_fetch_recipients.go b/cmd/connectors/internal/connectors/moneycorp/task_fetch_recipients.go deleted file mode 100644 index 5e9823ea..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_fetch_recipients.go +++ /dev/null @@ -1,135 +0,0 @@ -package moneycorp - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchRecipientsState struct { - LastPage int `json:"last_page"` - // Moneycorp does not allow us to sort by , but we can still - // sort by ID created (which is incremental when creating accounts). - LastCreatedAt time.Time `json:"last_created_at"` -} - -func taskFetchRecipients(client *client.Client, accountID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskFetchRecipients", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("accountID", accountID), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchRecipientsState{}) - - newState, err := fetchRecipients(ctx, client, accountID, connectorID, ingester, scheduler, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func fetchRecipients( - ctx context.Context, - client *client.Client, - accountID string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - state fetchRecipientsState, -) (fetchRecipientsState, error) { - newState := fetchRecipientsState{ - LastPage: state.LastPage, - LastCreatedAt: state.LastCreatedAt, - } - - for page := 0; ; page++ { - newState.LastPage = page - - pagedRecipients, err := client.GetRecipients(ctx, accountID, page, pageSize) - if err != nil { - // Retryable errors already handled by the client - return fetchRecipientsState{}, err - } - - if len(pagedRecipients) == 0 { - break - } - - batch := ingestion.AccountBatch{} - for _, recipient := range pagedRecipients { - createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", recipient.Attributes.CreatedAt) - if err != nil { - return fetchRecipientsState{}, errors.Wrap(task.ErrNonRetryable, fmt.Sprintf("failed to parse transaction date: %v", err)) - } - - switch createdAt.Compare(state.LastCreatedAt) { - case -1, 0: - continue - default: - } - - raw, err := json.Marshal(recipient) - if err != nil { - return fetchRecipientsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - batch = append(batch, &models.Account{ - ID: models.AccountID{ - Reference: recipient.ID, - ConnectorID: connectorID, - }, - // Moneycorp does not send the opening date of the account - CreatedAt: createdAt, - Reference: recipient.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, recipient.Attributes.BankAccountCurrency), - AccountName: recipient.Attributes.BankAccountName, - Type: models.AccountTypeExternal, - RawData: raw, - }) - - newState.LastCreatedAt = createdAt - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return fetchRecipientsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedRecipients) < pageSize { - break - } - } - - return newState, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_fetch_transactions.go b/cmd/connectors/internal/connectors/moneycorp/task_fetch_transactions.go deleted file mode 100644 index 53695292..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_fetch_transactions.go +++ /dev/null @@ -1,214 +0,0 @@ -package moneycorp - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchTransactionsState struct { - LastCreatedAt time.Time `json:"last_created_at"` -} - -func taskFetchTransactions(client *client.Client, accountID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskFetchTransactions", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("accountID", accountID), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchTransactionsState{}) - - newState, err := fetchTransactions(ctx, client, accountID, connectorID, ingester, state) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := ingester.UpdateTaskState(ctx, newState); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func fetchTransactions( - ctx context.Context, - client *client.Client, - accountID string, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - state fetchTransactionsState, -) (fetchTransactionsState, error) { - newState := fetchTransactionsState{ - LastCreatedAt: state.LastCreatedAt, - } - - for page := 0; ; page++ { - pagedTransactions, err := client.GetTransactions(ctx, accountID, page, pageSize, state.LastCreatedAt) - if err != nil { - // retryable error already handled by the client - return fetchTransactionsState{}, err - } - - if len(pagedTransactions) == 0 { - break - } - - batch := ingestion.PaymentBatch{} - for _, transaction := range pagedTransactions { - createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transaction.Attributes.CreatedAt) - if err != nil { - return fetchTransactionsState{}, errors.Wrap(task.ErrNonRetryable, fmt.Sprintf("failed to parse transaction date: %v", err)) - } - - switch createdAt.Compare(state.LastCreatedAt) { - case -1, 0: - continue - default: - } - - newState.LastCreatedAt = createdAt - - batchElement, err := toPaymentBatch(connectorID, transaction) - if err != nil { - return fetchTransactionsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if batchElement == nil { - continue - } - - batch = append(batch, *batchElement) - } - - if err := ingester.IngestPayments(ctx, batch); err != nil { - return fetchTransactionsState{}, errors.Wrap(task.ErrRetryable, err.Error()) - } - - if len(pagedTransactions) < pageSize { - break - } - } - - return fetchTransactionsState{}, nil -} - -func toPaymentBatch( - connectorID models.ConnectorID, - transaction *client.Transaction, -) (*ingestion.PaymentBatchElement, error) { - rawData, err := json.Marshal(transaction) - if err != nil { - return nil, fmt.Errorf("failed to marshal transaction: %w", err) - } - - paymentType, shouldBeRecorded := matchPaymentType(transaction.Attributes.Type, transaction.Attributes.Direction) - if !shouldBeRecorded { - return nil, nil - } - - createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transaction.Attributes.CreatedAt) - if err != nil { - return nil, fmt.Errorf("failed to parse transaction date: %w", err) - } - - c, err := currency.GetPrecision(supportedCurrenciesWithDecimal, transaction.Attributes.Currency) - if err != nil { - return nil, err - } - - amount, err := currency.GetAmountWithPrecisionFromString(transaction.Attributes.Amount.String(), c) - if err != nil { - return nil, err - } - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: transaction.ID, - Type: paymentType, - }, - ConnectorID: connectorID, - }, - CreatedAt: createdAt, - Reference: transaction.ID, - ConnectorID: connectorID, - Amount: amount, - InitialAmount: amount, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Attributes.Currency), - Type: paymentType, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeOther, - RawData: rawData, - }, - } - - switch paymentType { - case models.PaymentTypePayIn: - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: strconv.Itoa(int(transaction.Attributes.AccountID)), - ConnectorID: connectorID, - } - case models.PaymentTypePayOut: - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: strconv.Itoa(int(transaction.Attributes.AccountID)), - ConnectorID: connectorID, - } - default: - if transaction.Attributes.Direction == "Debit" { - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: strconv.Itoa(int(transaction.Attributes.AccountID)), - ConnectorID: connectorID, - } - } else { - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: strconv.Itoa(int(transaction.Attributes.AccountID)), - ConnectorID: connectorID, - } - } - } - - return &batchElement, nil -} - -func matchPaymentType(transactionType string, transactionDirection string) (models.PaymentType, bool) { - switch transactionType { - case "Transfer": - return models.PaymentTypeTransfer, true - case "Payment", "Exchange", "Charge", "Refund": - switch transactionDirection { - case "Debit": - return models.PaymentTypePayOut, true - case "Credit": - return models.PaymentTypePayIn, true - } - } - - return models.PaymentTypeOther, false -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_main.go b/cmd/connectors/internal/connectors/moneycorp/task_main.go deleted file mode 100644 index 4391b0da..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_main.go +++ /dev/null @@ -1,50 +0,0 @@ -package moneycorp - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_payments.go b/cmd/connectors/internal/connectors/moneycorp/task_payments.go deleted file mode 100644 index e2ecc981..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_payments.go +++ /dev/null @@ -1,326 +0,0 @@ -package moneycorp - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskInitiatePayment(moneycorpClient *client.Client, transferID string) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, moneycorpClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - moneycorpClient *client.Client, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount == nil { - err = errors.New("no source account provided") - } - - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - - var curr string - var precision int - curr, precision, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - amount, err := currency.GetStringAmountFromBigIntWithPrecision(transfer.Amount, precision) - if err != nil { - return err - } - - var connectorPaymentID string - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - var resp *client.TransferResponse - resp, err = moneycorpClient.InitiateTransfer(ctx, &client.TransferRequest{ - SourceAccountID: transfer.SourceAccountID.Reference, - IdempotencyKey: fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments)), - ReceivingAccountID: transfer.DestinationAccountID.Reference, - TransferAmount: json.Number(amount), - TransferCurrency: curr, - TransferReference: transfer.Description, - ClientReference: transfer.Description, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - var resp *client.PayoutResponse - resp, err = moneycorpClient.InitiatePayout(ctx, &client.PayoutRequest{ - SourceAccountID: transfer.SourceAccountID.Reference, - IdempotencyKey: fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments)), - RecipientID: transfer.DestinationAccountID.Reference, - PaymentAmount: json.Number(amount), - PaymentCurrency: curr, - PaymentMethgod: "Standard", - PaymentReference: transfer.Description, - ClientReference: transfer.Description, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: connectorPaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func taskUpdatePaymentStatus( - moneycorpClient *client.Client, - transferID string, - pID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "moneycorp.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", pID), - attribute.Int("attempt", attempt), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := updatePaymentStatus(ctx, moneycorpClient, transfer, paymentID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func updatePaymentStatus( - ctx context.Context, - moneycorpClient *client.Client, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var status string - var resultMessage string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - var resp *client.TransferResponse - resp, err = moneycorpClient.GetTransfer(ctx, transfer.SourceAccount.Reference, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Attributes.TransferStatus - case models.TransferInitiationTypePayout: - var resp *client.PayoutResponse - resp, err = moneycorpClient.GetPayout(ctx, transfer.SourceAccount.Reference, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Attributes.PaymentStatus - } - - switch status { - case "Awaiting Dispatch": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: 2 * time.Minute, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - case "Cleared", "Sent": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - case "Unauthorised", "Failed", "Cancelled": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, resultMessage, time.Now()) - if err != nil { - return err - } - - return nil - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/moneycorp/task_resolve.go b/cmd/connectors/internal/connectors/moneycorp/task_resolve.go deleted file mode 100644 index 4be23abd..00000000 --- a/cmd/connectors/internal/connectors/moneycorp/task_resolve.go +++ /dev/null @@ -1,72 +0,0 @@ -package moneycorp - -import ( - "fmt" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/moneycorp/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const ( - taskNameMain = "main" - taskNameFetchAccounts = "fetch-accounts" - taskNameFetchRecipients = "fetch-recipients" - taskNameFetchTransactions = "fetch-transactions" - taskNameFetchBalances = "fetch-balances" - taskNameInitiatePayment = "initiate-payment" - taskNameUpdatePaymentStatus = "update-payment-status" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - AccountID string `json:"accountID" yaml:"accountID" bson:"accountID"` - TransferID string `json:"transferID" yaml:"transferID" bson:"transferID"` - PaymentID string `json:"paymentID" yaml:"paymentID" bson:"paymentID"` - Attempt int `json:"attempt" yaml:"attempt" bson:"attempt"` -} - -// clientID, apiKey, endpoint string, logger logging -func resolveTasks(logger logging.Logger, config Config) func(taskDefinition TaskDescriptor) task.Task { - moneycorpClient, err := client.NewClient( - config.ClientID, - config.APIKey, - config.Endpoint, - logger, - ) - if err != nil { - logger.Error(err) - - return func(taskDescriptor TaskDescriptor) task.Task { - return func() error { - return fmt.Errorf("cannot build moneycorp client: %w", err) - } - } - } - - return func(taskDescriptor TaskDescriptor) task.Task { - switch taskDescriptor.Key { - case taskNameMain: - return taskMain() - case taskNameFetchAccounts: - return taskFetchAccounts(moneycorpClient, &config) - case taskNameFetchRecipients: - return taskFetchRecipients(moneycorpClient, taskDescriptor.AccountID) - case taskNameFetchTransactions: - return taskFetchTransactions(moneycorpClient, taskDescriptor.AccountID) - case taskNameFetchBalances: - return taskFetchBalances(moneycorpClient, taskDescriptor.AccountID) - case taskNameInitiatePayment: - return taskInitiatePayment(moneycorpClient, taskDescriptor.TransferID) - case taskNameUpdatePaymentStatus: - return taskUpdatePaymentStatus(moneycorpClient, taskDescriptor.TransferID, taskDescriptor.PaymentID, taskDescriptor.Attempt) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDescriptor.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/connectors/stripe/client/accounts.go b/cmd/connectors/internal/connectors/stripe/client/accounts.go deleted file mode 100644 index f35bfa8a..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/accounts.go +++ /dev/null @@ -1,84 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" -) - -const ( - accountsEndpoint = "https://api.stripe.com/v1/accounts" -) - -//nolint:tagliatelle // allow different styled tags in client -type AccountsListResponse struct { - HasMore bool `json:"has_more"` - Data []*stripe.Account `json:"data"` -} - -func (d *DefaultClient) Accounts(ctx context.Context, - options ...ClientOption, -) ([]*stripe.Account, bool, error) { - f := connectors.ClientMetrics(ctx, "stripe", "list_accounts") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, accountsEndpoint, nil) - if err != nil { - return nil, false, errors.Wrap(err, "creating http request") - } - - for _, opt := range options { - opt.Apply(req) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(d.apiKey, "") // gfyrag: really weird authentication right? - - var httpResponse *http.Response - - httpResponse, err = d.httpClient.Do(req) - if err != nil { - return nil, false, errors.Wrap(err, "doing request") - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - return nil, false, fmt.Errorf("unexpected status code: %d", httpResponse.StatusCode) - } - - type listResponse struct { - AccountsListResponse - Data []json.RawMessage `json:"data"` - } - - rsp := &listResponse{} - - err = json.NewDecoder(httpResponse.Body).Decode(rsp) - if err != nil { - return nil, false, errors.Wrap(err, "decoding response") - } - - accounts := make([]*stripe.Account, 0) - - if len(rsp.Data) > 0 { - for _, data := range rsp.Data { - account := &stripe.Account{} - - err = json.Unmarshal(data, &account) - if err != nil { - return nil, false, err - } - - accounts = append(accounts, account) - } - } - - return accounts, rsp.HasMore, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/client/balance_transactions.go b/cmd/connectors/internal/connectors/stripe/client/balance_transactions.go deleted file mode 100644 index 9c53d773..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/balance_transactions.go +++ /dev/null @@ -1,88 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" -) - -const ( - balanceTransactionsEndpoint = "https://api.stripe.com/v1/balance_transactions" -) - -//nolint:tagliatelle // allow different styled tags in client -type TransactionsListResponse struct { - HasMore bool `json:"has_more"` - Data []*stripe.BalanceTransaction `json:"data"` -} - -func (d *DefaultClient) BalanceTransactions(ctx context.Context, - options ...ClientOption, -) ([]*stripe.BalanceTransaction, bool, error) { - f := connectors.ClientMetrics(ctx, "stripe", "list_balance_transactions") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, balanceTransactionsEndpoint, nil) - if err != nil { - return nil, false, errors.Wrap(err, "creating http request") - } - - for _, opt := range options { - opt.Apply(req) - } - - if d.stripeAccount != "" { - req.Header.Set("Stripe-Account", d.stripeAccount) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(d.apiKey, "") // gfyrag: really weird authentication right? - - var httpResponse *http.Response - - httpResponse, err = d.httpClient.Do(req) - if err != nil { - return nil, false, errors.Wrap(err, "doing request") - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - return nil, false, fmt.Errorf("unexpected status code: %d", httpResponse.StatusCode) - } - - type listResponse struct { - TransactionsListResponse - Data []json.RawMessage `json:"data"` - } - - rsp := &listResponse{} - - err = json.NewDecoder(httpResponse.Body).Decode(rsp) - if err != nil { - return nil, false, errors.Wrap(err, "decoding response") - } - - asBalanceTransactions := make([]*stripe.BalanceTransaction, 0) - - if len(rsp.Data) > 0 { - for _, data := range rsp.Data { - asBalanceTransaction := &stripe.BalanceTransaction{} - - err = json.Unmarshal(data, &asBalanceTransaction) - if err != nil { - return nil, false, err - } - - asBalanceTransactions = append(asBalanceTransactions, asBalanceTransaction) - } - } - - return asBalanceTransactions, rsp.HasMore, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/client/balances.go b/cmd/connectors/internal/connectors/stripe/client/balances.go deleted file mode 100644 index 651768f6..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/balances.go +++ /dev/null @@ -1,67 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" -) - -const ( - balanceEndpoint = "https://api.stripe.com/v1/balance" -) - -type BalanceResponse struct { - *stripe.Balance -} - -func (d *DefaultClient) Balance(ctx context.Context, options ...ClientOption) (*stripe.Balance, error) { - f := connectors.ClientMetrics(ctx, "stripe", "get_balance") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, balanceEndpoint, nil) - if err != nil { - return nil, errors.Wrap(err, "creating http request") - } - - for _, opt := range options { - opt.Apply(req) - } - - if d.stripeAccount != "" { - req.Header.Set("Stripe-Account", d.stripeAccount) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(d.apiKey, "") // gfyrag: really weird authentication right? - - var httpResponse *http.Response - httpResponse, err = d.httpClient.Do(req) - if err != nil { - return nil, errors.Wrap(err, "doing request") - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", httpResponse.StatusCode) - } - - type balanceResponse struct { - BalanceResponse - } - - rsp := &balanceResponse{} - - err = json.NewDecoder(httpResponse.Body).Decode(rsp) - if err != nil { - return nil, errors.Wrap(err, "decoding response") - } - - return rsp.Balance, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/client/client.go b/cmd/connectors/internal/connectors/stripe/client/client.go deleted file mode 100644 index cd7dcfd7..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/client.go +++ /dev/null @@ -1,72 +0,0 @@ -package client - -import ( - "context" - "net/http" - "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - - "github.com/stripe/stripe-go/v72" -) - -type ClientOption interface { - Apply(req *http.Request) -} -type ClientOptionFn func(req *http.Request) - -func (fn ClientOptionFn) Apply(req *http.Request) { - fn(req) -} - -func QueryParam(key, value string) ClientOptionFn { - return func(req *http.Request) { - q := req.URL.Query() - q.Set(key, value) - req.URL.RawQuery = q.Encode() - } -} - -type Client interface { - Accounts(ctx context.Context, options ...ClientOption) ([]*stripe.Account, bool, error) - ExternalAccounts(ctx context.Context, options ...ClientOption) ([]*stripe.ExternalAccount, bool, error) - BalanceTransactions(ctx context.Context, options ...ClientOption) ([]*stripe.BalanceTransaction, bool, error) - Balance(ctx context.Context, options ...ClientOption) (*stripe.Balance, error) - CreateTransfer(ctx context.Context, CreateTransferRequest *CreateTransferRequest, options ...ClientOption) (*stripe.Transfer, error) - ReverseTransfer(ctx context.Context, createTransferReversalRequest *CreateTransferReversalRequest, options ...ClientOption) (*stripe.Reversal, error) - CreatePayout(ctx context.Context, createPayoutRequest *CreatePayoutRequest, options ...ClientOption) (*stripe.Payout, error) - GetPayout(ctx context.Context, payoutID string, options ...ClientOption) (*stripe.Payout, error) - ForAccount(account string) Client -} - -type DefaultClient struct { - httpClient *http.Client - apiKey string - stripeAccount string -} - -func NewDefaultClient(apiKey string) *DefaultClient { - return &DefaultClient{ - httpClient: newHTTPClient(), - apiKey: apiKey, - } -} - -func (d *DefaultClient) ForAccount(account string) Client { - cp := *d - cp.stripeAccount = account - - return &cp -} - -func newHTTPClient() *http.Client { - return &http.Client{ - Transport: otelhttp.NewTransport(http.DefaultTransport), - } -} - -var _ Client = &DefaultClient{} - -func DatePtr(t time.Time) *time.Time { - return &t -} diff --git a/cmd/connectors/internal/connectors/stripe/client/client_test.go b/cmd/connectors/internal/connectors/stripe/client/client_test.go deleted file mode 100644 index e0c74412..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/client_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package client - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "reflect" - "sync" - "testing" - "time" - - "github.com/stripe/stripe-go/v72" -) - -type httpMockExpectation interface { - handle(t *testing.T, r *http.Request) (*http.Response, error) -} - -type httpMock struct { - t *testing.T - expectations []httpMockExpectation - mu sync.Mutex -} - -func (mock *httpMock) RoundTrip(request *http.Request) (*http.Response, error) { - mock.mu.Lock() - defer mock.mu.Unlock() - - if len(mock.expectations) == 0 { - return nil, fmt.Errorf("no more expectations") - } - - expectations := mock.expectations[0] - if len(mock.expectations) == 1 { - mock.expectations = make([]httpMockExpectation, 0) - } else { - mock.expectations = mock.expectations[1:] - } - - return expectations.handle(mock.t, request) -} - -var _ http.RoundTripper = &httpMock{} - -type HTTPExpect[REQUEST any, RESPONSE any] struct { - statusCode int - path string - method string - requestBody *REQUEST - responseBody *RESPONSE - queryParams map[string]any -} - -func (e *HTTPExpect[REQUEST, RESPONSE]) handle(t *testing.T, request *http.Request) (*http.Response, error) { - t.Helper() - - if e.path != request.URL.Path { - return nil, fmt.Errorf("expected url was '%s', got, '%s'", e.path, request.URL.Path) - } - - if e.method != request.Method { - return nil, fmt.Errorf("expected method was '%s', got, '%s'", e.method, request.Method) - } - - if e.requestBody != nil { - body := new(REQUEST) - - err := json.NewDecoder(request.Body).Decode(body) - if err != nil { - panic(err) - } - - if !reflect.DeepEqual(*e.responseBody, *body) { - return nil, fmt.Errorf("mismatch body") - } - } - - for key, value := range e.queryParams { - qpvalue := "" - - switch value.(type) { - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - qpvalue = fmt.Sprintf("%d", value) - default: - qpvalue = fmt.Sprintf("%s", value) - } - - if rvalue := request.URL.Query().Get(key); rvalue != qpvalue { - return nil, fmt.Errorf("expected query param '%s' with value '%s', got '%s'", key, qpvalue, rvalue) - } - } - - data := make([]byte, 0) - - if e.responseBody != nil { - var err error - - data, err = json.Marshal(e.responseBody) - if err != nil { - panic(err) - } - } - - return &http.Response{ - StatusCode: e.statusCode, - Body: io.NopCloser(bytes.NewReader(data)), - ContentLength: int64(len(data)), - Request: request, - }, nil -} - -func (e *HTTPExpect[REQUEST, RESPONSE]) Path(p string) *HTTPExpect[REQUEST, RESPONSE] { - e.path = p - - return e -} - -func (e *HTTPExpect[REQUEST, RESPONSE]) Method(p string) *HTTPExpect[REQUEST, RESPONSE] { - e.method = p - - return e -} - -func (e *HTTPExpect[REQUEST, RESPONSE]) Body(body *REQUEST) *HTTPExpect[REQUEST, RESPONSE] { - e.requestBody = body - - return e -} - -func (e *HTTPExpect[REQUEST, RESPONSE]) QueryParam(key string, value any) *HTTPExpect[REQUEST, RESPONSE] { - e.queryParams[key] = value - - return e -} - -func (e *HTTPExpect[REQUEST, RESPONSE]) RespondsWith(statusCode int, - body *RESPONSE, -) *HTTPExpect[REQUEST, RESPONSE] { - e.statusCode = statusCode - e.responseBody = body - - return e -} - -func Expect[REQUEST, RESPONSE any](mock *httpMock) *HTTPExpect[REQUEST, RESPONSE] { - expectations := &HTTPExpect[REQUEST, RESPONSE]{ - queryParams: map[string]any{}, - } - - mock.mu.Lock() - defer mock.mu.Unlock() - - mock.expectations = append(mock.expectations, expectations) - - return expectations -} - -type StripeBalanceTransactionListExpect struct { - *HTTPExpect[struct{}, MockedListResponse] -} - -func (e *StripeBalanceTransactionListExpect) Path(p string) *StripeBalanceTransactionListExpect { - e.HTTPExpect.Path(p) - - return e -} - -func (e *StripeBalanceTransactionListExpect) Method(p string) *StripeBalanceTransactionListExpect { - e.HTTPExpect.Method(p) - - return e -} - -func (e *StripeBalanceTransactionListExpect) QueryParam(key string, - value any, -) *StripeBalanceTransactionListExpect { - e.HTTPExpect.QueryParam(key, value) - - return e -} - -func (e *StripeBalanceTransactionListExpect) RespondsWith(statusCode int, hasMore bool, - body ...*stripe.BalanceTransaction, -) *StripeBalanceTransactionListExpect { - e.HTTPExpect.RespondsWith(statusCode, &MockedListResponse{ - HasMore: hasMore, - Data: body, - }) - - return e -} - -func (e *StripeBalanceTransactionListExpect) StartingAfter(v string) *StripeBalanceTransactionListExpect { - e.QueryParam("starting_after", v) - - return e -} - -func (e *StripeBalanceTransactionListExpect) CreatedLte(v time.Time) *StripeBalanceTransactionListExpect { - e.QueryParam("created[lte]", v.Unix()) - - return e -} - -func (e *StripeBalanceTransactionListExpect) Limit(v int) *StripeBalanceTransactionListExpect { - e.QueryParam("limit", v) - - return e -} - -func ExpectBalanceTransactionList(mock *httpMock) *StripeBalanceTransactionListExpect { - e := Expect[struct{}, MockedListResponse](mock) - e.Path("/v1/balance_transactions").Method(http.MethodGet) - - return &StripeBalanceTransactionListExpect{ - HTTPExpect: e, - } -} - -type BalanceTransactionSource stripe.BalanceTransactionSource - -func (t *BalanceTransactionSource) MarshalJSON() ([]byte, error) { - type Aux BalanceTransactionSource - - return json.Marshal(struct { - Aux - Charge *stripe.Charge `json:"charge"` - Payout *stripe.Payout `json:"payout"` - Refund *stripe.Refund `json:"refund"` - Transfer *stripe.Transfer `json:"transfer"` - }{ - Aux: Aux(*t), - Charge: t.Charge, - Payout: t.Payout, - Refund: t.Refund, - Transfer: t.Transfer, - }) -} - -type BalanceTransaction stripe.BalanceTransaction - -func (t *BalanceTransaction) MarshalJSON() ([]byte, error) { - type Aux BalanceTransaction - - return json.Marshal(struct { - Aux - Source *BalanceTransactionSource `json:"source"` - }{ - Aux: Aux(*t), - Source: (*BalanceTransactionSource)(t.Source), - }) -} - -//nolint:tagliatelle // allow snake_case in client -type MockedListResponse struct { - HasMore bool `json:"has_more"` - Data []*stripe.BalanceTransaction `json:"data"` -} - -func (t *MockedListResponse) MarshalJSON() ([]byte, error) { - type Aux MockedListResponse - - txs := make([]*BalanceTransaction, 0) - for _, tx := range t.Data { - txs = append(txs, (*BalanceTransaction)(tx)) - } - - return json.Marshal(struct { - Aux - Data []*BalanceTransaction `json:"data"` - }{ - Aux: Aux(*t), - Data: txs, - }) -} diff --git a/cmd/connectors/internal/connectors/stripe/client/external_accounts.go b/cmd/connectors/internal/connectors/stripe/client/external_accounts.go deleted file mode 100644 index cd5ff3bb..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/external_accounts.go +++ /dev/null @@ -1,81 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" -) - -const ( - externalAccountsEndpoint = "https://api.stripe.com/v1/accounts/%s/external_accounts" -) - -func (d *DefaultClient) ExternalAccounts(ctx context.Context, options ...ClientOption) ([]*stripe.ExternalAccount, bool, error) { - f := connectors.ClientMetrics(ctx, "stripe", "list_external_accounts") - now := time.Now() - defer f(ctx, now) - - if d.stripeAccount == "" { - return nil, false, errors.New("stripe account is required") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(externalAccountsEndpoint, d.stripeAccount), nil) - if err != nil { - return nil, false, errors.Wrap(err, "creating http request") - } - - for _, opt := range options { - opt.Apply(req) - } - - req.Header.Set("Stripe-Account", d.stripeAccount) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(d.apiKey, "") // gfyrag: really weird authentication right? - - var httpResponse *http.Response - - httpResponse, err = d.httpClient.Do(req) - if err != nil { - return nil, false, errors.Wrap(err, "doing request") - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - return nil, false, fmt.Errorf("unexpected status code: %d", httpResponse.StatusCode) - } - - type listResponse struct { - TransactionsListResponse - Data []json.RawMessage `json:"data"` - } - - rsp := &listResponse{} - - err = json.NewDecoder(httpResponse.Body).Decode(rsp) - if err != nil { - return nil, false, errors.Wrap(err, "decoding response") - } - - externalAccounts := make([]*stripe.ExternalAccount, 0) - - if len(rsp.Data) > 0 { - for _, data := range rsp.Data { - account := &stripe.ExternalAccount{} - - err = json.Unmarshal(data, &account) - if err != nil { - return nil, false, err - } - - externalAccounts = append(externalAccounts, account) - } - } - - return externalAccounts, rsp.HasMore, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/client/payouts.go b/cmd/connectors/internal/connectors/stripe/client/payouts.go deleted file mode 100644 index 611f6634..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/payouts.go +++ /dev/null @@ -1,71 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" - "github.com/stripe/stripe-go/v72/payout" -) - -type CreatePayoutRequest struct { - IdempotencyKey string - Amount int64 - Currency string - Destination string - Description string -} - -func (d *DefaultClient) CreatePayout(ctx context.Context, createPayoutRequest *CreatePayoutRequest, options ...ClientOption) (*stripe.Payout, error) { - f := connectors.ClientMetrics(ctx, "stripe", "initiate_payout") - now := time.Now() - defer f(ctx, now) - - stripe.Key = d.apiKey - - params := &stripe.PayoutParams{ - Params: stripe.Params{ - Context: ctx, - }, - Amount: stripe.Int64(createPayoutRequest.Amount), - Currency: stripe.String(createPayoutRequest.Currency), - Destination: stripe.String(createPayoutRequest.Destination), - Method: stripe.String("standard"), - } - - if d.stripeAccount != "" { - params.SetStripeAccount(d.stripeAccount) - } - - if createPayoutRequest.IdempotencyKey != "" { - params.IdempotencyKey = stripe.String(createPayoutRequest.IdempotencyKey) - } - - if createPayoutRequest.Description != "" { - params.Description = stripe.String(createPayoutRequest.Description) - } - - payoutResponse, err := payout.New(params) - if err != nil { - return nil, errors.Wrap(err, "creating transfer") - } - - return payoutResponse, nil -} - -func (d *DefaultClient) GetPayout(ctx context.Context, payoutID string, options ...ClientOption) (*stripe.Payout, error) { - f := connectors.ClientMetrics(ctx, "stripe", "get_payout") - now := time.Now() - defer f(ctx, now) - - stripe.Key = d.apiKey - - payoutResponse, err := payout.Get(payoutID, nil) - if err != nil { - return nil, errors.Wrap(err, "getting payout") - } - - return payoutResponse, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/client/transfer_reversal.go b/cmd/connectors/internal/connectors/stripe/client/transfer_reversal.go deleted file mode 100644 index 192281f3..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/transfer_reversal.go +++ /dev/null @@ -1,46 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/stripe/stripe-go/v72" - "github.com/stripe/stripe-go/v72/reversal" -) - -type CreateTransferReversalRequest struct { - TransferID string - Amount int64 - Description string - Metadata map[string]string -} - -func (d *DefaultClient) ReverseTransfer(ctx context.Context, createTransferReversalRequest *CreateTransferReversalRequest, options ...ClientOption) (*stripe.Reversal, error) { - f := connectors.ClientMetrics(ctx, "stripe", "reverse_transfer") - now := time.Now() - defer f(ctx, now) - - stripe.Key = d.apiKey - - params := &stripe.ReversalParams{ - Params: stripe.Params{ - Context: ctx, - Metadata: createTransferReversalRequest.Metadata, - }, - Transfer: stripe.String(createTransferReversalRequest.TransferID), - Amount: stripe.Int64(createTransferReversalRequest.Amount), - Description: stripe.String(createTransferReversalRequest.Description), - } - - if d.stripeAccount != "" { - params.SetStripeAccount(d.stripeAccount) - } - - reversalResponse, err := reversal.New(params) - if err != nil { - return nil, err - } - - return reversalResponse, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/client/transfers.go b/cmd/connectors/internal/connectors/stripe/client/transfers.go deleted file mode 100644 index 3813e99c..00000000 --- a/cmd/connectors/internal/connectors/stripe/client/transfers.go +++ /dev/null @@ -1,51 +0,0 @@ -package client - -import ( - "context" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" - "github.com/stripe/stripe-go/v72/transfer" -) - -type CreateTransferRequest struct { - IdempotencyKey string - Amount int64 - Currency string - Destination string - Description string -} - -func (d *DefaultClient) CreateTransfer(ctx context.Context, createTransferRequest *CreateTransferRequest, options ...ClientOption) (*stripe.Transfer, error) { - f := connectors.ClientMetrics(ctx, "stripe", "initiate_transfer") - now := time.Now() - defer f(ctx, now) - - stripe.Key = d.apiKey - - params := &stripe.TransferParams{ - Params: stripe.Params{ - Context: ctx, - }, - Amount: stripe.Int64(createTransferRequest.Amount), - Currency: stripe.String(createTransferRequest.Currency), - Destination: stripe.String(createTransferRequest.Destination), - } - - if d.stripeAccount != "" { - params.SetStripeAccount(d.stripeAccount) - } - - if createTransferRequest.IdempotencyKey != "" { - params.IdempotencyKey = stripe.String(createTransferRequest.IdempotencyKey) - } - - transferResponse, err := transfer.New(params) - if err != nil { - return nil, errors.Wrap(err, "creating transfer") - } - - return transferResponse, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/config.go b/cmd/connectors/internal/connectors/stripe/config.go deleted file mode 100644 index fdcf01e1..00000000 --- a/cmd/connectors/internal/connectors/stripe/config.go +++ /dev/null @@ -1,66 +0,0 @@ -package stripe - -import ( - "encoding/json" - "errors" - "fmt" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -const ( - defaultPageSize = 10 - defaultPollingPeriod = 2 * time.Minute -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` - APIKey string `json:"apiKey" yaml:"apiKey" bson:"apiKey"` - TimelineConfig `bson:",inline"` -} - -// String obfuscates sensitive fields and returns a string representation of the config. -// This is used for logging. -func (c Config) String() string { - return fmt.Sprintf("pollingPeriod=%s, pageSize=%d, apiKey=****", c.PollingPeriod, c.PageSize) -} - -func (c Config) Validate() error { - if c.APIKey == "" { - return errors.New("missing api key") - } - - if c.Name == "" { - return errors.New("missing name") - } - - return nil -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -type TimelineConfig struct { - PageSize uint64 `json:"pageSize" yaml:"pageSize" bson:"pageSize"` -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - cfg.AddParameter("pageSize", configtemplate.TypeDurationUnsignedInteger, strconv.Itoa(defaultPageSize), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/stripe/connector.go b/cmd/connectors/internal/connectors/stripe/connector.go deleted file mode 100644 index 39c2666b..00000000 --- a/cmd/connectors/internal/connectors/stripe/connector.go +++ /dev/null @@ -1,153 +0,0 @@ -package stripe - -import ( - "context" - "errors" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" -) - -const name = models.ConnectorProviderStripe - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Main task to periodically fetch transactions", - Main: true, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - descriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - descriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return c.resolveTasks()(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - // Detach the context since we're launching an async task and we're mostly - // coming from a HTTP request. - detachedCtx, _ := contextutil.Detached(ctx.Context()) - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Reverse payment", - Key: taskNameReversePayment, - TransferReversalID: transferReversal.ID.String(), - }) - if err != nil { - return err - } - - err = ctx.Scheduler().Schedule(detachedCtx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW_SYNC, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/stripe/currencies.go b/cmd/connectors/internal/connectors/stripe/currencies.go deleted file mode 100644 index 4e6518ec..00000000 --- a/cmd/connectors/internal/connectors/stripe/currencies.go +++ /dev/null @@ -1,162 +0,0 @@ -package stripe - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - // c.f. https://stripe.com/docs/currencies#zero-decimal - supportedCurrenciesWithDecimal = map[string]int{ - "USD": currency.ISO4217Currencies["USD"], // United States dollar - "AED": currency.ISO4217Currencies["AED"], // United Arab Emirates dirham - "AFN": currency.ISO4217Currencies["AFN"], // Afghan afghani - "ALL": currency.ISO4217Currencies["ALL"], // Albanian lek - "AMD": currency.ISO4217Currencies["AMD"], // Armenian dram - "ANG": currency.ISO4217Currencies["ANG"], // Netherlands Antillean guilder - "AOA": currency.ISO4217Currencies["AOA"], // Angolan kwanza - "ARS": currency.ISO4217Currencies["ARS"], // Argentine peso - "AUD": currency.ISO4217Currencies["AUD"], // Australian dollar - "AWG": currency.ISO4217Currencies["AWG"], // Aruban florin - "AZN": currency.ISO4217Currencies["AZN"], // Azerbaijani manat - "BAM": currency.ISO4217Currencies["BAM"], // Bosnia and Herzegovina convertible mark - "BBD": currency.ISO4217Currencies["BBD"], // Barbados dollar - "BDT": currency.ISO4217Currencies["BDT"], // Bangladeshi taka - "BGN": currency.ISO4217Currencies["BGN"], // Bulgarian lev - "BIF": currency.ISO4217Currencies["BIF"], // Burundian franc - "BMD": currency.ISO4217Currencies["BMD"], // Bermudian dollar - "BND": currency.ISO4217Currencies["BND"], // Brunei dollar - "BOB": currency.ISO4217Currencies["BOB"], // Bolivian boliviano - "BRL": currency.ISO4217Currencies["BRL"], // Brazilian real - "BSD": currency.ISO4217Currencies["BSD"], // Bahamian dollar - "BWP": currency.ISO4217Currencies["BWP"], // Botswana pula - "BYN": currency.ISO4217Currencies["BYN"], // Belarusian ruble - "BZD": currency.ISO4217Currencies["BZD"], // Belize dollar - "CAD": currency.ISO4217Currencies["CAD"], // Canadian dollar - "CDF": currency.ISO4217Currencies["CDF"], // Congolese franc - "CHF": currency.ISO4217Currencies["CHF"], // Swiss franc - "CLP": currency.ISO4217Currencies["CLP"], // Chilean peso - "CNY": currency.ISO4217Currencies["CNY"], // Chinese yuan - "COP": currency.ISO4217Currencies["COP"], // Colombian peso - "CRC": currency.ISO4217Currencies["CRC"], // Costa Rican colon - "CVE": currency.ISO4217Currencies["CVE"], // Cape Verdean escudo - "CZK": currency.ISO4217Currencies["CZK"], // Czech koruna - "DJF": currency.ISO4217Currencies["DJF"], // Djiboutian franc - "DKK": currency.ISO4217Currencies["DKK"], // Danish krone - "DOP": currency.ISO4217Currencies["DOP"], // Dominican peso - "DZD": currency.ISO4217Currencies["DZD"], // Algerian dinar - "EGP": currency.ISO4217Currencies["EGP"], // Egyptian pound - "ETB": currency.ISO4217Currencies["ETB"], // Ethiopian birr - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "FJD": currency.ISO4217Currencies["FJD"], // Fiji dollar - "FKP": currency.ISO4217Currencies["FKP"], // Falkland Islands pound - "GBP": currency.ISO4217Currencies["GBP"], // Pound sterling - "GEL": currency.ISO4217Currencies["GEL"], // Georgian lari - "GIP": currency.ISO4217Currencies["GIP"], // Gibraltar pound - "GMD": currency.ISO4217Currencies["GMD"], // Gambian dalasi - "GNF": currency.ISO4217Currencies["GNF"], // Guinean franc - "GTQ": currency.ISO4217Currencies["GTQ"], // Guatemalan quetzal - "GYD": currency.ISO4217Currencies["GYD"], // Guyanese dollar - "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong dollar - "HNL": currency.ISO4217Currencies["HNL"], // Honduran lempira - "HTG": currency.ISO4217Currencies["HTG"], // Haitian gourde - "IDR": currency.ISO4217Currencies["IDR"], // Indonesian rupiah - "ILS": currency.ISO4217Currencies["ILS"], // Israeli new shekel - "INR": currency.ISO4217Currencies["INR"], // Indian rupee - "JMD": currency.ISO4217Currencies["JMD"], // Jamaican dollar - "JPY": currency.ISO4217Currencies["JPY"], // Japanese yen - "KES": currency.ISO4217Currencies["KES"], // Kenyan shilling - "KGS": currency.ISO4217Currencies["KGS"], // Kyrgyzstani som - "KHR": currency.ISO4217Currencies["KHR"], // Cambodian riel - "KMF": currency.ISO4217Currencies["KMF"], // Comoro franc - "KRW": currency.ISO4217Currencies["KRW"], // South Korean won - "KYD": currency.ISO4217Currencies["KYD"], // Cayman Islands dollar - "KZT": currency.ISO4217Currencies["KZT"], // Kazakhstani tenge - "LAK": currency.ISO4217Currencies["LAK"], // Lao kip - "LBP": currency.ISO4217Currencies["LBP"], // Lebanese pound - "LKR": currency.ISO4217Currencies["LKR"], // Sri Lankan rupee - "LRD": currency.ISO4217Currencies["LRD"], // Liberian dollar - "LSL": currency.ISO4217Currencies["LSL"], // Lesotho loti - "MAD": currency.ISO4217Currencies["MAD"], // Moroccan dirham - "MDL": currency.ISO4217Currencies["MDL"], // Moldovan leu - "MKD": currency.ISO4217Currencies["MKD"], // Macedonian denar - "MMK": currency.ISO4217Currencies["MMK"], // Burmese kyat - "MNT": currency.ISO4217Currencies["MNT"], // Mongolian tögrög - "MOP": currency.ISO4217Currencies["MOP"], // Macanese pataca - "MUR": currency.ISO4217Currencies["MUR"], // Mauritian rupee - "MVR": currency.ISO4217Currencies["MVR"], // Maldivian rufiyaa - "MWK": currency.ISO4217Currencies["MWK"], // Malawian kwacha - "MXN": currency.ISO4217Currencies["MXN"], // Mexican peso - "MYR": currency.ISO4217Currencies["MYR"], // Malaysian ringgit - "MZN": currency.ISO4217Currencies["MZN"], // Mozambican metical - "NAD": currency.ISO4217Currencies["NAD"], // Namibian dollar - "NGN": currency.ISO4217Currencies["NGN"], // Nigerian naira - "NIO": currency.ISO4217Currencies["NIO"], // Nicaraguan córdoba - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian krone - "NPR": currency.ISO4217Currencies["NPR"], // Nepalese rupee - "NZD": currency.ISO4217Currencies["NZD"], // New Zealand dollar - "PAB": currency.ISO4217Currencies["PAB"], // Panamanian balboa - "PEN": currency.ISO4217Currencies["PEN"], // Peruvian sol - "PGK": currency.ISO4217Currencies["PGK"], // Papua New Guinean kina - "PHP": currency.ISO4217Currencies["PHP"], // Philippine peso - "PKR": currency.ISO4217Currencies["PKR"], // Pakistani rupee - "PLN": currency.ISO4217Currencies["PLN"], // Polish złoty - "PYG": currency.ISO4217Currencies["PYG"], // Paraguayan guaraní - "QAR": currency.ISO4217Currencies["QAR"], // Qatari riyal - "RON": currency.ISO4217Currencies["RON"], // Romanian leu - "RSD": currency.ISO4217Currencies["RSD"], // Serbian dinar - "RUB": currency.ISO4217Currencies["RUB"], // Russian ruble - "RWF": currency.ISO4217Currencies["RWF"], // Rwandan franc - "SAR": currency.ISO4217Currencies["SAR"], // Saudi riyal - "SBD": currency.ISO4217Currencies["SBD"], // Solomon Islands dollar - "SCR": currency.ISO4217Currencies["SCR"], // Seychelles rupee - "SEK": currency.ISO4217Currencies["SEK"], // Swedish krona/kronor - "SGD": currency.ISO4217Currencies["SGD"], // Singapore dollar - "SHP": currency.ISO4217Currencies["SHP"], // Saint Helena pound - "SOS": currency.ISO4217Currencies["SOS"], // Somali shilling - "SRD": currency.ISO4217Currencies["SRD"], // Surinamese dollar - "SZL": currency.ISO4217Currencies["SZL"], // Swazi lilangeni - "THB": currency.ISO4217Currencies["THB"], // Thai baht - "TJS": currency.ISO4217Currencies["TJS"], // Tajikistani somoni - "TOP": currency.ISO4217Currencies["TOP"], // Tongan paʻanga - "TRY": currency.ISO4217Currencies["TRY"], // Turkish lira - "TTD": currency.ISO4217Currencies["TTD"], // Trinidad and Tobago dollar - "TZS": currency.ISO4217Currencies["TZS"], // Tanzanian shilling - "UAH": currency.ISO4217Currencies["UAH"], // Ukrainian hryvnia - "UYU": currency.ISO4217Currencies["UYU"], // Uruguayan peso - "UZS": currency.ISO4217Currencies["UZS"], // Uzbekistan som - "VND": currency.ISO4217Currencies["VND"], // Vietnamese đồng - "VUV": currency.ISO4217Currencies["VUV"], // Vanuatu vatu - "WST": currency.ISO4217Currencies["WST"], // Samoan tala - "XAF": currency.ISO4217Currencies["XAF"], // Central African CFA franc - "XCD": currency.ISO4217Currencies["XCD"], // East Caribbean dollar - "XOF": currency.ISO4217Currencies["XOF"], // West African CFA franc - "XPF": currency.ISO4217Currencies["XPF"], // CFP franc - "YER": currency.ISO4217Currencies["YER"], // Yemeni rial - "ZAR": currency.ISO4217Currencies["ZAR"], // South African rand - "ZMW": currency.ISO4217Currencies["ZMW"], // Zambian kwacha - - // Unsupported currencies - // The following currencies are not in the ISO 4217 standard, - //so let's not handle them for now. - // "SLE": 2 // Sierra Leonean leone - // "STD": 2 // São Tomé and Príncipe dobra - - // The following currencies have not the same decimals in Stripe compared - // to ISO 4217 standard, so let's not handle them for now. - // "MGA": 2, // Malagasy ariary - - // The following currencies are 3 decimals currencies, but in order - // to use them with Stripe, it requires the last digit to be 0. - // Let's not handle them for now. - // "BHD": 3, // Bahraini dinar - // "JOD": 3, // Jordanian dinar - // "KWD": 3, // Kuwaiti dinar - // "OMR": 3, // Omani rial - // "TND": 3, // Tunisian dinar - - // The following currencies are apecial cases in stripe API (cf link above) - // let's not handle them for now. - // "ISK": 0, // Icelandic króna - // "HUF": 2, // Hungarian forint - // "UGX": 0, // Ugandan shilling - // "TWD": 2 // New Taiwan dollar - } -) diff --git a/cmd/connectors/internal/connectors/stripe/ingester.go b/cmd/connectors/internal/connectors/stripe/ingester.go deleted file mode 100644 index ebbead87..00000000 --- a/cmd/connectors/internal/connectors/stripe/ingester.go +++ /dev/null @@ -1,43 +0,0 @@ -package stripe - -import ( - "context" - - "github.com/stripe/stripe-go/v72" -) - -type ingestTransaction func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error -type ingestAccounts func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error -type ingestExternalAccounts func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error - -type Ingester interface { - IngestTransactions(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error - IngestAccounts(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error - IngestExternalAccounts(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error -} - -type ingester struct { - it ingestTransaction - ia ingestAccounts - iea ingestExternalAccounts -} - -func NewIngester(it ingestTransaction, ia ingestAccounts, iea ingestExternalAccounts) Ingester { - return &ingester{ - it: it, - ia: ia, - iea: iea, - } -} - -func (i *ingester) IngestTransactions(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - return i.it(ctx, batch, commitState, tail) -} - -func (i *ingester) IngestAccounts(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - return i.ia(ctx, batch, commitState, tail) -} - -func (i *ingester) IngestExternalAccounts(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - return i.iea(ctx, batch, commitState, tail) -} diff --git a/cmd/connectors/internal/connectors/stripe/loader.go b/cmd/connectors/internal/connectors/stripe/loader.go deleted file mode 100644 index 6cb25a59..00000000 --- a/cmd/connectors/internal/connectors/stripe/loader.go +++ /dev/null @@ -1,51 +0,0 @@ -package stripe - -import ( - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PageSize == 0 { - cfg.PageSize = defaultPageSize - } - - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod = connectors.Duration{Duration: defaultPollingPeriod} - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/stripe/state.go b/cmd/connectors/internal/connectors/stripe/state.go deleted file mode 100644 index 9ae3edbc..00000000 --- a/cmd/connectors/internal/connectors/stripe/state.go +++ /dev/null @@ -1,11 +0,0 @@ -package stripe - -import "time" - -type TimelineState struct { - OldestID string `bson:"oldestID,omitempty" json:"oldestID"` - OldestDate *time.Time `bson:"oldestDate,omitempty" json:"oldestDate"` - MoreRecentID string `bson:"moreRecentID,omitempty" json:"moreRecentID"` - MoreRecentDate *time.Time `bson:"moreRecentDate,omitempty" json:"moreRecentDate"` - NoMoreHistory bool `bson:"noMoreHistory" json:"noMoreHistory"` -} diff --git a/cmd/connectors/internal/connectors/stripe/task_fetch_accounts.go b/cmd/connectors/internal/connectors/stripe/task_fetch_accounts.go deleted file mode 100644 index 80fa20b9..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_fetch_accounts.go +++ /dev/null @@ -1,212 +0,0 @@ -package stripe - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" - "go.opentelemetry.io/otel/attribute" -) - -const ( - rootAccountReference = "root" -) - -func fetchAccountsTask(config TimelineConfig, client *client.DefaultClient) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - resolver task.StateResolver, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "stripe.fetchAccountsTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - // Register root account. - if err := registerRootAccount(ctx, connectorID, ingester, scheduler); err != nil { - otel.RecordError(span, err) - return err - } - - tt := NewTimelineTrigger( - logger, - NewIngester( - func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - return nil - - }, - func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - if err := ingestAccountsBatch(ctx, connectorID, ingester, batch); err != nil { - return err - } - - for _, account := range batch { - transactionsTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transactions for a specific connected account", - Key: taskNameFetchPaymentsForAccounts, - Account: account.ID, - }) - if err != nil { - return errors.Wrap(err, "failed to transform task descriptor") - } - - err = scheduler.Schedule(ctx, transactionsTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return errors.Wrap(err, "scheduling connected account") - } - - balanceTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch balance for a specific connected account", - Key: taskNameFetchBalances, - Account: account.ID, - }) - if err != nil { - return errors.Wrap(err, "failed to transform task descriptor") - } - - err = scheduler.Schedule(ctx, balanceTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return errors.Wrap(err, "scheduling connected account") - } - - externalAccountsTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch external account for a specific connected account", - Key: taskNameFetchExternalAccounts, - Account: account.ID, - }) - if err != nil { - return errors.Wrap(err, "failed to transform task descriptor") - } - - err = scheduler.Schedule(ctx, externalAccountsTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return errors.Wrap(err, "scheduling connected account") - } - } - - return nil - }, - func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - return nil - - }, - ), - NewTimeline(client, - config, task.MustResolveTo(ctx, resolver, TimelineState{})), - TimelineTriggerTypeAccounts, - ) - - if err := tt.Fetch(ctx); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func registerRootAccount( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, -) error { - if err := ingester.IngestAccounts(ctx, ingestion.AccountBatch{ - { - ID: models.AccountID{ - Reference: rootAccountReference, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Now().UTC(), - Reference: rootAccountReference, - Type: models.AccountTypeInternal, - }, - }); err != nil { - return err - } - balanceTask, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch balance for the root account", - Key: taskNameFetchBalances, - Account: rootAccountReference, - }) - if err != nil { - return errors.Wrap(err, "failed to transform task descriptor") - } - err = scheduler.Schedule(ctx, balanceTask, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return errors.Wrap(err, "scheduling connected account") - } - - return nil -} - -func ingestAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accounts []*stripe.Account, -) error { - batch := ingestion.AccountBatch{} - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - metadata := make(map[string]string) - for k, v := range account.Metadata { - metadata[k] = v - } - - batch = append(batch, &models.Account{ - ID: models.AccountID{ - Reference: account.ID, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(account.Created, 0).UTC(), - Reference: account.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(account.DefaultCurrency)), - Type: models.AccountTypeInternal, - RawData: raw, - Metadata: metadata, - }) - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/stripe/task_fetch_balances.go b/cmd/connectors/internal/connectors/stripe/task_fetch_balances.go deleted file mode 100644 index c54230af..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_fetch_balances.go +++ /dev/null @@ -1,72 +0,0 @@ -package stripe - -import ( - "context" - "math/big" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func balanceTask(account string, client *client.DefaultClient) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "stripe.balanceTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("account", account), - ) - defer span.End() - - stripeAccount := account - if account == rootAccountReference { - // special case for root account - stripeAccount = "" - } - - balances, err := client.ForAccount(stripeAccount).Balance(ctx) - if err != nil { - otel.RecordError(span, err) - return err - } - - batch := ingestion.BalanceBatch{} - for _, balance := range balances.Available { - timestamp := time.Now() - batch = append(batch, &models.Balance{ - AccountID: models.AccountID{ - Reference: account, - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balance.Currency)), - Balance: big.NewInt(balance.Value), - CreatedAt: timestamp, - LastUpdatedAt: timestamp, - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestBalances(ctx, batch, false); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/stripe/task_fetch_external_accounts.go b/cmd/connectors/internal/connectors/stripe/task_fetch_external_accounts.go deleted file mode 100644 index 2810cd3e..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_fetch_external_accounts.go +++ /dev/null @@ -1,102 +0,0 @@ -package stripe - -import ( - "context" - "encoding/json" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/stripe/stripe-go/v72" - "go.opentelemetry.io/otel/attribute" -) - -func fetchExternalAccountsTask(config TimelineConfig, account string, client *client.DefaultClient) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - resolver task.StateResolver, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "stripe.fetchExternalAccountsTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("account", account), - ) - defer span.End() - - tt := NewTimelineTrigger( - logger, - NewIngester( - func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - return nil - - }, - func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - return nil - }, - func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - if err := ingestExternalAccountsBatch(ctx, connectorID, ingester, batch); err != nil { - return err - } - return nil - }, - ), - NewTimeline(client.ForAccount(account), - config, task.MustResolveTo(ctx, resolver, TimelineState{})), - TimelineTriggerTypeExternalAccounts, - ) - - if err := tt.Fetch(ctx); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func ingestExternalAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accounts []*stripe.ExternalAccount, -) error { - batch := ingestion.AccountBatch{} - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return err - } - - batch = append(batch, &models.Account{ - ID: models.AccountID{ - Reference: account.ID, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(account.BankAccount.Account.Created, 0).UTC(), - Reference: account.ID, - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(account.BankAccount.Account.DefaultCurrency)), - Type: models.AccountTypeExternal, - RawData: raw, - }) - } - - if err := ingester.IngestAccounts(ctx, batch); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/stripe/task_fetch_payments.go b/cmd/connectors/internal/connectors/stripe/task_fetch_payments.go deleted file mode 100644 index c144dc73..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_fetch_payments.go +++ /dev/null @@ -1,65 +0,0 @@ -package stripe - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/stripe/stripe-go/v72" - "go.opentelemetry.io/otel/attribute" -) - -func fetchPaymentsTask(config TimelineConfig, client *client.DefaultClient) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - resolver task.StateResolver, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "stripe.fetchPaymentsTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("account", rootAccountReference), - ) - defer span.End() - - tt := NewTimelineTrigger( - logger, - NewIngester( - func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - if err := ingestBatch(ctx, connectorID, rootAccountReference, logger, ingester, batch, commitState, tail); err != nil { - return err - } - - return nil - }, - func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - return nil - }, - func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - return nil - }, - ), - NewTimeline(client, - config, task.MustResolveTo(ctx, resolver, TimelineState{})), - TimelineTriggerTypeTransactions, - ) - - if err := tt.Fetch(ctx); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/stripe/task_fetch_payments_for_connected_account.go b/cmd/connectors/internal/connectors/stripe/task_fetch_payments_for_connected_account.go deleted file mode 100644 index fe555926..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_fetch_payments_for_connected_account.go +++ /dev/null @@ -1,109 +0,0 @@ -package stripe - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/stripe/stripe-go/v72" - "go.opentelemetry.io/otel/attribute" -) - -func ingestBatch( - ctx context.Context, - connectorID models.ConnectorID, - account string, - logger logging.Logger, - ingester ingestion.Ingester, - bts []*stripe.BalanceTransaction, - commitState TimelineState, - tail bool, -) error { - batch := ingestion.PaymentBatch{} - - for i := range bts { - batchElement, handled := createBatchElement(connectorID, bts[i], account, !tail) - - if !handled { - logger.Debugf("Balance transaction type not handled: %s", bts[i].Type) - - continue - } - - if batchElement.Adjustment == nil && batchElement.Payment == nil { - continue - } - - batch = append(batch, batchElement) - } - - logger.WithFields(map[string]interface{}{ - "state": commitState, - }).Debugf("updating state") - - err := ingester.IngestPayments(ctx, batch) - if err != nil { - return err - } - - err = ingester.UpdateTaskState(ctx, commitState) - if err != nil { - return err - } - - return nil -} - -func connectedAccountTask(config TimelineConfig, account string, client *client.DefaultClient) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "stripe.connectedAccountTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("account", account), - ) - defer span.End() - - trigger := NewTimelineTrigger( - logger, - NewIngester( - func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - if err := ingestBatch(ctx, connectorID, account, logger, ingester, batch, commitState, tail); err != nil { - return err - } - - return nil - }, - func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - return nil - }, - func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - return nil - }, - ), - NewTimeline(client. - ForAccount(account), config, task.MustResolveTo(ctx, resolver, TimelineState{})), - TimelineTriggerTypeTransactions, - ) - - if err := trigger.Fetch(ctx); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/stripe/task_main.go b/cmd/connectors/internal/connectors/stripe/task_main.go deleted file mode 100644 index 5cf7de46..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_main.go +++ /dev/null @@ -1,68 +0,0 @@ -package stripe - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// Launch accounts and payments tasks -func (c *Connector) mainTask() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "stripe.mainTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskAccounts, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch accounts from client", - Key: taskNameFetchAccounts, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskAccounts, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - taskPayments, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch payments from client", - Key: taskNameFetchPayments, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskPayments, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/stripe/task_payments.go b/cmd/connectors/internal/connectors/stripe/task_payments.go deleted file mode 100644 index d0e445de..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_payments.go +++ /dev/null @@ -1,309 +0,0 @@ -package stripe - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/stripe/stripe-go/v72" - "go.opentelemetry.io/otel/attribute" -) - -const ( - transferIDKey string = "transfer_id" -) - -func initiatePaymentTask(transferID string, stripeClient *client.DefaultClient) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "stripe.initiatePaymentTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, stripeClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - stripeClient *client.DefaultClient, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount != nil { - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - } - - var curr string - curr, _, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - c := client.Client(stripeClient) - // If source account is nil, or equal to root (which is a special - // account we create for stripe for the balance platform), we don't need - // to set the stripe account. - if transfer.SourceAccount != nil && transfer.SourceAccount.Reference != rootAccountReference { - c = c.ForAccount(transfer.SourceAccountID.Reference) - } - - var connectorPaymentID string - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - var resp *stripe.Transfer - resp, err = c.CreateTransfer(ctx, &client.CreateTransferRequest{ - IdempotencyKey: fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments)), - Amount: transfer.Amount.Int64(), - Currency: curr, - Destination: transfer.DestinationAccountID.Reference, - Description: transfer.Description, - }) - if err != nil { - return err - } - - if transfer.Metadata == nil { - transfer.Metadata = make(map[string]string) - } - transfer.Metadata[transferIDKey] = resp.ID - connectorPaymentID = resp.BalanceTransaction.ID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - var resp *stripe.Payout - resp, err = c.CreatePayout(ctx, &client.CreatePayoutRequest{ - IdempotencyKey: fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments)), - Amount: transfer.Amount.Int64(), - Currency: curr, - Destination: transfer.DestinationAccountID.Reference, - Description: transfer.Description, - }) - if err != nil { - return err - } - - connectorPaymentID = resp.BalanceTransaction.ID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: connectorPaymentID, - Type: paymentType, - }, - ConnectorID: connectorID, - } - - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func updatePaymentStatusTask( - transferID string, - pID string, - attempt int, - stripeClient *client.DefaultClient, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "stripe.updatePaymentStatusTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", pID), - attribute.Int("attempt", attempt), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := updatePaymentStatus(ctx, stripeClient, transfer, paymentID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func updatePaymentStatus( - ctx context.Context, - stripeClient *client.DefaultClient, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var status stripe.PayoutFailureCode - var resultMessage string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - // Nothing to do - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - - case models.TransferInitiationTypePayout: - var resp *stripe.Payout - resp, err = stripeClient.GetPayout(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.FailureCode - resultMessage = resp.FailureMessage - } - - if status == "" { - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - } - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, resultMessage, time.Now()) - if err != nil { - return err - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/task_resolve.go b/cmd/connectors/internal/connectors/stripe/task_resolve.go deleted file mode 100644 index dbdc8814..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_resolve.go +++ /dev/null @@ -1,62 +0,0 @@ -package stripe - -import ( - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const ( - taskNameFetchAccounts = "fetch_accounts" - taskNameFetchPaymentsForAccounts = "fetch_transactions" - taskNameFetchPayments = "fetch_payments" - taskNameFetchBalances = "fetch_balance" - taskNameFetchExternalAccounts = "fetch_external_accounts" - taskNameInitiatePayment = "initiate-payment" - taskNameReversePayment = "reverse-payment" - taskNameUpdatePaymentStatus = "update-payment-status" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - Main bool `json:"main,omitempty" yaml:"main" bson:"main"` - Account string `json:"account,omitempty" yaml:"account" bson:"account"` - TransferID string `json:"transferID,omitempty" yaml:"transferID" bson:"transferID"` - TransferReversalID string `json:"transferReversalID,omitempty" yaml:"transferReversalID" bson:"transferReversalID"` - PaymentID string `json:"paymentID,omitempty" yaml:"paymentID" bson:"paymentID"` - Attempt int `json:"attempt,omitempty" yaml:"attempt" bson:"attempt"` -} - -// clientID, apiKey, endpoint string, logger logging -func (c *Connector) resolveTasks() func(taskDefinition TaskDescriptor) task.Task { - client := client.NewDefaultClient(c.cfg.APIKey) - - return func(taskDescriptor TaskDescriptor) task.Task { - if taskDescriptor.Main { - return c.mainTask() - } - - switch taskDescriptor.Key { - case taskNameFetchPayments: - return fetchPaymentsTask(c.cfg.TimelineConfig, client) - case taskNameFetchAccounts: - return fetchAccountsTask(c.cfg.TimelineConfig, client) - case taskNameFetchExternalAccounts: - return fetchExternalAccountsTask(c.cfg.TimelineConfig, taskDescriptor.Account, client) - case taskNameFetchPaymentsForAccounts: - return connectedAccountTask(c.cfg.TimelineConfig, taskDescriptor.Account, client) - case taskNameFetchBalances: - return balanceTask(taskDescriptor.Account, client) - case taskNameInitiatePayment: - return initiatePaymentTask(taskDescriptor.TransferID, client) - case taskNameReversePayment: - return reversePaymentTask(taskDescriptor.TransferReversalID, client) - case taskNameUpdatePaymentStatus: - return updatePaymentStatusTask(taskDescriptor.TransferID, taskDescriptor.PaymentID, taskDescriptor.Attempt, client) - default: - // For compatibility with old tasks - return connectedAccountTask(c.cfg.TimelineConfig, taskDescriptor.Account, client) - } - } -} diff --git a/cmd/connectors/internal/connectors/stripe/task_reverse_payment.go b/cmd/connectors/internal/connectors/stripe/task_reverse_payment.go deleted file mode 100644 index ad927b51..00000000 --- a/cmd/connectors/internal/connectors/stripe/task_reverse_payment.go +++ /dev/null @@ -1,143 +0,0 @@ -package stripe - -import ( - "context" - "errors" - "time" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func reversePaymentTask(transferReversalID string, stripeClient *client.DefaultClient) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - reversalID := models.MustTransferReversalIDFromString(transferReversalID) - - ctx, span := connectors.StartSpan( - ctx, - "stripe.reversePaymentTask", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferReversalID", transferReversalID), - attribute.String("reference", reversalID.Reference), - ) - defer span.End() - - transferReversal, err := getTransferReversal(ctx, storageReader, reversalID) - if err != nil { - otel.RecordError(span, err) - return err - } - - transfer, err := getTransfer(ctx, storageReader, transferReversal.TransferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := reversePayment(ctx, stripeClient, transfer, transferReversal, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func reversePayment( - ctx context.Context, - stripeClient *client.DefaultClient, - transfer *models.TransferInitiation, - transferReversal *models.TransferReversal, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - - transferReversal.Status = models.TransferReversalStatusFailed - transferReversal.Error = err.Error() - transferReversal.UpdatedAt = time.Now().UTC() - - _ = ingester.UpdateTransferReversalStatus(ctx, transfer, transferReversal) - } - }() - - c := client.Client(stripeClient) - // If source account is nil, or equal to root (which is a special - // account we create for stripe for the balance platform), we don't need - // to set the stripe account. - if transfer.SourceAccount != nil && transfer.SourceAccount.Reference != rootAccountReference { - c = c.ForAccount(transfer.SourceAccountID.Reference) - } - - transferID, err := getTransferIDFromMetadata(transfer) - if err != nil { - return err - } - - _, err = c.ReverseTransfer(ctx, &client.CreateTransferReversalRequest{ - TransferID: transferID, - Amount: transferReversal.Amount.Int64(), - Description: transferReversal.Description, - Metadata: transferReversal.Metadata, - }) - if err != nil { - return err - } - - transferReversal.Status = models.TransferReversalStatusProcessed - transferReversal.UpdatedAt = time.Now().UTC() - if err = ingester.UpdateTransferReversalStatus(ctx, transfer, transferReversal); err != nil { - return err - } - - return nil -} - -func getTransferReversal( - ctx context.Context, - reader storage.Reader, - transferReversalID models.TransferReversalID, -) (*models.TransferReversal, error) { - transferReversal, err := reader.GetTransferReversal(ctx, transferReversalID) - if err != nil { - return nil, err - } - - return transferReversal, nil -} - -func getTransferIDFromMetadata( - transfer *models.TransferInitiation, -) (string, error) { - if transfer.Metadata == nil { - return "", errors.New("metadata not found") - } - - transferID, ok := transfer.Metadata[transferIDKey] - if !ok { - return "", errors.New("transfer id not found in metadata") - } - - return transferID, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline.go b/cmd/connectors/internal/connectors/stripe/timeline.go deleted file mode 100644 index b27a76cc..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline.go +++ /dev/null @@ -1,54 +0,0 @@ -package stripe - -import ( - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" -) - -type Timeline struct { - state TimelineState - firstIDAfterStartingAt string - startingAt time.Time - config TimelineConfig - client client.Client -} - -func NewTimeline(client client.Client, cfg TimelineConfig, state TimelineState, options ...TimelineOption) *Timeline { - defaultOptions := make([]TimelineOption, 0) - - c := &Timeline{ - config: cfg, - state: state, - client: client, - } - - options = append(defaultOptions, append([]TimelineOption{ - WithStartingAt(time.Now()), - }, options...)...) - - for _, opt := range options { - opt.apply(c) - } - - return c -} - -type TimelineOption interface { - apply(c *Timeline) -} -type TimelineOptionFn func(c *Timeline) - -func (fn TimelineOptionFn) apply(c *Timeline) { - fn(c) -} - -func WithStartingAt(v time.Time) TimelineOptionFn { - return func(c *Timeline) { - c.startingAt = v - } -} - -func (tl *Timeline) State() TimelineState { - return tl.state -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline_connected_account.go b/cmd/connectors/internal/connectors/stripe/timeline_connected_account.go deleted file mode 100644 index 3d53f235..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline_connected_account.go +++ /dev/null @@ -1,142 +0,0 @@ -package stripe - -import ( - "context" - "fmt" - "net/url" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/stripe/stripe-go/v72" -) - -func (tl *Timeline) doAccountsRequest(ctx context.Context, queryParams url.Values, - to *[]*stripe.Account, -) (bool, error) { - options := make([]client.ClientOption, 0) - options = append(options, client.QueryParam("limit", fmt.Sprintf("%d", tl.config.PageSize))) - - for k, v := range queryParams { - options = append(options, client.QueryParam(k, v[0])) - } - - txs, hasMore, err := tl.client.Accounts(ctx, options...) - if err != nil { - return false, err - } - - *to = txs - - return hasMore, nil -} - -func (tl *Timeline) initAccounts(ctx context.Context) error { - ret := make([]*stripe.Account, 0) - params := url.Values{} - params.Set("limit", "1") - params.Set("created[lt]", fmt.Sprintf("%d", tl.startingAt.Unix())) - - _, err := tl.doAccountsRequest(ctx, params, &ret) - if err != nil { - return err - } - - if len(ret) > 0 { - tl.firstIDAfterStartingAt = ret[0].ID - } - - return nil -} - -func (tl *Timeline) AccountsTail(ctx context.Context, to *[]*stripe.Account) (bool, TimelineState, func(), error) { - queryParams := url.Values{} - - switch { - case tl.state.OldestID != "": - queryParams.Set("starting_after", tl.state.OldestID) - default: - queryParams.Set("created[lte]", fmt.Sprintf("%d", tl.startingAt.Unix())) - } - - hasMore, err := tl.doAccountsRequest(ctx, queryParams, to) - if err != nil { - return false, TimelineState{}, nil, err - } - - futureState := tl.state - - if len(*to) > 0 { - lastItem := (*to)[len(*to)-1] - futureState.OldestID = lastItem.ID - oldestDate := time.Unix(lastItem.Created, 0) - futureState.OldestDate = &oldestDate - - if futureState.MoreRecentID == "" { - firstItem := (*to)[0] - futureState.MoreRecentID = firstItem.ID - moreRecentDate := time.Unix(firstItem.Created, 0) - futureState.MoreRecentDate = &moreRecentDate - } - } - - futureState.NoMoreHistory = !hasMore - - return hasMore, futureState, func() { - tl.state = futureState - }, nil -} - -func (tl *Timeline) AccountsHead(ctx context.Context, to *[]*stripe.Account) (bool, TimelineState, func(), error) { - if tl.firstIDAfterStartingAt == "" && tl.state.MoreRecentID == "" { - err := tl.initAccounts(ctx) - if err != nil { - return false, TimelineState{}, nil, err - } - - if tl.firstIDAfterStartingAt == "" { - return false, TimelineState{ - NoMoreHistory: true, - }, func() {}, nil - } - } - - queryParams := url.Values{} - - switch { - case tl.state.MoreRecentID != "": - queryParams.Set("ending_before", tl.state.MoreRecentID) - case tl.firstIDAfterStartingAt != "": - queryParams.Set("ending_before", tl.firstIDAfterStartingAt) - } - - hasMore, err := tl.doAccountsRequest(ctx, queryParams, to) - if err != nil { - return false, TimelineState{}, nil, err - } - - futureState := tl.state - - if len(*to) > 0 { - firstItem := (*to)[0] - futureState.MoreRecentID = firstItem.ID - moreRecentDate := time.Unix(firstItem.Created, 0) - futureState.MoreRecentDate = &moreRecentDate - - if futureState.OldestID == "" { - lastItem := (*to)[len(*to)-1] - futureState.OldestID = lastItem.ID - oldestDate := time.Unix(lastItem.Created, 0) - futureState.OldestDate = &oldestDate - } - } - - futureState.NoMoreHistory = !hasMore - - for i, j := 0, len(*to)-1; i < j; i, j = i+1, j-1 { - (*to)[i], (*to)[j] = (*to)[j], (*to)[i] - } - - return hasMore, futureState, func() { - tl.state = futureState - }, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline_external_accounts.go b/cmd/connectors/internal/connectors/stripe/timeline_external_accounts.go deleted file mode 100644 index 251ad9e7..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline_external_accounts.go +++ /dev/null @@ -1,144 +0,0 @@ -package stripe - -import ( - "context" - "fmt" - "net/url" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/stripe/stripe-go/v72" -) - -//nolint:tagliatelle // allow different styled tags in client -type ExternalAccountsListResponse struct { - HasMore bool `json:"has_more"` - Data []*stripe.ExternalAccount `json:"data"` -} - -func (tl *Timeline) doExternalAccountsRequest(ctx context.Context, queryParams url.Values, - to *[]*stripe.ExternalAccount, -) (bool, error) { - options := make([]client.ClientOption, 0) - options = append(options, client.QueryParam("limit", fmt.Sprintf("%d", tl.config.PageSize))) - - for k, v := range queryParams { - options = append(options, client.QueryParam(k, v[0])) - } - - txs, hasMore, err := tl.client.ExternalAccounts(ctx, options...) - if err != nil { - return false, err - } - - *to = txs - - return hasMore, nil -} - -func (tl *Timeline) initExternalAccounts(ctx context.Context) error { - ret := make([]*stripe.ExternalAccount, 0) - - _, err := tl.doExternalAccountsRequest(ctx, url.Values{}, &ret) - if err != nil { - return err - } - - if len(ret) > 0 { - tl.firstIDAfterStartingAt = ret[0].ID - } - - return nil -} - -func (tl *Timeline) ExternalAccountsTail(ctx context.Context, to *[]*stripe.ExternalAccount) (bool, TimelineState, func(), error) { - queryParams := url.Values{} - - switch { - case tl.state.OldestID != "": - queryParams.Set("starting_after", tl.state.OldestID) - default: - } - - hasMore, err := tl.doExternalAccountsRequest(ctx, queryParams, to) - if err != nil { - return false, TimelineState{}, nil, err - } - - futureState := tl.state - - if len(*to) > 0 { - lastItem := (*to)[len(*to)-1] - futureState.OldestID = lastItem.ID - oldestDate := time.Unix(lastItem.BankAccount.Account.Created, 0) - futureState.OldestDate = &oldestDate - - if futureState.MoreRecentID == "" { - firstItem := (*to)[0] - futureState.MoreRecentID = firstItem.ID - moreRecentDate := time.Unix(firstItem.BankAccount.Account.Created, 0) - futureState.MoreRecentDate = &moreRecentDate - } - } - - futureState.NoMoreHistory = !hasMore - - return hasMore, futureState, func() { - tl.state = futureState - }, nil -} - -func (tl *Timeline) ExternalAccountsHead(ctx context.Context, to *[]*stripe.ExternalAccount) (bool, TimelineState, func(), error) { - if tl.firstIDAfterStartingAt == "" && tl.state.MoreRecentID == "" { - err := tl.initExternalAccounts(ctx) - if err != nil { - return false, TimelineState{}, nil, err - } - - if tl.firstIDAfterStartingAt == "" { - return false, TimelineState{ - NoMoreHistory: true, - }, func() {}, nil - } - } - - queryParams := url.Values{} - - switch { - case tl.state.MoreRecentID != "": - queryParams.Set("ending_before", tl.state.MoreRecentID) - case tl.firstIDAfterStartingAt != "": - queryParams.Set("ending_before", tl.firstIDAfterStartingAt) - } - - hasMore, err := tl.doExternalAccountsRequest(ctx, queryParams, to) - if err != nil { - return false, TimelineState{}, nil, err - } - - futureState := tl.state - - if len(*to) > 0 { - firstItem := (*to)[0] - futureState.MoreRecentID = firstItem.ID - moreRecentDate := time.Unix(firstItem.BankAccount.Account.Created, 0) - futureState.MoreRecentDate = &moreRecentDate - - if futureState.OldestID == "" { - lastItem := (*to)[len(*to)-1] - futureState.OldestID = lastItem.ID - oldestDate := time.Unix(lastItem.BankAccount.Account.Created, 0) - futureState.OldestDate = &oldestDate - } - } - - futureState.NoMoreHistory = !hasMore - - for i, j := 0, len(*to)-1; i < j; i, j = i+1, j-1 { - (*to)[i], (*to)[j] = (*to)[j], (*to)[i] - } - - return hasMore, futureState, func() { - tl.state = futureState - }, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline_test.go b/cmd/connectors/internal/connectors/stripe/timeline_test.go deleted file mode 100644 index 59503f42..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package stripe - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/stretchr/testify/require" - "github.com/stripe/stripe-go/v72" -) - -func TestTimeline(t *testing.T) { - t.Parallel() - - mock := NewClientMock(t, true) - ref := time.Now() - timeline := NewTimeline(mock, TimelineConfig{ - PageSize: 2, - }, TimelineState{}, WithStartingAt(ref)) - - tx1 := &stripe.BalanceTransaction{ - ID: "tx1", - Created: ref.Add(-time.Minute).Unix(), - } - - tx2 := &stripe.BalanceTransaction{ - ID: "tx2", - Created: ref.Add(-2 * time.Minute).Unix(), - } - - mock.Expect(). - Limit(2). - CreatedLte(ref). - RespondsWith(true, tx1, tx2) - - ret := make([]*stripe.BalanceTransaction, 0) - hasMore, state, commit, err := timeline.TransactionsTail(context.TODO(), &ret) - require.NoError(t, err) - require.True(t, hasMore) - require.Equal(t, TimelineState{ - OldestID: "tx2", - OldestDate: client.DatePtr(time.Unix(tx2.Created, 0)), - MoreRecentID: "tx1", - MoreRecentDate: client.DatePtr(time.Unix(tx1.Created, 0)), - NoMoreHistory: false, - }, state) - - commit() - - tx3 := &stripe.BalanceTransaction{ - ID: "tx3", - Created: ref.Add(-3 * time.Minute).Unix(), - } - - mock.Expect().Limit(2).StartingAfter(tx2.ID).RespondsWith(false, tx3) - - hasMore, state, _, err = timeline.TransactionsTail(context.TODO(), &ret) - require.NoError(t, err) - require.False(t, hasMore) - require.Equal(t, TimelineState{ - OldestID: "tx3", - OldestDate: client.DatePtr(time.Unix(tx3.Created, 0)), - MoreRecentID: "tx1", - MoreRecentDate: client.DatePtr(time.Unix(tx1.Created, 0)), - NoMoreHistory: true, - }, state) -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline_transactions.go b/cmd/connectors/internal/connectors/stripe/timeline_transactions.go deleted file mode 100644 index 5df01e81..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline_transactions.go +++ /dev/null @@ -1,145 +0,0 @@ -package stripe - -import ( - "context" - "fmt" - "net/url" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/stripe/stripe-go/v72" -) - -func (tl *Timeline) doTransactionsRequest(ctx context.Context, queryParams url.Values, - to *[]*stripe.BalanceTransaction, -) (bool, error) { - options := make([]client.ClientOption, 0) - options = append(options, client.QueryParam("limit", fmt.Sprintf("%d", tl.config.PageSize))) - options = append(options, client.QueryParam("expand[]", "data.source")) - options = append(options, client.QueryParam("expand[]", "data.source.charge")) - options = append(options, client.QueryParam("expand[]", "data.source.payment_intent")) - - for k, v := range queryParams { - options = append(options, client.QueryParam(k, v[0])) - } - - txs, hasMore, err := tl.client.BalanceTransactions(ctx, options...) - if err != nil { - return false, err - } - - *to = txs - - return hasMore, nil -} - -func (tl *Timeline) initTransactions(ctx context.Context) error { - ret := make([]*stripe.BalanceTransaction, 0) - params := url.Values{} - params.Set("limit", "1") - params.Set("created[lt]", fmt.Sprintf("%d", tl.startingAt.Unix())) - - _, err := tl.doTransactionsRequest(ctx, params, &ret) - if err != nil { - return err - } - - if len(ret) > 0 { - tl.firstIDAfterStartingAt = ret[0].ID - } - - return nil -} - -func (tl *Timeline) TransactionsTail(ctx context.Context, to *[]*stripe.BalanceTransaction) (bool, TimelineState, func(), error) { - queryParams := url.Values{} - - switch { - case tl.state.OldestID != "": - queryParams.Set("starting_after", tl.state.OldestID) - default: - queryParams.Set("created[lte]", fmt.Sprintf("%d", tl.startingAt.Unix())) - } - - hasMore, err := tl.doTransactionsRequest(ctx, queryParams, to) - if err != nil { - return false, TimelineState{}, nil, err - } - - futureState := tl.state - - if len(*to) > 0 { - lastItem := (*to)[len(*to)-1] - futureState.OldestID = lastItem.ID - oldestDate := time.Unix(lastItem.Created, 0) - futureState.OldestDate = &oldestDate - - if futureState.MoreRecentID == "" { - firstItem := (*to)[0] - futureState.MoreRecentID = firstItem.ID - moreRecentDate := time.Unix(firstItem.Created, 0) - futureState.MoreRecentDate = &moreRecentDate - } - } - - futureState.NoMoreHistory = !hasMore - - return hasMore, futureState, func() { - tl.state = futureState - }, nil -} - -func (tl *Timeline) TransactionsHead(ctx context.Context, to *[]*stripe.BalanceTransaction) (bool, TimelineState, func(), error) { - if tl.firstIDAfterStartingAt == "" && tl.state.MoreRecentID == "" { - err := tl.initTransactions(ctx) - if err != nil { - return false, TimelineState{}, nil, err - } - - if tl.firstIDAfterStartingAt == "" { - return false, TimelineState{ - NoMoreHistory: true, - }, func() {}, nil - } - } - - queryParams := url.Values{} - - switch { - case tl.state.MoreRecentID != "": - queryParams.Set("ending_before", tl.state.MoreRecentID) - case tl.firstIDAfterStartingAt != "": - queryParams.Set("ending_before", tl.firstIDAfterStartingAt) - } - - hasMore, err := tl.doTransactionsRequest(ctx, queryParams, to) - if err != nil { - return false, TimelineState{}, nil, err - } - - futureState := tl.state - - if len(*to) > 0 { - firstItem := (*to)[0] - futureState.MoreRecentID = firstItem.ID - moreRecentDate := time.Unix(firstItem.Created, 0) - futureState.MoreRecentDate = &moreRecentDate - - if futureState.OldestID == "" { - lastItem := (*to)[len(*to)-1] - futureState.OldestID = lastItem.ID - oldestDate := time.Unix(lastItem.Created, 0) - futureState.OldestDate = &oldestDate - } - } - - futureState.NoMoreHistory = !hasMore - - for i, j := 0, len(*to)-1; i < j; i, j = i+1, j-1 { - (*to)[i], (*to)[j] = (*to)[j], (*to)[i] - } - - return hasMore, futureState, func() { - tl.state = futureState - }, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline_trigger.go b/cmd/connectors/internal/connectors/stripe/timeline_trigger.go deleted file mode 100644 index ab5e072d..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline_trigger.go +++ /dev/null @@ -1,184 +0,0 @@ -package stripe - -import ( - "context" - - "github.com/formancehq/go-libs/logging" - "github.com/pkg/errors" - "github.com/stripe/stripe-go/v72" - "golang.org/x/sync/semaphore" -) - -type TimelineTriggerType string - -const ( - TimelineTriggerTypeTransactions TimelineTriggerType = "transactions" - TimelineTriggerTypeAccounts TimelineTriggerType = "accounts" - TimelineTriggerTypeExternalAccounts TimelineTriggerType = "external_accounts" -) - -func NewTimelineTrigger( - logger logging.Logger, - ingester Ingester, - timeline *Timeline, - timelineType TimelineTriggerType, -) *TimelineTrigger { - return &TimelineTrigger{ - logger: logger.WithFields(map[string]interface{}{ - "component": "timeline-trigger", - }), - ingester: ingester, - timeline: timeline, - timelineType: timelineType, - sem: semaphore.NewWeighted(1), - } -} - -type TimelineTrigger struct { - logger logging.Logger - ingester Ingester - timeline *Timeline - timelineType TimelineTriggerType - sem *semaphore.Weighted - cancel func() -} - -func (t *TimelineTrigger) Fetch(ctx context.Context) error { - if t.sem.TryAcquire(1) { - defer t.sem.Release(1) - - ctx, t.cancel = context.WithCancel(ctx) - if !t.timeline.State().NoMoreHistory { - if err := t.fetch(ctx, true); err != nil { - return err - } - } - - select { - case <-ctx.Done(): - return ctx.Err() - default: - if err := t.fetch(ctx, false); err != nil { - return err - } - } - } - - return nil -} - -func (t *TimelineTrigger) Cancel(ctx context.Context) { - if t.cancel != nil { - t.cancel() - - err := t.sem.Acquire(ctx, 1) - if err != nil { - panic(err) - } - - t.sem.Release(1) - } -} - -func (t *TimelineTrigger) fetch(ctx context.Context, tail bool) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - hasMore, err := t.triggerPage(ctx, tail) - if err != nil { - return errors.Wrap(err, "error triggering tail page") - } - - if !hasMore { - return nil - } - } - } -} - -func (t *TimelineTrigger) triggerPage(ctx context.Context, tail bool) (bool, error) { - logger := t.logger.WithFields(map[string]interface{}{ - "tail": tail, - }) - - logger.Debugf("Trigger page") - - var hasMore bool - switch t.timelineType { - case TimelineTriggerTypeTransactions: - ret := make([]*stripe.BalanceTransaction, 0) - method := t.timeline.TransactionsHead - if tail { - method = t.timeline.TransactionsTail - } - - more, futureState, commitFn, err := method(ctx, &ret) - if err != nil { - return false, errors.Wrap(err, "fetching timeline") - } - hasMore = more - - logger.Debug("Ingest transactions batch") - - if len(ret) > 0 { - err = t.ingester.IngestTransactions(ctx, ret, futureState, tail) - if err != nil { - return false, errors.Wrap(err, "ingesting batch") - } - } - - commitFn() - - case TimelineTriggerTypeAccounts: - ret := make([]*stripe.Account, 0) - method := t.timeline.AccountsHead - if tail { - method = t.timeline.AccountsTail - } - - more, futureState, commitFn, err := method(ctx, &ret) - if err != nil { - return false, errors.Wrap(err, "fetching timeline") - } - hasMore = more - - logger.Debug("Ingest accounts batch") - - if len(ret) > 0 { - err = t.ingester.IngestAccounts(ctx, ret, futureState, tail) - if err != nil { - return false, errors.Wrap(err, "ingesting batch") - } - } - - commitFn() - - case TimelineTriggerTypeExternalAccounts: - ret := make([]*stripe.ExternalAccount, 0) - method := t.timeline.ExternalAccountsHead - if tail { - method = t.timeline.ExternalAccountsTail - } - - more, futureState, commitFn, err := method(ctx, &ret) - if err != nil { - return false, errors.Wrap(err, "fetching timeline") - } - hasMore = more - - logger.Debug("Ingest transactions batch") - - if len(ret) > 0 { - err = t.ingester.IngestExternalAccounts(ctx, ret, futureState, tail) - if err != nil { - return false, errors.Wrap(err, "ingesting batch") - } - } - - commitFn() - } - - return hasMore, nil -} diff --git a/cmd/connectors/internal/connectors/stripe/timeline_trigger_test.go b/cmd/connectors/internal/connectors/stripe/timeline_trigger_test.go deleted file mode 100644 index 21fa54ac..00000000 --- a/cmd/connectors/internal/connectors/stripe/timeline_trigger_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package stripe - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/stretchr/testify/require" - "github.com/stripe/stripe-go/v72" -) - -func TestTimelineTrigger(t *testing.T) { - t.Parallel() - - const txCount = 12 - - mock := NewClientMock(t, true) - ref := time.Now().Add(-time.Minute * time.Duration(txCount) / 2) - timeline := NewTimeline(mock, TimelineConfig{ - PageSize: 2, - }, TimelineState{}, WithStartingAt(ref)) - - ingestedTx := make([]*stripe.BalanceTransaction, 0) - trigger := NewTimelineTrigger( - logging.FromContext(context.TODO()), - NewIngester( - func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - ingestedTx = append(ingestedTx, batch...) - return nil - }, - func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - return nil - }, - func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - return nil - }, - ), - timeline, - TimelineTriggerTypeTransactions, - ) - - allTxs := make([]*stripe.BalanceTransaction, txCount) - for i := 0; i < txCount/2; i++ { - allTxs[txCount/2+i] = &stripe.BalanceTransaction{ - ID: fmt.Sprintf("%d", txCount/2+i), - Created: ref.Add(-time.Duration(i) * time.Minute).Unix(), - } - allTxs[txCount/2-i-1] = &stripe.BalanceTransaction{ - ID: fmt.Sprintf("%d", txCount/2-i-1), - Created: ref.Add(time.Duration(i) * time.Minute).Unix(), - } - } - - for i := 0; i < txCount/2; i += 2 { - mock.Expect().Limit(2).RespondsWith(i < txCount/2-2, allTxs[txCount/2+i], allTxs[txCount/2+i+1]) - } - - for i := 0; i < txCount/2; i += 2 { - mock.Expect().Limit(2).RespondsWith(i < txCount/2-2, allTxs[txCount/2-i-2], allTxs[txCount/2-i-1]) - } - - ctx, cancel := context.WithDeadline(context.TODO(), time.Now().Add(time.Second)) - defer cancel() - - require.NoError(t, trigger.Fetch(ctx)) - require.Len(t, ingestedTx, txCount) -} - -func TestCancelTimelineTrigger(t *testing.T) { - t.Parallel() - - const txCount = 12 - - mock := NewClientMock(t, false) - ref := time.Now().Add(-time.Minute * time.Duration(txCount) / 2) - timeline := NewTimeline(mock, TimelineConfig{ - PageSize: 1, - }, TimelineState{}, WithStartingAt(ref)) - - waiting := make(chan struct{}) - trigger := NewTimelineTrigger( - logging.FromContext(context.TODO()), - NewIngester( - func(ctx context.Context, batch []*stripe.BalanceTransaction, commitState TimelineState, tail bool) error { - close(waiting) // Instruct the test the trigger is in fetching state - <-ctx.Done() - - return nil - }, - func(ctx context.Context, batch []*stripe.Account, commitState TimelineState, tail bool) error { - return nil - }, - func(ctx context.Context, batch []*stripe.ExternalAccount, commitState TimelineState, tail bool) error { - return nil - }, - ), - timeline, - TimelineTriggerTypeTransactions, - ) - - allTxs := make([]*stripe.BalanceTransaction, txCount) - for i := 0; i < txCount; i++ { - allTxs[i] = &stripe.BalanceTransaction{ - ID: fmt.Sprintf("%d", i), - } - mock.Expect().Limit(1).RespondsWith(i < txCount-1, allTxs[i]) - } - - go func() { - // TODO: Handle error - _ = trigger.Fetch(context.TODO()) - }() - select { - case <-time.After(time.Second): - t.Fatalf("timeout") - case <-waiting: - trigger.Cancel(context.TODO()) - require.NotEmpty(t, mock.expectations) - } -} diff --git a/cmd/connectors/internal/connectors/stripe/translate.go b/cmd/connectors/internal/connectors/stripe/translate.go deleted file mode 100644 index 057035ba..00000000 --- a/cmd/connectors/internal/connectors/stripe/translate.go +++ /dev/null @@ -1,901 +0,0 @@ -package stripe - -import ( - "encoding/json" - "log" - "math/big" - "runtime/debug" - "strings" - "time" - - "github.com/davecgh/go-spew/spew" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/internal/models" - "github.com/stripe/stripe-go/v72" -) - -func createBatchElement( - connectorID models.ConnectorID, - balanceTransaction *stripe.BalanceTransaction, - account string, - forward bool, -) (ingestion.PaymentBatchElement, bool) { - var payment *models.Payment - var adjustment *models.PaymentAdjustment - - defer func() { - // DEBUG - if e := recover(); e != nil { - log.Println("Error translating transaction") - debug.PrintStack() - spew.Dump(balanceTransaction) - panic(e) - } - }() - - if balanceTransaction.Source == nil { - return ingestion.PaymentBatchElement{}, false - } - - rawData, err := json.Marshal(balanceTransaction) - if err != nil { - return ingestion.PaymentBatchElement{}, false - } - - switch balanceTransaction.Type { - case stripe.BalanceTransactionTypeCharge: - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Created, 0) - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Charge.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Charge.Metadata, balanceTransaction.Source.Charge.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Charge.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Charge.Amount - balanceTransaction.Source.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Charge.Amount), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - RawData: rawData, - Scheme: models.PaymentScheme(balanceTransaction.Source.Charge.PaymentMethodDetails.Card.Brand), - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - case stripe.BalanceTransactionTypeRefund: - // Refund a charge - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Refund.Charge.Created, 0) - // Created when a credit card charge refund is initiated. - // If you authorize and capture separately and the capture amount is - // less than the initial authorization, you see a balance transaction - // of type charge for the full authorization amount and another balance - // transaction of type refund for the uncaptured portion. - // cf https://stripe.com/docs/reports/balance-transaction-types - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Refund.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Metadata, balanceTransaction.Source.Refund.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount - balanceTransaction.Source.Refund.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - RawData: rawData, - Scheme: models.PaymentScheme(balanceTransaction.Source.Refund.Charge.PaymentMethodDetails.Card.Brand), - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), - Status: models.PaymentStatusRefunded, - RawData: rawData, - } - - case stripe.BalanceTransactionTypeRefundFailure: - // Refund a charge - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Refund.Charge.Created, 0) - // Created when a credit card charge refund is initiated. - // If you authorize and capture separately and the capture amount is - // less than the initial authorization, you see a balance transaction - // of type charge for the full authorization amount and another balance - // transaction of type refund for the uncaptured portion. - // cf https://stripe.com/docs/reports/balance-transaction-types - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Refund.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Metadata, balanceTransaction.Source.Refund.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount - balanceTransaction.Source.Refund.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - RawData: rawData, - Scheme: models.PaymentScheme(balanceTransaction.Source.Refund.Charge.PaymentMethodDetails.Card.Brand), - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), - Status: models.PaymentStatusRefundedFailure, - RawData: rawData, - } - - case stripe.BalanceTransactionTypePayment: - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Created, 0) - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Charge.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Charge.Metadata, balanceTransaction.Source.Charge.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Charge.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Charge.Amount - balanceTransaction.Source.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Charge.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - Scheme: models.PaymentSchemeOther, - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - case stripe.BalanceTransactionTypePaymentRefund: - // Refund a charge - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Refund.Charge.Created, 0) - // Created when a credit card charge refund is initiated. - // If you authorize and capture separately and the capture amount is - // less than the initial authorization, you see a balance transaction - // of type charge for the full authorization amount and another balance - // transaction of type refund for the uncaptured portion. - // cf https://stripe.com/docs/reports/balance-transaction-types - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Refund.Charge.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Charge.Metadata, balanceTransaction.Source.Refund.Charge.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Charge.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount - balanceTransaction.Source.Refund.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - Scheme: models.PaymentSchemeOther, - RawData: rawData, - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), - Status: models.PaymentStatusRefunded, - RawData: rawData, - } - - case stripe.BalanceTransactionTypePaymentFailureRefund: - // Refund a charge - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Refund.Charge.Created, 0) - // Created when a credit card charge refund is initiated. - // If you authorize and capture separately and the capture amount is - // less than the initial authorization, you see a balance transaction - // of type charge for the full authorization amount and another balance - // transaction of type refund for the uncaptured portion. - // cf https://stripe.com/docs/reports/balance-transaction-types - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Refund.Charge.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Charge.Metadata, balanceTransaction.Source.Refund.Charge.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Refund.Charge.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount - balanceTransaction.Source.Refund.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Refund.Charge.Amount), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - Scheme: models.PaymentSchemeOther, - RawData: rawData, - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), - Status: models.PaymentStatusRefundedFailure, - RawData: rawData, - } - - case stripe.BalanceTransactionTypePayout: - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Payout.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.ID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Created, 0) - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayOut, - Status: convertPayoutStatus(balanceTransaction.Source.Payout.Status), - Amount: big.NewInt(balanceTransaction.Source.Payout.Amount), - InitialAmount: big.NewInt(balanceTransaction.Source.Payout.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Payout.Currency)), - Scheme: func() models.PaymentScheme { - switch balanceTransaction.Source.Payout.Type { - case stripe.PayoutTypeBank: - return models.PaymentSchemeSepaCredit - case stripe.PayoutTypeCard: - return models.PaymentScheme(balanceTransaction.Source.Payout.Card.Brand) - } - - return models.PaymentSchemeUnknown - }(), - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Payout.Metadata), - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - case stripe.BalanceTransactionTypePayoutFailure: - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Payout.BalanceTransaction.ID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Payout.Created, 0) - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Payout.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusFailed, - Amount: big.NewInt(balanceTransaction.Source.Payout.Amount), - InitialAmount: big.NewInt(balanceTransaction.Source.Payout.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Payout.Currency)), - Scheme: func() models.PaymentScheme { - switch balanceTransaction.Source.Payout.Type { - case stripe.PayoutTypeBank: - return models.PaymentSchemeSepaCredit - case stripe.PayoutTypeCard: - return models.PaymentScheme(balanceTransaction.Source.Payout.Card.Brand) - } - - return models.PaymentSchemeUnknown - }(), - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Payout.Metadata), - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Payout.BalanceTransaction.ID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Status: models.PaymentStatusFailed, - RawData: rawData, - } - - case stripe.BalanceTransactionTypePayoutCancel: - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Payout.BalanceTransaction.ID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Payout.Created, 0) - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Payout.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusCancelled, - Amount: big.NewInt(balanceTransaction.Source.Payout.Amount), - InitialAmount: big.NewInt(balanceTransaction.Source.Payout.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Payout.Currency)), - Scheme: func() models.PaymentScheme { - switch balanceTransaction.Source.Payout.Type { - case stripe.PayoutTypeBank: - return models.PaymentSchemeSepaCredit - case stripe.PayoutTypeCard: - return models.PaymentScheme(balanceTransaction.Source.Payout.Card.Brand) - } - - return models.PaymentSchemeUnknown - }(), - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Payout.Metadata), - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Payout.BalanceTransaction.ID, - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Status: models.PaymentStatusCancelled, - RawData: rawData, - } - - case stripe.BalanceTransactionTypeTransfer: - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Transfer.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Created, 0) - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypeTransfer, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Transfer.Amount - balanceTransaction.Source.Transfer.AmountReversed), - InitialAmount: big.NewInt(balanceTransaction.Source.Transfer.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Transfer.Currency)), - Scheme: models.PaymentSchemeOther, - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Transfer.Metadata), - } - - if balanceTransaction.Source.Transfer.Destination != nil { - payment.DestinationAccountID = &models.AccountID{ - Reference: balanceTransaction.Source.Transfer.Destination.ID, - ConnectorID: connectorID, - } - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - case stripe.BalanceTransactionTypeTransferRefund: - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Transfer.Created, 0) - // Two things to insert here: the balance transaction at the origin - // of the refund and the balance transaction of the refund, which is an - // adjustment of the origin. - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypeTransfer, - Status: models.PaymentStatusSucceeded, - Amount: big.NewInt(balanceTransaction.Source.Transfer.Amount - balanceTransaction.Source.Transfer.AmountReversed), - InitialAmount: big.NewInt(balanceTransaction.Source.Transfer.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Transfer.Currency)), - Scheme: models.PaymentSchemeOther, - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Transfer.Metadata), - } - - if balanceTransaction.Source.Transfer.Destination != nil { - payment.DestinationAccountID = &models.AccountID{ - Reference: balanceTransaction.Source.Transfer.Destination.ID, - ConnectorID: connectorID, - } - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Amount), - Status: models.PaymentStatusRefunded, - RawData: rawData, - } - - case stripe.BalanceTransactionTypeTransferCancel: - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Transfer.Created, 0) - - // Two things to insert here: the balance transaction at the origin - // of the refund and the balance transaction of the refund, which is an - // adjustment of the origin. - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypeTransfer, - Status: models.PaymentStatusCancelled, - Amount: big.NewInt(balanceTransaction.Source.Transfer.Amount - balanceTransaction.Source.Transfer.AmountReversed), - InitialAmount: big.NewInt(balanceTransaction.Source.Transfer.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Transfer.Currency)), - Scheme: models.PaymentSchemeOther, - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Transfer.Metadata), - } - - if balanceTransaction.Source.Transfer.Destination != nil { - payment.DestinationAccountID = &models.AccountID{ - Reference: balanceTransaction.Source.Transfer.Destination.ID, - ConnectorID: connectorID, - } - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Amount), - Status: models.PaymentStatusCancelled, - RawData: rawData, - } - - case stripe.BalanceTransactionTypeTransferFailure: - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Transfer.Created, 0) - // Two things to insert here: the balance transaction at the origin - // of the refund and the balance transaction of the refund, which is an - // adjustment of the origin. - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypeTransfer, - Status: models.PaymentStatusFailed, - Amount: big.NewInt(balanceTransaction.Source.Transfer.Amount - balanceTransaction.Source.Transfer.AmountReversed), - InitialAmount: big.NewInt(balanceTransaction.Source.Transfer.Amount), - RawData: rawData, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(balanceTransaction.Source.Transfer.Currency)), - Scheme: models.PaymentSchemeOther, - CreatedAt: createdAt, - Metadata: computeMetadata(paymentID, createdAt, balanceTransaction.Source.Transfer.Metadata), - } - - if balanceTransaction.Source.Transfer.Destination != nil { - payment.DestinationAccountID = &models.AccountID{ - Reference: balanceTransaction.Source.Transfer.Destination.ID, - ConnectorID: connectorID, - } - } - - if account != "" { - payment.SourceAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Amount: big.NewInt(balanceTransaction.Amount), - Status: models.PaymentStatusFailed, - RawData: rawData, - } - - case stripe.BalanceTransactionTypeAdjustment: - if balanceTransaction.Source.Dispute == nil { - // We are only handle dispute adjustments - return ingestion.PaymentBatchElement{}, false - } - - transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Dispute.Charge.Currency)) - _, ok := supportedCurrenciesWithDecimal[transactionCurrency] - if !ok { - return ingestion.PaymentBatchElement{}, false - } - - disputeStatus := convertDisputeStatus(balanceTransaction.Source.Dispute.Status) - paymentStatus := models.PaymentStatusPending - switch disputeStatus { - case models.PaymentStatusDisputeWon: - paymentStatus = models.PaymentStatusSucceeded - case models.PaymentStatusDisputeLost: - paymentStatus = models.PaymentStatusFailed - default: - paymentStatus = models.PaymentStatusPending - } - - paymentID := models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Dispute.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - } - createdAt := time.Unix(balanceTransaction.Source.Dispute.Charge.Created, 0) - - var metadata []*models.PaymentMetadata - if balanceTransaction.Source.Dispute.Charge.PaymentIntent != nil { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Dispute.Charge.Metadata, balanceTransaction.Source.Dispute.Charge.PaymentIntent.Metadata) - } else { - metadata = computeMetadata(paymentID, createdAt, balanceTransaction.Source.Dispute.Charge.Metadata) - } - - payment = &models.Payment{ - ID: paymentID, - Reference: balanceTransaction.Source.Dispute.Charge.BalanceTransaction.ID, - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: paymentStatus, // Dispute is occuring, we don't know the outcome yet - Amount: big.NewInt(balanceTransaction.Source.Dispute.Charge.Amount - balanceTransaction.Source.Dispute.Charge.AmountRefunded), - InitialAmount: big.NewInt(balanceTransaction.Source.Dispute.Charge.Amount), - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), - RawData: rawData, - Scheme: models.PaymentScheme(balanceTransaction.Source.Dispute.Charge.PaymentMethodDetails.Card.Brand), - CreatedAt: createdAt, - Metadata: metadata, - } - - if account != "" { - payment.DestinationAccountID = &models.AccountID{ - Reference: account, - ConnectorID: connectorID, - } - } - - adjustment = &models.PaymentAdjustment{ - PaymentID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: balanceTransaction.Source.Dispute.Charge.BalanceTransaction.ID, - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - CreatedAt: time.Unix(balanceTransaction.Created, 0), - Reference: balanceTransaction.ID, - Status: disputeStatus, - RawData: rawData, - } - - case stripe.BalanceTransactionTypeStripeFee: - return ingestion.PaymentBatchElement{}, false - default: - return ingestion.PaymentBatchElement{}, false - } - - return ingestion.PaymentBatchElement{ - Payment: payment, - Adjustment: adjustment, - }, true -} - -func convertDisputeStatus(status stripe.DisputeStatus) models.PaymentStatus { - switch status { - case stripe.DisputeStatusNeedsResponse, stripe.DisputeStatusUnderReview: - return models.PaymentStatusDispute - case stripe.DisputeStatusLost: - return models.PaymentStatusDisputeLost - case stripe.DisputeStatusWon: - return models.PaymentStatusDisputeWon - default: - return models.PaymentStatusDispute - } -} - -func convertPayoutStatus(status stripe.PayoutStatus) models.PaymentStatus { - switch status { - case stripe.PayoutStatusCanceled: - return models.PaymentStatusCancelled - case stripe.PayoutStatusFailed: - return models.PaymentStatusFailed - case stripe.PayoutStatusInTransit, stripe.PayoutStatusPending: - return models.PaymentStatusPending - case stripe.PayoutStatusPaid: - return models.PaymentStatusSucceeded - } - - return models.PaymentStatusOther -} - -func computeMetadata(paymentID models.PaymentID, createdAt time.Time, metadatas ...map[string]string) []*models.PaymentMetadata { - res := make([]*models.PaymentMetadata, 0) - for _, metadata := range metadatas { - for k, v := range metadata { - res = append(res, &models.PaymentMetadata{ - PaymentID: paymentID, - CreatedAt: createdAt, - Key: k, - Value: v, - }) - } - } - - return res -} diff --git a/cmd/connectors/internal/connectors/stripe/utils_test.go b/cmd/connectors/internal/connectors/stripe/utils_test.go deleted file mode 100644 index 117bf33c..00000000 --- a/cmd/connectors/internal/connectors/stripe/utils_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package stripe - -import ( - "context" - "flag" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "os" - "sync" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/stripe/client" - "github.com/stripe/stripe-go/v72" -) - -func TestMain(m *testing.M) { - flag.Parse() - - os.Exit(m.Run()) -} - -type ClientMockExpectation struct { - query url.Values - hasMore bool - items []*stripe.BalanceTransaction -} - -func (e *ClientMockExpectation) QueryParam(key string, value any) *ClientMockExpectation { - var qpvalue string - switch value.(type) { - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - qpvalue = fmt.Sprintf("%d", value) - default: - qpvalue = fmt.Sprintf("%s", value) - } - e.query.Set(key, qpvalue) - - return e -} - -func (e *ClientMockExpectation) StartingAfter(v string) *ClientMockExpectation { - e.QueryParam("starting_after", v) - - return e -} - -func (e *ClientMockExpectation) CreatedLte(v time.Time) *ClientMockExpectation { - e.QueryParam("created[lte]", v.Unix()) - - return e -} - -func (e *ClientMockExpectation) Limit(v int) *ClientMockExpectation { - e.QueryParam("limit", v) - - return e -} - -func (e *ClientMockExpectation) RespondsWith(hasMore bool, - txs ...*stripe.BalanceTransaction, -) *ClientMockExpectation { - e.hasMore = hasMore - e.items = txs - - return e -} - -func (e *ClientMockExpectation) handle(options ...client.ClientOption) ([]*stripe.BalanceTransaction, bool, error) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - - for _, option := range options { - option.Apply(req) - } - - for key := range e.query { - if req.URL.Query().Get(key) != e.query.Get(key) { - return nil, false, fmt.Errorf("mismatch query params, expected query param '%s' "+ - "with value '%s', got '%s'", key, e.query.Get(key), req.URL.Query().Get(key)) - } - } - - return e.items, e.hasMore, nil -} - -type ClientMock struct { - expectations *FIFO[*ClientMockExpectation] -} - -func (m *ClientMock) ForAccount(account string) client.Client { - return m -} - -func (m *ClientMock) Accounts(ctx context.Context, - options ...client.ClientOption, -) ([]*stripe.Account, bool, error) { - return nil, false, nil -} - -func (m *ClientMock) Balance(ctx context.Context, - options ...client.ClientOption, -) (*stripe.Balance, error) { - return nil, nil -} - -func (m *ClientMock) ExternalAccounts(ctx context.Context, - options ...client.ClientOption, -) ([]*stripe.ExternalAccount, bool, error) { - return nil, false, nil -} - -func (m *ClientMock) CreateTransfer(ctx context.Context, - createTransferRequest *client.CreateTransferRequest, - options ...client.ClientOption, -) (*stripe.Transfer, error) { - return nil, nil -} - -func (m *ClientMock) ReverseTransfer(ctx context.Context, - createTransferReversalRequest *client.CreateTransferReversalRequest, - options ...client.ClientOption, -) (*stripe.Reversal, error) { - return nil, nil -} - -func (m *ClientMock) CreatePayout(ctx context.Context, - createPayoutRequest *client.CreatePayoutRequest, - options ...client.ClientOption, -) (*stripe.Payout, error) { - return nil, nil -} - -func (m *ClientMock) GetPayout(ctx context.Context, - payoutID string, - options ...client.ClientOption, -) (*stripe.Payout, error) { - return nil, nil -} - -func (m *ClientMock) BalanceTransactions(ctx context.Context, - options ...client.ClientOption, -) ([]*stripe.BalanceTransaction, bool, error) { - e, ok := m.expectations.Pop() - if !ok { - return nil, false, fmt.Errorf("no more expectation") - } - - return e.handle(options...) -} - -func (m *ClientMock) Expect() *ClientMockExpectation { - e := &ClientMockExpectation{ - query: url.Values{}, - } - m.expectations.Push(e) - - return e -} - -func NewClientMock(t *testing.T, expectationsShouldBeConsumed bool) *ClientMock { - t.Helper() - - m := &ClientMock{ - expectations: &FIFO[*ClientMockExpectation]{}, - } - - if expectationsShouldBeConsumed { - t.Cleanup(func() { - if !m.expectations.Empty() && !t.Failed() { - t.Errorf("all expectations not consumed") - } - }) - } - - return m -} - -var _ client.Client = &ClientMock{} - -type FIFO[ITEM any] struct { - mu sync.Mutex - items []ITEM -} - -func (s *FIFO[ITEM]) Pop() (ITEM, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - if len(s.items) == 0 { - var i ITEM - - return i, false - } - - ret := s.items[0] - - if len(s.items) == 1 { - s.items = make([]ITEM, 0) - - return ret, true - } - - s.items = s.items[1:] - - return ret, true -} - -func (s *FIFO[ITEM]) Peek() (ITEM, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - if len(s.items) == 0 { - var i ITEM - - return i, false - } - - return s.items[0], true -} - -func (s *FIFO[ITEM]) Push(i ITEM) *FIFO[ITEM] { - s.mu.Lock() - defer s.mu.Unlock() - - s.items = append(s.items, i) - - return s -} - -func (s *FIFO[ITEM]) Empty() bool { - s.mu.Lock() - defer s.mu.Unlock() - - return len(s.items) == 0 -} diff --git a/cmd/connectors/internal/connectors/utils.go b/cmd/connectors/internal/connectors/utils.go deleted file mode 100644 index eea7591c..00000000 --- a/cmd/connectors/internal/connectors/utils.go +++ /dev/null @@ -1,52 +0,0 @@ -package connectors - -import ( - "context" - "os" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/trace" -) - -type DeferrableFunc func(ctx context.Context, timeSince time.Time) - -func ClientMetrics(ctx context.Context, connectorName, operation string) DeferrableFunc { - attributes := []attribute.KeyValue{ - attribute.String("connector", connectorName), - attribute.String("operation", operation), - } - - stack := os.Getenv("STACK") - if stack != "" { - attributes = append(attributes, attribute.String("stack", stack)) - } - - metrics.GetMetricsRegistry().ConnectorPSPCalls().Add(ctx, 1, metric.WithAttributes(attributes...)) - - return func(ctx context.Context, timeSince time.Time) { - metrics.GetMetricsRegistry().ConnectorPSPCallLatencies().Record(ctx, time.Since(timeSince).Milliseconds(), metric.WithAttributes(attributes...)) - } -} - -func StartSpan( - ctx context.Context, - spanName string, - attributes ...attribute.KeyValue, -) (context.Context, trace.Span) { - parentSpan := trace.SpanFromContext(ctx) - return otel.Tracer().Start( - ctx, - spanName, - trace.WithNewRoot(), - trace.WithLinks(trace.Link{ - SpanContext: parentSpan.SpanContext(), - }), - trace.WithAttributes( - attributes..., - ), - ) -} diff --git a/cmd/connectors/internal/connectors/wise/client/balances.go b/cmd/connectors/internal/connectors/wise/client/balances.go deleted file mode 100644 index eb9b5e71..00000000 --- a/cmd/connectors/internal/connectors/wise/client/balances.go +++ /dev/null @@ -1,67 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Balance struct { - ID uint64 `json:"id"` - Currency string `json:"currency"` - Type string `json:"type"` - Name string `json:"name"` - Amount struct { - Value json.Number `json:"value"` - Currency string `json:"currency"` - } `json:"amount"` - ReservedAmount struct { - Value json.Number `json:"value"` - Currency string `json:"currency"` - } `json:"reservedAmount"` - CashAmount struct { - Value json.Number `json:"value"` - Currency string `json:"currency"` - } `json:"cashAmount"` - TotalWorth struct { - Value json.Number `json:"value"` - Currency string `json:"currency"` - } `json:"totalWorth"` - CreationTime time.Time `json:"creationTime"` - ModificationTime time.Time `json:"modificationTime"` - Visible bool `json:"visible"` -} - -func (w *Client) GetBalances(ctx context.Context, profileID uint64) ([]*Balance, error) { - f := connectors.ClientMetrics(ctx, "wise", "list_balances") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint(fmt.Sprintf("v4/profiles/%d/balances?types=STANDARD", profileID)), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - var balances []*Balance - err = json.NewDecoder(res.Body).Decode(&balances) - if err != nil { - return nil, fmt.Errorf("failed to decode account: %w", err) - } - - return balances, nil -} diff --git a/cmd/connectors/internal/connectors/wise/client/client.go b/cmd/connectors/internal/connectors/wise/client/client.go deleted file mode 100644 index bfe308c0..00000000 --- a/cmd/connectors/internal/connectors/wise/client/client.go +++ /dev/null @@ -1,47 +0,0 @@ -package client - -import ( - "fmt" - "net/http" - - lru "github.com/hashicorp/golang-lru/v2" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -const apiEndpoint = "https://api.wise.com" - -type apiTransport struct { - APIKey string - underlying http.RoundTripper -} - -func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", t.APIKey)) - - return t.underlying.RoundTrip(req) -} - -type Client struct { - httpClient *http.Client - - recipientAccountsCache *lru.Cache[uint64, *RecipientAccount] -} - -func (w *Client) endpoint(path string) string { - return fmt.Sprintf("%s/%s", apiEndpoint, path) -} - -func NewClient(apiKey string) *Client { - recipientsCache, _ := lru.New[uint64, *RecipientAccount](2048) - httpClient := &http.Client{ - Transport: &apiTransport{ - APIKey: apiKey, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - } - - return &Client{ - httpClient: httpClient, - recipientAccountsCache: recipientsCache, - } -} diff --git a/cmd/connectors/internal/connectors/wise/client/error.go b/cmd/connectors/internal/connectors/wise/client/error.go deleted file mode 100644 index 13f3813d..00000000 --- a/cmd/connectors/internal/connectors/wise/client/error.go +++ /dev/null @@ -1,42 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "io" -) - -type wiseErrors struct { - Errors []*wiseError `json:"errors"` -} - -type wiseError struct { - StatusCode int `json:"-"` - Code string `json:"code"` - Message string `json:"message"` -} - -func (me *wiseError) Error() error { - if me.Message == "" { - return fmt.Errorf("unexpected status code: %d", me.StatusCode) - } - - return fmt.Errorf("%s: %s", me.Code, me.Message) -} - -func unmarshalError(statusCode int, body io.ReadCloser) *wiseError { - var ces wiseErrors - _ = json.NewDecoder(body).Decode(&ces) - - if len(ces.Errors) == 0 { - return &wiseError{ - StatusCode: statusCode, - } - } - - return &wiseError{ - StatusCode: statusCode, - Code: ces.Errors[0].Code, - Message: ces.Errors[0].Message, - } -} diff --git a/cmd/connectors/internal/connectors/wise/client/payouts.go b/cmd/connectors/internal/connectors/wise/client/payouts.go deleted file mode 100644 index a87111e4..00000000 --- a/cmd/connectors/internal/connectors/wise/client/payouts.go +++ /dev/null @@ -1,131 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Payout struct { - ID uint64 `json:"id"` - Reference string `json:"reference"` - Status string `json:"status"` - SourceAccount uint64 `json:"sourceAccount"` - SourceCurrency string `json:"sourceCurrency"` - SourceValue json.Number `json:"sourceValue"` - TargetAccount uint64 `json:"targetAccount"` - TargetCurrency string `json:"targetCurrency"` - TargetValue json.Number `json:"targetValue"` - Business uint64 `json:"business"` - Created string `json:"created"` - //nolint:tagliatelle // allow for clients - CustomerTransactionID string `json:"customerTransactionId"` - Details struct { - Reference string `json:"reference"` - } `json:"details"` - Rate float64 `json:"rate"` - User uint64 `json:"user"` - - SourceBalanceID uint64 `json:"-"` - DestinationBalanceID uint64 `json:"-"` - - CreatedAt time.Time `json:"-"` -} - -func (t *Payout) UnmarshalJSON(data []byte) error { - type Alias Transfer - - aux := &struct { - Created string `json:"created"` - *Alias - }{ - Alias: (*Alias)(t), - } - - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - - var err error - - t.CreatedAt, err = time.Parse("2006-01-02 15:04:05", aux.Created) - if err != nil { - return fmt.Errorf("failed to parse created time: %w", err) - } - - return nil -} - -func (w *Client) GetPayout(ctx context.Context, payoutID string) (*Payout, error) { - f := connectors.ClientMetrics(ctx, "wise", "get_payout") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v1/transfers/"+payoutID), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var payout Payout - err = json.Unmarshal(body, &payout) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfer: %w", err) - } - - return &payout, nil -} - -func (w *Client) CreatePayout(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Payout, error) { - f := connectors.ClientMetrics(ctx, "wise", "initiate_payout") - now := time.Now() - defer f(ctx, now) - - req, err := json.Marshal(map[string]interface{}{ - "targetAccount": targetAccount, - "quoteUuid": quote.ID.String(), - "customerTransactionId": transactionID, - }) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Post(w.endpoint("v1/transfers"), "application/json", bytes.NewBuffer(req)) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - var response Payout - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("failed to get response from transfer: %w", err) - } - - return &response, nil -} diff --git a/cmd/connectors/internal/connectors/wise/client/profiles.go b/cmd/connectors/internal/connectors/wise/client/profiles.go deleted file mode 100644 index 6cf4fe19..00000000 --- a/cmd/connectors/internal/connectors/wise/client/profiles.go +++ /dev/null @@ -1,48 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type Profile struct { - ID uint64 `json:"id"` - Type string `json:"type"` -} - -func (w *Client) GetProfiles(ctx context.Context) ([]Profile, error) { - f := connectors.ClientMetrics(ctx, "wise", "list_profiles") - now := time.Now() - defer f(ctx, now) - - var profiles []Profile - - res, err := w.httpClient.Get(w.endpoint("v2/profiles")) - if err != nil { - return profiles, err - } - - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - err = json.Unmarshal(body, &profiles) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal profiles: %w", err) - } - - return profiles, nil -} diff --git a/cmd/connectors/internal/connectors/wise/client/quotes.go b/cmd/connectors/internal/connectors/wise/client/quotes.go deleted file mode 100644 index cdbced8e..00000000 --- a/cmd/connectors/internal/connectors/wise/client/quotes.go +++ /dev/null @@ -1,57 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/google/uuid" -) - -type Quote struct { - ID uuid.UUID `json:"id"` -} - -func (w *Client) CreateQuote(ctx context.Context, profileID, currency string, amount json.Number) (Quote, error) { - f := connectors.ClientMetrics(ctx, "wise", "create_quote") - now := time.Now() - defer f(ctx, now) - - var response Quote - - req, err := json.Marshal(map[string]interface{}{ - "sourceCurrency": currency, - "targetCurrency": currency, - "sourceAmount": amount, - }) - if err != nil { - return response, err - } - - res, err := w.httpClient.Post(w.endpoint("v3/profiles/"+profileID+"/quotes"), "application/json", bytes.NewBuffer(req)) - if err != nil { - return response, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return response, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return response, fmt.Errorf("failed to read response body: %w", err) - } - - err = json.Unmarshal(body, &response) - if err != nil { - return response, fmt.Errorf("failed to get response from quote: %w", err) - } - - return response, nil -} diff --git a/cmd/connectors/internal/connectors/wise/client/recipient_accounts.go b/cmd/connectors/internal/connectors/wise/client/recipient_accounts.go deleted file mode 100644 index 83930a71..00000000 --- a/cmd/connectors/internal/connectors/wise/client/recipient_accounts.go +++ /dev/null @@ -1,132 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" -) - -type RecipientAccountsResponse struct { - Content []*RecipientAccount `json:"content"` - SeekPositionForCurrent uint64 `json:"seekPositionForCurrent"` - SeekPositionForNext uint64 `json:"seekPositionForNext"` - Size int `json:"size"` -} - -type RecipientAccount struct { - ID uint64 `json:"id"` - Profile uint64 `json:"profileId"` - Currency string `json:"currency"` - Name struct { - FullName string `json:"fullName"` - } `json:"name"` -} - -func (w *Client) GetRecipientAccounts(ctx context.Context, profileID uint64, pageSize int, seekPositionForNext uint64) (*RecipientAccountsResponse, error) { - f := connectors.ClientMetrics(ctx, "wise", "list_recipient_accounts") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v2/accounts"), http.NoBody) - if err != nil { - return nil, err - } - - q := req.URL.Query() - q.Add("profile", fmt.Sprintf("%d", profileID)) - q.Add("size", fmt.Sprintf("%d", pageSize)) - q.Add("sort", "id,asc") - if seekPositionForNext > 0 { - q.Add("seekPosition", fmt.Sprintf("%d", seekPositionForNext)) - } - req.URL.RawQuery = q.Encode() - - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var recipientAccounts *RecipientAccountsResponse - err = json.Unmarshal(body, &recipientAccounts) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfers: %w", err) - } - - return recipientAccounts, nil -} - -func (w *Client) GetRecipientAccount(ctx context.Context, accountID uint64) (*RecipientAccount, error) { - f := connectors.ClientMetrics(ctx, "wise", "get_recipient_account") - now := time.Now() - defer f(ctx, now) - - if rc, ok := w.recipientAccountsCache.Get(accountID); ok { - return rc, nil - } - - req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint(fmt.Sprintf("v1/accounts/%d", accountID)), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - type errorResponse struct { - Errors []struct { - Code string `json:"code"` - Message string `json:"message"` - } - } - - var e errorResponse - err = json.NewDecoder(res.Body).Decode(&e) - if err != nil { - return nil, fmt.Errorf("failed to decode error response: %w", err) - } - - if len(e.Errors) == 0 { - return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } - - switch e.Errors[0].Code { - case "RECIPIENT_MISSING": - // This is a valid response, we just don't have the account amoungs - // our recipients. - return &RecipientAccount{}, nil - } - - return nil, fmt.Errorf("unexpected status code: %d with err: %v", res.StatusCode, e) - } - - var account RecipientAccount - err = json.NewDecoder(res.Body).Decode(&account) - if err != nil { - return nil, fmt.Errorf("failed to decode account: %w", err) - } - - w.recipientAccountsCache.Add(accountID, &account) - - return &account, nil -} diff --git a/cmd/connectors/internal/connectors/wise/client/transfers.go b/cmd/connectors/internal/connectors/wise/client/transfers.go deleted file mode 100644 index 0de78341..00000000 --- a/cmd/connectors/internal/connectors/wise/client/transfers.go +++ /dev/null @@ -1,264 +0,0 @@ -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" -) - -type Transfer struct { - ID uint64 `json:"id"` - Reference string `json:"reference"` - Status string `json:"status"` - SourceAccount uint64 `json:"sourceAccount"` - SourceCurrency string `json:"sourceCurrency"` - SourceValue json.Number `json:"sourceValue"` - TargetAccount uint64 `json:"targetAccount"` - TargetCurrency string `json:"targetCurrency"` - TargetValue json.Number `json:"targetValue"` - Business uint64 `json:"business"` - Created string `json:"created"` - //nolint:tagliatelle // allow for clients - CustomerTransactionID string `json:"customerTransactionId"` - Details struct { - Reference string `json:"reference"` - } `json:"details"` - Rate float64 `json:"rate"` - User uint64 `json:"user"` - - SourceBalanceID uint64 `json:"-"` - DestinationBalanceID uint64 `json:"-"` - - CreatedAt time.Time `json:"-"` -} - -func (t *Transfer) UnmarshalJSON(data []byte) error { - type Alias Transfer - - aux := &struct { - Created string `json:"created"` - *Alias - }{ - Alias: (*Alias)(t), - } - - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - - var err error - - t.CreatedAt, err = time.Parse("2006-01-02 15:04:05", aux.Created) - if err != nil { - return fmt.Errorf("failed to parse created time: %w", err) - } - - return nil -} - -func (w *Client) GetTransfers(ctx context.Context, profile *Profile) ([]Transfer, error) { - f := connectors.ClientMetrics(ctx, "wise", "list_transfers") - now := time.Now() - defer f(ctx, now) - - var transfers []Transfer - - limit := 10 - offset := 0 - - for { - req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v1/transfers"), http.NoBody) - if err != nil { - return transfers, err - } - - q := req.URL.Query() - q.Add("limit", fmt.Sprintf("%d", limit)) - q.Add("profile", fmt.Sprintf("%d", profile.ID)) - q.Add("offset", fmt.Sprintf("%d", offset)) - req.URL.RawQuery = q.Encode() - - res, err := w.httpClient.Do(req) - if err != nil { - return transfers, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var transferList []Transfer - - err = json.Unmarshal(body, &transferList) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfers: %w", err) - } - - for i, transfer := range transferList { - var sourceProfileID, targetProfileID uint64 - if transfer.SourceAccount != 0 { - recipientAccount, err := w.GetRecipientAccount(ctx, transfer.SourceAccount) - if err != nil { - return nil, fmt.Errorf("failed to get source profile id: %w", err) - } - - sourceProfileID = recipientAccount.Profile - } - - if transfer.TargetAccount != 0 { - recipientAccount, err := w.GetRecipientAccount(ctx, transfer.TargetAccount) - if err != nil { - return nil, fmt.Errorf("failed to get target profile id: %w", err) - } - - targetProfileID = recipientAccount.Profile - } - - // TODO(polo): fetching balances for each transfer is not efficient - // and can be quite long. We should consider caching balances, but - // at the same time we will develop a feature soon to get balances - // for every accounts, so caching is not a solution. - switch { - case sourceProfileID == 0 && targetProfileID == 0: - // Do nothing - case sourceProfileID == targetProfileID && sourceProfileID != 0: - // Same profile id for target and source - balances, err := w.GetBalances(ctx, sourceProfileID) - if err != nil { - return nil, fmt.Errorf("failed to get balances: %w", err) - } - for _, balance := range balances { - if balance.Currency == transfer.SourceCurrency { - transferList[i].SourceBalanceID = balance.ID - } - - if balance.Currency == transfer.TargetCurrency { - transferList[i].DestinationBalanceID = balance.ID - } - } - default: - if sourceProfileID != 0 { - balances, err := w.GetBalances(ctx, sourceProfileID) - if err != nil { - return nil, fmt.Errorf("failed to get balances: %w", err) - } - for _, balance := range balances { - if balance.Currency == transfer.SourceCurrency { - transferList[i].SourceBalanceID = balance.ID - } - } - } - - if targetProfileID != 0 { - balances, err := w.GetBalances(ctx, targetProfileID) - if err != nil { - return nil, fmt.Errorf("failed to get balances: %w", err) - } - for _, balance := range balances { - if balance.Currency == transfer.TargetCurrency { - transferList[i].DestinationBalanceID = balance.ID - } - } - } - - } - } - - transfers = append(transfers, transferList...) - - if len(transferList) < limit { - break - } - - offset += limit - } - - return transfers, nil -} - -func (w *Client) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) { - f := connectors.ClientMetrics(ctx, "wise", "get_transfer") - now := time.Now() - defer f(ctx, now) - - req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v1/transfers/"+transferID), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - - body, err := io.ReadAll(res.Body) - if err != nil { - res.Body.Close() - - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if err = res.Body.Close(); err != nil { - return nil, fmt.Errorf("failed to close response body: %w", err) - } - - var transfer Transfer - err = json.Unmarshal(body, &transfer) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfer: %w", err) - } - - return &transfer, nil -} - -func (w *Client) CreateTransfer(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Transfer, error) { - metrics.GetMetricsRegistry().ConnectorPSPCalls().Add(ctx, 1, metric.WithAttributes([]attribute.KeyValue{ - attribute.String("connector", "wise"), - attribute.String("operation", "initiate_transfer"), - }...)) - - req, err := json.Marshal(map[string]interface{}{ - "targetAccount": targetAccount, - "quoteUuid": quote.ID.String(), - "customerTransactionId": transactionID, - }) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Post(w.endpoint("v1/transfers"), "application/json", bytes.NewBuffer(req)) - if err != nil { - return nil, err - } - - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } - - var response Transfer - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("failed to get response from transfer: %w", err) - } - - return &response, nil -} diff --git a/cmd/connectors/internal/connectors/wise/config.go b/cmd/connectors/internal/connectors/wise/config.go deleted file mode 100644 index aacab625..00000000 --- a/cmd/connectors/internal/connectors/wise/config.go +++ /dev/null @@ -1,56 +0,0 @@ -package wise - -import ( - "encoding/json" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/configtemplate" -) - -const ( - defaultPollingPeriod = 2 * time.Minute - pageSize = 100 -) - -type Config struct { - Name string `json:"name" yaml:"name" bson:"name"` - APIKey string `json:"apiKey" yaml:"apiKey" bson:"apiKey"` - PollingPeriod connectors.Duration `json:"pollingPeriod" yaml:"pollingPeriod" bson:"pollingPeriod"` -} - -// String obfuscates sensitive fields and returns a string representation of the config. -// This is used for logging. -func (c Config) String() string { - return "apiKey=***" -} - -func (c Config) Validate() error { - if c.APIKey == "" { - return ErrMissingAPIKey - } - - if c.Name == "" { - return ErrMissingName - } - - return nil -} - -func (c Config) Marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c Config) ConnectorName() string { - return c.Name -} - -func (c Config) BuildTemplate() (string, configtemplate.Config) { - cfg := configtemplate.NewConfig() - - cfg.AddParameter("name", configtemplate.TypeString, name.String(), false) - cfg.AddParameter("apiKey", configtemplate.TypeString, "", true) - cfg.AddParameter("pollingPeriod", configtemplate.TypeDurationNs, defaultPollingPeriod.String(), false) - - return name.String(), cfg -} diff --git a/cmd/connectors/internal/connectors/wise/connector.go b/cmd/connectors/internal/connectors/wise/connector.go deleted file mode 100644 index db043da8..00000000 --- a/cmd/connectors/internal/connectors/wise/connector.go +++ /dev/null @@ -1,137 +0,0 @@ -package wise - -import ( - "context" - - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const name = models.ConnectorProviderWise - -var ( - mainTaskDescriptor = TaskDescriptor{ - Name: "Fetch profiles from client", - Key: taskNameFetchProfiles, - } -) - -type Connector struct { - logger logging.Logger - cfg Config -} - -func newConnector(logger logging.Logger, cfg Config) *Connector { - return &Connector{ - logger: logger.WithFields(map[string]any{ - "component": "connector", - }), - cfg: cfg, - } -} - -func (c *Connector) UpdateConfig(ctx task.ConnectorContext, config models.ConnectorConfigObject) error { - cfg, ok := config.(Config) - if !ok { - return connectors.ErrInvalidConfig - } - - restartTask := c.cfg.PollingPeriod.Duration != cfg.PollingPeriod.Duration - - c.cfg = cfg - - if restartTask { - descriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_STOP_AND_RESTART, - }) - } - - return nil -} - -func (c *Connector) Install(ctx task.ConnectorContext) error { - descriptor, err := models.EncodeTaskDescriptor(mainTaskDescriptor) - if err != nil { - return err - } - - return ctx.Scheduler().Schedule(ctx.Context(), descriptor, models.TaskSchedulerOptions{ - // We want to polling every c.cfg.PollingPeriod.Duration seconds the users - // and their transactions. - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: c.cfg.PollingPeriod.Duration, - // No need to restart this task, since the connector is not existing or - // was uninstalled previously, the task does not exists in the database - RestartOption: models.OPTIONS_RESTART_NEVER, - }) -} - -func (c *Connector) Uninstall(ctx context.Context) error { - return nil -} - -func (c *Connector) Resolve(descriptor models.TaskDescriptor) task.Task { - taskDescriptor, err := models.DecodeTaskDescriptor[TaskDescriptor](descriptor) - if err != nil { - panic(err) - } - - return c.resolveTasks()(taskDescriptor) -} - -func (c *Connector) SupportedCurrenciesAndDecimals() map[string]int { - return supportedCurrenciesWithDecimal -} - -func (c *Connector) InitiatePayment(ctx task.ConnectorContext, transfer *models.TransferInitiation) error { - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Initiate payment", - Key: taskNameInitiatePayment, - TransferID: transfer.ID.String(), - }) - if err != nil { - return err - } - - scheduleOption := models.OPTIONS_RUN_NOW_SYNC - scheduledAt := transfer.ScheduledAt - if !scheduledAt.IsZero() { - scheduleOption = models.OPTIONS_RUN_SCHEDULED_AT - } - - err = ctx.Scheduler().Schedule(ctx.Context(), taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: scheduleOption, - ScheduleAt: scheduledAt, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func (c *Connector) ReversePayment(ctx task.ConnectorContext, transferReversal *models.TransferReversal) error { - return connectors.ErrNotImplemented -} - -func (c *Connector) CreateExternalBankAccount(ctx task.ConnectorContext, bankAccount *models.BankAccount) error { - return connectors.ErrNotImplemented -} - -var _ connectors.Connector = &Connector{} diff --git a/cmd/connectors/internal/connectors/wise/currencies.go b/cmd/connectors/internal/connectors/wise/currencies.go deleted file mode 100644 index ad2f38fb..00000000 --- a/cmd/connectors/internal/connectors/wise/currencies.go +++ /dev/null @@ -1,33 +0,0 @@ -package wise - -import "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - -var ( - // c.f. https://wise.com/help/articles/2897238/which-currencies-can-i-add-keep-and-receive-in-my-wise-account - supportedCurrenciesWithDecimal = map[string]int{ - "AUD": currency.ISO4217Currencies["AUD"], // Australian dollar - "BGN": currency.ISO4217Currencies["BGN"], // Bulgarian lev - "BRL": currency.ISO4217Currencies["BRL"], // Brazilian real - "CAD": currency.ISO4217Currencies["CAD"], // Canadian dollar - "CNY": currency.ISO4217Currencies["CNY"], // Chinese yuan - "CHF": currency.ISO4217Currencies["CHF"], // Swiss franc - "CZK": currency.ISO4217Currencies["CZK"], // Czech koruna - "DKK": currency.ISO4217Currencies["DKK"], // Danish krone - "EUR": currency.ISO4217Currencies["EUR"], // Euro - "GBP": currency.ISO4217Currencies["GBP"], // Pound sterling - "IDR": currency.ISO4217Currencies["IDR"], // Indonesian rupiah - "JPY": currency.ISO4217Currencies["JPY"], // Japanese yen - "MYR": currency.ISO4217Currencies["MYR"], // Malaysian ringgit - "NOK": currency.ISO4217Currencies["NOK"], // Norwegian krone - "NZD": currency.ISO4217Currencies["NZD"], // New Zealand dollar - "PLN": currency.ISO4217Currencies["PLN"], // Polish złoty - "RON": currency.ISO4217Currencies["RON"], // Romanian leu - "SEK": currency.ISO4217Currencies["SEK"], // Swedish krona/kronor - "SGD": currency.ISO4217Currencies["SGD"], // Singapore dollar - "TRY": currency.ISO4217Currencies["TRY"], // Turkish lira - "USD": currency.ISO4217Currencies["USD"], // United States dollar - - // Unsupported currencies - // "HUF": currency.ISO4217Currencies["HUF"], // Hungarian forint - } -) diff --git a/cmd/connectors/internal/connectors/wise/errors.go b/cmd/connectors/internal/connectors/wise/errors.go deleted file mode 100644 index 894ef089..00000000 --- a/cmd/connectors/internal/connectors/wise/errors.go +++ /dev/null @@ -1,14 +0,0 @@ -package wise - -import "github.com/pkg/errors" - -var ( - // ErrMissingTask is returned when the task is missing. - ErrMissingTask = errors.New("task is not implemented") - - // ErrMissingAPIKey is returned when the api key is missing from config. - ErrMissingAPIKey = errors.New("missing apiKey from config") - - // ErrMissingName is returned when the name is missing from config. - ErrMissingName = errors.New("missing name from config") -) diff --git a/cmd/connectors/internal/connectors/wise/loader.go b/cmd/connectors/internal/connectors/wise/loader.go deleted file mode 100644 index 63cee78c..00000000 --- a/cmd/connectors/internal/connectors/wise/loader.go +++ /dev/null @@ -1,47 +0,0 @@ -package wise - -import ( - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/gorilla/mux" -) - -type Loader struct{} - -const allowedTasks = 50 - -func (l *Loader) AllowTasks() int { - return allowedTasks -} - -func (l *Loader) Name() models.ConnectorProvider { - return name -} - -func (l *Loader) Load(logger logging.Logger, config Config) connectors.Connector { - return newConnector(logger, config) -} - -func (l *Loader) ApplyDefaults(cfg Config) Config { - if cfg.PollingPeriod.Duration == 0 { - cfg.PollingPeriod.Duration = defaultPollingPeriod - } - - if cfg.Name == "" { - cfg.Name = name.String() - } - - return cfg -} - -func (l *Loader) Router(_ *storage.Storage) *mux.Router { - // Webhooks are not implemented yet - return nil -} - -// NewLoader creates a new loader. -func NewLoader() *Loader { - return &Loader{} -} diff --git a/cmd/connectors/internal/connectors/wise/task_fetch_profiles.go b/cmd/connectors/internal/connectors/wise/task_fetch_profiles.go deleted file mode 100644 index 72ba0a33..00000000 --- a/cmd/connectors/internal/connectors/wise/task_fetch_profiles.go +++ /dev/null @@ -1,177 +0,0 @@ -package wise - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strconv" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -func taskFetchProfiles(wiseClient *client.Client) task.Task { - return func( - ctx context.Context, - logger logging.Logger, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - ) error { - sp := trace.SpanFromContext(ctx) - sp.SetName("wise.taskFetchProfiles") - sp.SetAttributes( - attribute.String("connectorID", connectorID.String()), - ) - - if err := fetchProfiles(ctx, wiseClient, connectorID, ingester, scheduler); err != nil { - otel.RecordError(sp, err) - return err - } - - return nil - } -} - -func fetchProfiles( - ctx context.Context, - wiseClient *client.Client, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, -) error { - profiles, err := wiseClient.GetProfiles(ctx) - if err != nil { - return err - } - - var descriptors []models.TaskDescriptor - for _, profile := range profiles { - balances, err := wiseClient.GetBalances(ctx, profile.ID) - if err != nil { - return err - } - - if err := ingestAccountsBatch( - ctx, - connectorID, - ingester, - profile.ID, - balances, - ); err != nil { - return err - } - - transferDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch transfers from client by profile", - Key: taskNameFetchTransfers, - ProfileID: profile.ID, - }) - if err != nil { - return err - } - descriptors = append(descriptors, transferDescriptor) - - recipientAccountsDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch recipient accounts from client by profile", - Key: taskNameFetchRecipientAccounts, - ProfileID: profile.ID, - }) - if err != nil { - return err - } - descriptors = append(descriptors, recipientAccountsDescriptor) - } - - for _, descriptor := range descriptors { - err = scheduler.Schedule(ctx, descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - } - - return nil -} - -func ingestAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - profileID uint64, - balances []*client.Balance, -) error { - if len(balances) == 0 { - return nil - } - - accountsBatch := ingestion.AccountBatch{} - balancesBatch := ingestion.BalanceBatch{} - for _, balance := range balances { - raw, err := json.Marshal(balance) - if err != nil { - return err - } - - precision, ok := supportedCurrenciesWithDecimal[balance.Amount.Currency] - if !ok { - continue - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: fmt.Sprintf("%d", balance.ID), - ConnectorID: connectorID, - }, - CreatedAt: balance.CreationTime, - Reference: fmt.Sprintf("%d", balance.ID), - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Amount.Currency), - AccountName: balance.Name, - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "profile_id": strconv.FormatUint(profileID, 10), - }, - RawData: raw, - }) - - amount, err := currency.GetAmountWithPrecisionFromString(balance.Amount.Value.String(), precision) - if err != nil { - return err - } - - now := time.Now() - balancesBatch = append(balancesBatch, &models.Balance{ - AccountID: models.AccountID{ - Reference: fmt.Sprintf("%d", balance.ID), - ConnectorID: connectorID, - }, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Amount.Currency), - Balance: amount, - CreatedAt: now, - LastUpdatedAt: now, - ConnectorID: connectorID, - }) - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return err - } - - if err := ingester.IngestBalances(ctx, balancesBatch, false); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/wise/task_fetch_recipient_accounts.go b/cmd/connectors/internal/connectors/wise/task_fetch_recipient_accounts.go deleted file mode 100644 index f005d31b..00000000 --- a/cmd/connectors/internal/connectors/wise/task_fetch_recipient_accounts.go +++ /dev/null @@ -1,108 +0,0 @@ -package wise - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -type fetchRecipientAccountsState struct { - LastSeekPosition uint64 `json:"last_seek_position"` -} - -func taskFetchRecipientAccounts(wiseClient *client.Client, profileID uint64) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - resolver task.StateResolver, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "wise.taskFetchRecipientAccounts", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("profileID", strconv.FormatUint(profileID, 10)), - ) - defer span.End() - - state := task.MustResolveTo(ctx, resolver, fetchRecipientAccountsState{}) - - for { - recipientAccounts, err := wiseClient.GetRecipientAccounts(ctx, profileID, pageSize, state.LastSeekPosition) - if err != nil { - // Retryable errors already handled by the function - otel.RecordError(span, err) - return err - } - - if err := ingestRecipientAccountsBatch(ctx, connectorID, ingester, recipientAccounts.Content); err != nil { - // Retryable errors already handled by the function - otel.RecordError(span, err) - return err - } - - if recipientAccounts.SeekPositionForNext == 0 { - // No more data to fetch - break - } - - state.LastSeekPosition = recipientAccounts.SeekPositionForNext - } - - if err := ingester.UpdateTaskState(ctx, state); err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} - -func ingestRecipientAccountsBatch( - ctx context.Context, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - accounts []*client.RecipientAccount, -) error { - accountsBatch := ingestion.AccountBatch{} - for _, account := range accounts { - raw, err := json.Marshal(account) - if err != nil { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - accountsBatch = append(accountsBatch, &models.Account{ - ID: models.AccountID{ - Reference: fmt.Sprintf("%d", account.ID), - ConnectorID: connectorID, - }, - CreatedAt: time.Now(), - Reference: fmt.Sprintf("%d", account.ID), - ConnectorID: connectorID, - DefaultAsset: currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency), - AccountName: account.Name.FullName, - Type: models.AccountTypeExternal, - RawData: raw, - }) - } - - if err := ingester.IngestAccounts(ctx, accountsBatch); err != nil { - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil -} diff --git a/cmd/connectors/internal/connectors/wise/task_fetch_transfers.go b/cmd/connectors/internal/connectors/wise/task_fetch_transfers.go deleted file mode 100644 index 2e454112..00000000 --- a/cmd/connectors/internal/connectors/wise/task_fetch_transfers.go +++ /dev/null @@ -1,146 +0,0 @@ -package wise - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "go.opentelemetry.io/otel/attribute" -) - -func taskFetchTransfers(wiseClient *client.Client, profileID uint64) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "wise.taskFetchTransfers", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("profileID", strconv.FormatUint(profileID, 10)), - ) - defer span.End() - - if err := fetchTransfers(ctx, wiseClient, profileID, connectorID, scheduler, ingester); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func fetchTransfers( - ctx context.Context, - wiseClient *client.Client, - profileID uint64, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ingester ingestion.Ingester, -) error { - transfers, err := wiseClient.GetTransfers(ctx, &client.Profile{ - ID: profileID, - }) - if err != nil { - return err - } - - if len(transfers) == 0 { - return nil - } - - var ( - // accountBatch ingestion.AccountBatch - paymentBatch ingestion.PaymentBatch - ) - - for _, transfer := range transfers { - - var rawData json.RawMessage - - rawData, err = json.Marshal(transfer) - if err != nil { - return fmt.Errorf("failed to marshal transfer: %w", err) - } - - precision, ok := supportedCurrenciesWithDecimal[transfer.TargetCurrency] - if !ok { - continue - } - - amount, err := currency.GetAmountWithPrecisionFromString(transfer.TargetValue.String(), precision) - if err != nil { - return err - } - - batchElement := ingestion.PaymentBatchElement{ - Payment: &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: fmt.Sprintf("%d", transfer.ID), - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - CreatedAt: transfer.CreatedAt, - Reference: fmt.Sprintf("%d", transfer.ID), - ConnectorID: connectorID, - Type: models.PaymentTypeTransfer, - Status: matchTransferStatus(transfer.Status), - Scheme: models.PaymentSchemeOther, - Amount: amount, - Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transfer.TargetCurrency), - RawData: rawData, - }, - } - - if transfer.SourceBalanceID != 0 { - batchElement.Payment.SourceAccountID = &models.AccountID{ - Reference: fmt.Sprintf("%d", transfer.SourceBalanceID), - ConnectorID: connectorID, - } - } - - if transfer.DestinationBalanceID != 0 { - batchElement.Payment.DestinationAccountID = &models.AccountID{ - Reference: fmt.Sprintf("%d", transfer.DestinationBalanceID), - ConnectorID: connectorID, - } - } - - paymentBatch = append(paymentBatch, batchElement) - } - - if err := ingester.IngestPayments(ctx, paymentBatch); err != nil { - return err - } - - return nil -} - -func matchTransferStatus(status string) models.PaymentStatus { - switch status { - case "incoming_payment_waiting", "incoming_payment_initiated", "processing": - return models.PaymentStatusPending - case "funds_converted", "outgoing_payment_sent": - return models.PaymentStatusSucceeded - case "bounced_back", "funds_refunded": - return models.PaymentStatusFailed - case "cancelled": - return models.PaymentStatusCancelled - } - - return models.PaymentStatusOther -} diff --git a/cmd/connectors/internal/connectors/wise/task_main.go b/cmd/connectors/internal/connectors/wise/task_main.go deleted file mode 100644 index ea7cc20f..00000000 --- a/cmd/connectors/internal/connectors/wise/task_main.go +++ /dev/null @@ -1,50 +0,0 @@ -package wise - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" -) - -// taskMain is the main task of the connector. It launches the other tasks. -func taskMain() task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - scheduler task.Scheduler, - ) error { - ctx, span := connectors.StartSpan( - ctx, - "wise.taskMain", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - ) - defer span.End() - - taskUsers, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Fetch users from client", - Key: taskNameFetchProfiles, - }) - if err != nil { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - err = scheduler.Schedule(ctx, taskUsers, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - otel.RecordError(span, err) - return errors.Wrap(task.ErrRetryable, err.Error()) - } - - return nil - } -} diff --git a/cmd/connectors/internal/connectors/wise/task_payments.go b/cmd/connectors/internal/connectors/wise/task_payments.go deleted file mode 100644 index e46b33cf..00000000 --- a/cmd/connectors/internal/connectors/wise/task_payments.go +++ /dev/null @@ -1,341 +0,0 @@ -package wise - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "time" - - "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" - - "github.com/formancehq/go-libs/contextutil" - "github.com/formancehq/payments/cmd/connectors/internal/connectors" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/currency" - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise/client" - "github.com/formancehq/payments/cmd/connectors/internal/ingestion" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/cmd/connectors/internal/task" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/internal/otel" -) - -func taskInitiatePayment( - wiseClient *client.Client, - transferID string, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "wise.taskInitiatePayment", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, true) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := initiatePayment(ctx, wiseClient, transfer, connectorID, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func initiatePayment( - ctx context.Context, - wiseClient *client.Client, - transfer *models.TransferInitiation, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var paymentID *models.PaymentID - defer func() { - if err != nil { - ctx, cancel := contextutil.Detached(ctx) - defer cancel() - _ = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, err.Error(), time.Now()) - } - }() - - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessing, "", time.Now()) - if err != nil { - return err - } - - if transfer.SourceAccount == nil { - err = errors.New("missing source account") - return err - } - - if transfer.SourceAccount.Type == models.AccountTypeExternal { - err = errors.New("payin not implemented: source account must be an internal account") - return err - } - - profileID, ok := transfer.SourceAccount.Metadata["profile_id"] - if !ok || profileID == "" { - err = errors.New("missing user_id in source account metadata") - return err - } - - var curr string - var precision int - curr, precision, err = currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, transfer.Asset) - if err != nil { - return err - } - - amount, err := currency.GetStringAmountFromBigIntWithPrecision(transfer.Amount, precision) - if err != nil { - return err - } - - quote, err := wiseClient.CreateQuote(ctx, profileID, curr, json.Number(amount)) - if err != nil { - return err - } - - var connectorPaymentID uint64 - var paymentType models.PaymentType - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - switch transfer.DestinationAccount.Type { - case models.AccountTypeInternal: - // Transfer between internal accounts - destinationAccount, err := strconv.ParseUint(transfer.DestinationAccount.Metadata["profile_id"], 10, 64) - if err != nil { - return err - } - - var resp *client.Transfer - resp, err = wiseClient.CreateTransfer(ctx, quote, destinationAccount, fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments))) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypeTransfer - case models.AccountTypeExternal: - // Payout to an external account - - destinationAccount, err := strconv.ParseUint(transfer.DestinationAccount.Reference, 10, 64) - if err != nil { - return err - } - - var resp *client.Payout - resp, err = wiseClient.CreatePayout(ctx, quote, destinationAccount, fmt.Sprintf("%s_%d", transfer.ID.Reference, len(transfer.RelatedAdjustments))) - if err != nil { - return err - } - - connectorPaymentID = resp.ID - paymentType = models.PaymentTypePayOut - } - - paymentID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: strconv.FormatUint(connectorPaymentID, 10), - Type: paymentType, - }, - ConnectorID: connectorID, - } - err = ingester.AddTransferInitiationPaymentID(ctx, transfer, paymentID, time.Now()) - if err != nil { - return err - } - - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: 1, - }) - if err != nil { - return err - } - - ctx, _ = contextutil.DetachedWithTimeout(ctx, 10*time.Second) - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - - return nil -} - -func taskUpdatePaymentStatus( - wiseClient *client.Client, - transferID string, - pID string, - attempt int, -) task.Task { - return func( - ctx context.Context, - taskID models.TaskID, - connectorID models.ConnectorID, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, - ) error { - paymentID := models.MustPaymentIDFromString(pID) - transferInitiationID := models.MustTransferInitiationIDFromString(transferID) - - ctx, span := connectors.StartSpan( - ctx, - "wise.taskUpdatePaymentStatus", - attribute.String("connectorID", connectorID.String()), - attribute.String("taskID", taskID.String()), - attribute.String("transferID", transferID), - attribute.String("paymentID", pID), - attribute.Int("attempt", attempt), - attribute.String("reference", transferInitiationID.Reference), - ) - defer span.End() - - transfer, err := getTransfer(ctx, storageReader, transferInitiationID, false) - if err != nil { - otel.RecordError(span, err) - return err - } - - if err := updatePaymentStatus(ctx, wiseClient, transfer, paymentID, attempt, ingester, scheduler, storageReader); err != nil { - otel.RecordError(span, err) - return err - } - - return nil - } -} - -func updatePaymentStatus( - ctx context.Context, - wiseClient *client.Client, - transfer *models.TransferInitiation, - paymentID *models.PaymentID, - attempt int, - ingester ingestion.Ingester, - scheduler task.Scheduler, - storageReader storage.Reader, -) error { - var err error - var status string - switch transfer.Type { - case models.TransferInitiationTypeTransfer: - var resp *client.Transfer - resp, err = wiseClient.GetTransfer(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - case models.TransferInitiationTypePayout: - var resp *client.Payout - resp, err = wiseClient.GetPayout(ctx, paymentID.Reference) - if err != nil { - return err - } - - status = resp.Status - } - - switch status { - case "incoming_payment_waiting", - "incoming_payment_initiated", - "processing", - "funds_converted", - "bounced_back", - "unknown": - taskDescriptor, err := models.EncodeTaskDescriptor(TaskDescriptor{ - Name: "Update transfer initiation status", - Key: taskNameUpdatePaymentStatus, - TransferID: transfer.ID.String(), - PaymentID: paymentID.String(), - Attempt: attempt + 1, - }) - if err != nil { - return err - } - - err = scheduler.Schedule(ctx, taskDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_IN_DURATION, - Duration: 2 * time.Minute, - RestartOption: models.OPTIONS_RESTART_IF_NOT_ACTIVE, - }) - if err != nil && !errors.Is(err, task.ErrAlreadyScheduled) { - return err - } - case "outgoing_payment_sent", "funds_refunded": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusProcessed, "", time.Now()) - if err != nil { - return err - } - - return nil - case "charged_back", "cancelled": - err = ingester.UpdateTransferInitiationPaymentsStatus(ctx, transfer, paymentID, models.TransferInitiationStatusFailed, "", time.Now()) - if err != nil { - return err - } - - return nil - } - - return nil -} - -func getTransfer( - ctx context.Context, - reader storage.Reader, - transferID models.TransferInitiationID, - expand bool, -) (*models.TransferInitiation, error) { - transfer, err := reader.ReadTransferInitiation(ctx, transferID) - if err != nil { - return nil, err - } - - if expand { - if transfer.SourceAccountID != nil { - sourceAccount, err := reader.GetAccount(ctx, transfer.SourceAccountID.String()) - if err != nil { - return nil, err - } - transfer.SourceAccount = sourceAccount - } - - destinationAccount, err := reader.GetAccount(ctx, transfer.DestinationAccountID.String()) - if err != nil { - return nil, err - } - transfer.DestinationAccount = destinationAccount - } - - return transfer, nil -} diff --git a/cmd/connectors/internal/connectors/wise/task_resolve.go b/cmd/connectors/internal/connectors/wise/task_resolve.go deleted file mode 100644 index 6e07953a..00000000 --- a/cmd/connectors/internal/connectors/wise/task_resolve.go +++ /dev/null @@ -1,53 +0,0 @@ -package wise - -import ( - "fmt" - - "github.com/formancehq/payments/cmd/connectors/internal/connectors/wise/client" - "github.com/formancehq/payments/cmd/connectors/internal/task" -) - -const ( - taskNameMain = "main" - taskNameFetchTransfers = "fetch-transfers" - taskNameFetchProfiles = "fetch-profiles" - taskNameFetchRecipientAccounts = "fetch-recipient-accounts" - taskNameInitiatePayment = "initiate-payment" - taskNameUpdatePaymentStatus = "update-payment-status" -) - -// TaskDescriptor is the definition of a task. -type TaskDescriptor struct { - Name string `json:"name" yaml:"name" bson:"name"` - Key string `json:"key" yaml:"key" bson:"key"` - ProfileID uint64 `json:"profileID" yaml:"profileID" bson:"profileID"` - TransferID string `json:"transferID" yaml:"transferID" bson:"transferID"` - PaymentID string `json:"paymentID" yaml:"paymentID" bson:"paymentID"` - Attempt int `json:"attempt" yaml:"attempt" bson:"attempt"` -} - -func (c *Connector) resolveTasks() func(taskDefinition TaskDescriptor) task.Task { - client := client.NewClient(c.cfg.APIKey) - - return func(taskDefinition TaskDescriptor) task.Task { - switch taskDefinition.Key { - case taskNameMain: - return taskMain() - case taskNameFetchProfiles: - return taskFetchProfiles(client) - case taskNameFetchRecipientAccounts: - return taskFetchRecipientAccounts(client, taskDefinition.ProfileID) - case taskNameFetchTransfers: - return taskFetchTransfers(client, taskDefinition.ProfileID) - case taskNameInitiatePayment: - return taskInitiatePayment(client, taskDefinition.TransferID) - case taskNameUpdatePaymentStatus: - return taskUpdatePaymentStatus(client, taskDefinition.TransferID, taskDefinition.PaymentID, taskDefinition.Attempt) - } - - // This should never happen. - return func() error { - return fmt.Errorf("key '%s': %w", taskDefinition.Key, ErrMissingTask) - } - } -} diff --git a/cmd/connectors/internal/ingestion/accounts.go b/cmd/connectors/internal/ingestion/accounts.go deleted file mode 100644 index 05b7f379..00000000 --- a/cmd/connectors/internal/ingestion/accounts.go +++ /dev/null @@ -1,67 +0,0 @@ -package ingestion - -import ( - "context" - "fmt" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type AccountBatch []*models.Account - -type AccountIngesterFn func(ctx context.Context, batch AccountBatch, commitState any) error - -func (fn AccountIngesterFn) IngestAccounts(ctx context.Context, batch AccountBatch, commitState any) error { - return fn(ctx, batch, commitState) -} - -func (i *DefaultIngester) IngestAccounts(ctx context.Context, batch AccountBatch) error { - startingAt := time.Now() - - logging.FromContext(ctx).WithFields(map[string]interface{}{ - "size": len(batch), - "startingAt": startingAt, - }).Debugf("Ingest accounts batch") - - idsInserted, err := i.store.UpsertAccounts(ctx, batch) - if err != nil { - return fmt.Errorf("error upserting accounts: %w", err) - } - - idsInsertedMap := make(map[string]struct{}, len(idsInserted)) - for idx := range idsInserted { - idsInsertedMap[idsInserted[idx].String()] = struct{}{} - } - - for accountIdx := range batch { - _, ok := idsInsertedMap[batch[accountIdx].ID.String()] - if !ok { - // No need to publish an event for an already existing payment - continue - } - - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedAccounts(i.provider, batch[accountIdx]), - ), - ); err != nil { - logging.FromContext(ctx).Errorf("Publishing message: %w", err) - } - } - - endedAt := time.Now() - - logging.FromContext(ctx).WithFields(map[string]interface{}{ - "size": len(batch), - "endedAt": endedAt, - "latency": endedAt.Sub(startingAt).String(), - }).Debugf("Accounts batch ingested") - - return nil -} diff --git a/cmd/connectors/internal/ingestion/balances.go b/cmd/connectors/internal/ingestion/balances.go deleted file mode 100644 index 334a99ae..00000000 --- a/cmd/connectors/internal/ingestion/balances.go +++ /dev/null @@ -1,55 +0,0 @@ -package ingestion - -import ( - "context" - "fmt" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type BalanceBatch []*models.Balance - -type BalanceIngesterFn func(ctx context.Context, batch BalanceBatch) error - -func (fn BalanceIngesterFn) IngestBalances(ctx context.Context, batch BalanceBatch) error { - return fn(ctx, batch) -} - -func (i *DefaultIngester) IngestBalances(ctx context.Context, batch BalanceBatch, checkIfAccountExists bool) error { - startingAt := time.Now() - - logging.FromContext(ctx).WithFields(map[string]interface{}{ - "size": len(batch), - "startingAt": startingAt, - }).Debugf("Ingest balances batch") - - if err := i.store.InsertBalances(ctx, batch, checkIfAccountExists); err != nil { - return fmt.Errorf("error inserting balances: %w", err) - } - - for _, balance := range batch { - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedBalances(balance), - ), - ); err != nil { - logging.FromContext(ctx).Errorf("Publishing message: %w", err) - } - } - - endedAt := time.Now() - - logging.FromContext(ctx).WithFields(map[string]interface{}{ - "size": len(batch), - "endedAt": endedAt, - "latency": endedAt.Sub(startingAt).String(), - }).Debugf("Accounts batch ingested") - - return nil -} diff --git a/cmd/connectors/internal/ingestion/bank_account.go b/cmd/connectors/internal/ingestion/bank_account.go deleted file mode 100644 index c9a9e7b2..00000000 --- a/cmd/connectors/internal/ingestion/bank_account.go +++ /dev/null @@ -1,39 +0,0 @@ -package ingestion - -import ( - "context" - "time" - - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" -) - -func (i *DefaultIngester) LinkBankAccountWithAccount(ctx context.Context, bankAccount *models.BankAccount, accountID *models.AccountID) error { - adjustment := &models.BankAccountRelatedAccount{ - ID: uuid.New(), - CreatedAt: time.Now().UTC(), - BankAccountID: bankAccount.ID, - ConnectorID: accountID.ConnectorID, - AccountID: *accountID, - } - - if err := i.store.AddBankAccountRelatedAccount(ctx, adjustment); err != nil { - return err - } - - bankAccount.RelatedAccounts = append(bankAccount.RelatedAccounts, adjustment) - - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedBankAccounts(bankAccount), - ), - ); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/ingestion/ingester.go b/cmd/connectors/internal/ingestion/ingester.go deleted file mode 100644 index 0c54c7d5..00000000 --- a/cmd/connectors/internal/ingestion/ingester.go +++ /dev/null @@ -1,65 +0,0 @@ -package ingestion - -import ( - "context" - "encoding/json" - "time" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" -) - -type Ingester interface { - IngestAccounts(ctx context.Context, batch AccountBatch) error - IngestPayments(ctx context.Context, batch PaymentBatch) error - IngestBalances(ctx context.Context, batch BalanceBatch, checkIfAccountExists bool) error - UpdateTaskState(ctx context.Context, state any) error - UpdateTransferInitiationPayment(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, status models.TransferInitiationStatus, errorMessage string, updatedAt time.Time) error - UpdateTransferInitiationPaymentsStatus(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, status models.TransferInitiationStatus, errorMessage string, updatedAt time.Time) error - UpdateTransferReversalStatus(ctx context.Context, transfer *models.TransferInitiation, transferReversal *models.TransferReversal) error - AddTransferInitiationPaymentID(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, updatedAt time.Time) error - LinkBankAccountWithAccount(ctx context.Context, bankAccount *models.BankAccount, accountID *models.AccountID) error -} - -type DefaultIngester struct { - provider models.ConnectorProvider - connectorID models.ConnectorID - store Store - descriptor models.TaskDescriptor - publisher message.Publisher - messages *messages.Messages -} - -type Store interface { - UpsertAccounts(ctx context.Context, accounts []*models.Account) ([]models.AccountID, error) - UpsertPayments(ctx context.Context, payments []*models.Payment) ([]*models.PaymentID, error) - UpsertPaymentsAdjustments(ctx context.Context, paymentsAdjustment []*models.PaymentAdjustment) error - UpsertPaymentsMetadata(ctx context.Context, paymentsMetadata []*models.PaymentMetadata) error - InsertBalances(ctx context.Context, balances []*models.Balance, checkIfAccountExists bool) error - UpdateTaskState(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, state json.RawMessage) error - UpdateTransferInitiationPaymentsStatus(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, adjustment *models.TransferInitiationAdjustment) error - UpdateTransferReversalStatus(ctx context.Context, transfer *models.TransferInitiation, transferReversal *models.TransferReversal, adjustment *models.TransferInitiationAdjustment) error - AddTransferInitiationPaymentID(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, updatedAt time.Time, metadata map[string]string) error - AddBankAccountRelatedAccount(ctx context.Context, adjustment *models.BankAccountRelatedAccount) error -} - -func NewDefaultIngester( - provider models.ConnectorProvider, - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, - repo Store, - publisher message.Publisher, - messages *messages.Messages, -) *DefaultIngester { - return &DefaultIngester{ - provider: provider, - connectorID: connectorID, - descriptor: descriptor, - store: repo, - publisher: publisher, - messages: messages, - } -} - -var _ Ingester = (*DefaultIngester)(nil) diff --git a/cmd/connectors/internal/ingestion/ingester_test.go b/cmd/connectors/internal/ingestion/ingester_test.go deleted file mode 100644 index 9c1f1895..00000000 --- a/cmd/connectors/internal/ingestion/ingester_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package ingestion - -import ( - "context" - "encoding/json" - "time" - - "github.com/ThreeDotsLabs/watermill/message" - "github.com/formancehq/payments/internal/models" -) - -type MockStore struct { - paymentIDsNotModified map[string]struct{} -} - -func NewMockStore() *MockStore { - return &MockStore{ - paymentIDsNotModified: make(map[string]struct{}), - } -} - -func (m *MockStore) WithPaymentIDsNotModified(paymentsIDs []models.PaymentID) *MockStore { - for _, id := range paymentsIDs { - m.paymentIDsNotModified[id.String()] = struct{}{} - } - return m -} - -func (m *MockStore) UpsertAccounts(ctx context.Context, accounts []*models.Account) ([]models.AccountID, error) { - return nil, nil -} - -func (m *MockStore) UpsertPayments(ctx context.Context, payments []*models.Payment) ([]*models.PaymentID, error) { - ids := make([]*models.PaymentID, 0, len(payments)) - for _, payment := range payments { - if _, ok := m.paymentIDsNotModified[payment.ID.String()]; !ok { - ids = append(ids, &payment.ID) - } - } - - return ids, nil -} - -func (m *MockStore) UpsertPaymentsAdjustments(ctx context.Context, adjustments []*models.PaymentAdjustment) error { - return nil -} - -func (m *MockStore) UpsertPaymentsMetadata(ctx context.Context, metadata []*models.PaymentMetadata) error { - return nil -} - -func (m *MockStore) InsertBalances(ctx context.Context, balances []*models.Balance, checkIfAccountExists bool) error { - return nil -} - -func (m *MockStore) UpdateTaskState(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, state json.RawMessage) error { - return nil -} - -func (m *MockStore) UpdateTransferInitiationPaymentsStatus(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, adjustment *models.TransferInitiationAdjustment) error { - return nil -} - -func (m *MockStore) UpdateTransferReversalStatus(ctx context.Context, transfer *models.TransferInitiation, transferReversal *models.TransferReversal, adjustment *models.TransferInitiationAdjustment) error { - return nil -} - -func (m *MockStore) AddTransferInitiationPaymentID(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, updatedAt time.Time, metadata map[string]string) error { - return nil -} - -func (m *MockStore) AddBankAccountRelatedAccount(ctx context.Context, adjustment *models.BankAccountRelatedAccount) error { - return nil -} - -type MockPublisher struct { - messages chan *message.Message -} - -func NewMockPublisher() *MockPublisher { - return &MockPublisher{ - messages: make(chan *message.Message, 100), - } -} - -func (m *MockPublisher) Publish(topic string, messages ...*message.Message) error { - for _, msg := range messages { - m.messages <- msg - } - - return nil -} - -func (m *MockPublisher) Close() error { - close(m.messages) - return nil -} diff --git a/cmd/connectors/internal/ingestion/payments.go b/cmd/connectors/internal/ingestion/payments.go deleted file mode 100644 index 856bafef..00000000 --- a/cmd/connectors/internal/ingestion/payments.go +++ /dev/null @@ -1,110 +0,0 @@ -package ingestion - -import ( - "context" - "fmt" - "time" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type PaymentBatchElement struct { - Payment *models.Payment - Adjustment *models.PaymentAdjustment -} - -type PaymentBatch []PaymentBatchElement - -type IngesterFn func(ctx context.Context, batch PaymentBatch, commitState any) error - -func (fn IngesterFn) IngestPayments(ctx context.Context, batch PaymentBatch, commitState any) error { - return fn(ctx, batch, commitState) -} - -func (i *DefaultIngester) IngestPayments( - ctx context.Context, - batch PaymentBatch, -) error { - startingAt := time.Now() - - logging.FromContext(ctx).WithFields(map[string]interface{}{ - "size": len(batch), - "startingAt": startingAt, - }).Debugf("Ingest batch") - - var allPayments []*models.Payment //nolint:prealloc // length is unknown - var allMetadata []*models.PaymentMetadata - var allAdjustments []*models.PaymentAdjustment - - for batchIdx := range batch { - payment := batch[batchIdx].Payment - adjustment := batch[batchIdx].Adjustment - - if payment != nil { - allPayments = append(allPayments, payment) - - for _, data := range payment.Metadata { - data.Changelog = append(data.Changelog, - models.MetadataChangelog{ - CreatedAt: time.Now(), - Value: data.Value, - }) - - allMetadata = append(allMetadata, data) - } - } - - if adjustment != nil && adjustment.Reference != "" { - allAdjustments = append(allAdjustments, adjustment) - } - } - - // Insert first all payments - idsInserted, err := i.store.UpsertPayments(ctx, allPayments) - if err != nil { - return fmt.Errorf("error upserting payments: %w", err) - } - - // Then insert all metadata - if err := i.store.UpsertPaymentsMetadata(ctx, allMetadata); err != nil { - return fmt.Errorf("error upserting payments metadata: %w", err) - } - - // Then insert all adjustments - if err := i.store.UpsertPaymentsAdjustments(ctx, allAdjustments); err != nil { - return fmt.Errorf("error upserting payments adjustments: %w", err) - } - - idsInsertedMap := make(map[string]struct{}, len(idsInserted)) - for idx := range idsInserted { - idsInsertedMap[idsInserted[idx].String()] = struct{}{} - } - - for paymentIdx := range allPayments { - _, ok := idsInsertedMap[allPayments[paymentIdx].ID.String()] - if !ok { - // No need to publish an event for an already existing payment - continue - } - err = i.publisher.Publish(events.TopicPayments, - publish.NewMessage(ctx, i.messages.NewEventSavedPayments(i.provider, allPayments[paymentIdx]))) - if err != nil { - logging.FromContext(ctx).Errorf("Publishing message: %w", err) - - continue - } - } - - endedAt := time.Now() - - logging.FromContext(ctx).WithFields(map[string]interface{}{ - "size": len(batch), - "endedAt": endedAt, - "latency": endedAt.Sub(startingAt).String(), - }).Debugf("Batch ingested") - - return nil -} diff --git a/cmd/connectors/internal/ingestion/payments_test.go b/cmd/connectors/internal/ingestion/payments_test.go deleted file mode 100644 index 73d47dd0..00000000 --- a/cmd/connectors/internal/ingestion/payments_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package ingestion - -import ( - "context" - "encoding/json" - "math/big" - "testing" - "time" - - "github.com/formancehq/payments/internal/messages" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -var ( - connectorID = models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - - acc1 = models.AccountID{ - Reference: "acc1", - ConnectorID: connectorID, - } - acc2 = models.AccountID{ - Reference: "acc2", - ConnectorID: connectorID, - } - - p1 = &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p1", - Type: models.PaymentTypePayIn, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 4, 55, 0, 0, time.UTC), - Reference: "p1", - Amount: big.NewInt(100), - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusCancelled, - Scheme: models.PaymentSchemeA2A, - Asset: models.Asset("USD/2"), - SourceAccountID: &acc1, - } - - p2 = &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p2", - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 4, 54, 0, 0, time.UTC), - Reference: "p2", - Amount: big.NewInt(150), - Type: models.PaymentTypeTransfer, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeApplePay, - Asset: models.Asset("EUR/2"), - DestinationAccountID: &acc2, - } - - p3 = &models.Payment{ - ID: models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "p3", - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - }, - ConnectorID: connectorID, - CreatedAt: time.Date(2023, 11, 14, 4, 53, 0, 0, time.UTC), - Reference: "p3", - Amount: big.NewInt(200), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusPending, - Scheme: models.PaymentSchemeCardMasterCard, - Asset: models.Asset("USD/2"), - SourceAccountID: &acc1, - DestinationAccountID: &acc2, - } -) - -type linkPayload struct { - Name string `json:"name"` - URI string `json:"uri"` -} -type paymentMessagePayload struct { - Payload struct { - ID string `json:"id"` - Links []linkPayload `json:"links"` - } `json:"payload"` -} - -func TestIngestPayments(t *testing.T) { - t.Parallel() - - type testCase struct { - name string - batch PaymentBatch - paymentIDsNotModified []models.PaymentID - requiredPublishedPaymentIDs []models.PaymentID - } - - testCases := []testCase{ - { - name: "nominal", - batch: PaymentBatch{ - { - Payment: p1, - }, - { - Payment: p2, - }, - { - Payment: p3, - }, - }, - paymentIDsNotModified: []models.PaymentID{}, - requiredPublishedPaymentIDs: []models.PaymentID{p1.ID, p2.ID, p3.ID}, - }, - { - name: "only one payment upserted, should publish only one message", - batch: PaymentBatch{ - { - Payment: p1, - }, - { - Payment: p2, - }, - { - Payment: p3, - }, - }, - paymentIDsNotModified: []models.PaymentID{p1.ID, p2.ID}, - requiredPublishedPaymentIDs: []models.PaymentID{p3.ID}, - }, - { - name: "all payments are not modified, should not publish any message", - batch: PaymentBatch{ - { - Payment: p1, - }, - { - Payment: p2, - }, - { - Payment: p3, - }, - }, - paymentIDsNotModified: []models.PaymentID{p1.ID, p2.ID, p3.ID}, - requiredPublishedPaymentIDs: []models.PaymentID{}, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - publisher := NewMockPublisher() - - ingester := NewDefaultIngester( - models.ConnectorProviderDummyPay, - connectorID, - nil, - NewMockStore().WithPaymentIDsNotModified(tc.paymentIDsNotModified), - publisher, - messages.NewMessages(""), - ) - - err := ingester.IngestPayments(context.Background(), tc.batch) - publisher.Close() - require.NoError(t, err) - - require.Len(t, publisher.messages, len(tc.requiredPublishedPaymentIDs)) - i := 0 - for msg := range publisher.messages { - var payload paymentMessagePayload - require.NoError(t, json.Unmarshal(msg.Payload, &payload)) - require.Equal(t, tc.requiredPublishedPaymentIDs[i].String(), payload.Payload.ID) - - var expectedLinks []linkPayload - p := getPayment(tc.requiredPublishedPaymentIDs[i]) - if p == nil { - continue - } - if p.SourceAccountID != nil { - expectedLinks = append(expectedLinks, linkPayload{ - Name: "source_account", - URI: "/api/payments/accounts/" + p.SourceAccountID.String(), - }) - } - if p.DestinationAccountID != nil { - expectedLinks = append(expectedLinks, linkPayload{ - Name: "destination_account", - URI: "/api/payments/accounts/" + p.DestinationAccountID.String(), - }) - } - require.Equal(t, expectedLinks, payload.Payload.Links) - - i++ - } - }) - } -} - -func getPayment(id models.PaymentID) *models.Payment { - switch id { - case p1.ID: - return p1 - case p2.ID: - return p2 - case p3.ID: - return p3 - default: - return nil - } -} diff --git a/cmd/connectors/internal/ingestion/task.go b/cmd/connectors/internal/ingestion/task.go deleted file mode 100644 index b8588071..00000000 --- a/cmd/connectors/internal/ingestion/task.go +++ /dev/null @@ -1,20 +0,0 @@ -package ingestion - -import ( - "context" - "encoding/json" - "fmt" -) - -func (i *DefaultIngester) UpdateTaskState(ctx context.Context, state any) error { - taskState, err := json.Marshal(state) - if err != nil { - return fmt.Errorf("error marshaling task state: %w", err) - } - - if err = i.store.UpdateTaskState(ctx, i.connectorID, i.descriptor, taskState); err != nil { - return fmt.Errorf("error updating task state: %w", err) - } - - return nil -} diff --git a/cmd/connectors/internal/ingestion/test.json b/cmd/connectors/internal/ingestion/test.json deleted file mode 100644 index 2c806bb7..00000000 --- a/cmd/connectors/internal/ingestion/test.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"PAYMENT":[{"amount":300,"asset":"EUR/2","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-10-31T15:01:10Z","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwNzYyMTg3OSIsIlR5cGUiOiJUUkFOU0ZFUiJ9","initialAmount":300,"provider":"MANGOPAY","reference":"207621879","scheme":"other","status":"SUCCEEDED","type":"TRANSFER"},{"amount":300,"asset":"EUR/2","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-10-12T15:15:00Z","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwNjEzODczOSIsIlR5cGUiOiJUUkFOU0ZFUiJ9","initialAmount":300,"provider":"MANGOPAY","reference":"206138739","scheme":"other","status":"SUCCEEDED","type":"TRANSFER"},{"amount":300,"asset":"EUR/2","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-10-11T15:45:41Z","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwNjAzMTEyOSIsIlR5cGUiOiJUUkFOU0ZFUiJ9","initialAmount":300,"provider":"MANGOPAY","reference":"206031129","scheme":"other","status":"SUCCEEDED","type":"TRANSFER"},{"amount":300,"asset":"EUR/2","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-10-12T15:09:17Z","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwNjEzODIwOCIsIlR5cGUiOiJUUkFOU0ZFUiJ9","initialAmount":300,"provider":"MANGOPAY","reference":"206138208","scheme":"other","status":"SUCCEEDED","type":"TRANSFER"},{"amount":300,"asset":"EUR/2","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-10-03T13:58:43Z","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwNTE4Nzg0MSIsIlR5cGUiOiJUUkFOU0ZFUiJ9","initialAmount":300,"provider":"MANGOPAY","reference":"205187841","scheme":"other","status":"SUCCEEDED","type":"TRANSFER"}],"PAYMENT_ACCOUNT":[{"accountName":"Joe Blogs","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-08-14T09:26:07Z","defaultAsset":"","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwMDUxODA5NSJ9","provider":"MANGOPAY","reference":"200518095","type":"EXTERNAL"},{"accountName":"My big project transfer","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-08-28T09:58:34Z","defaultAsset":"EUR/2","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwMTY2MTE2NiJ9","provider":"MANGOPAY","reference":"201661166","type":"INTERNAL"},{"accountName":"My big project 2","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-07-25T11:48:44Z","defaultAsset":"USD/2","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjE5ODUzNjY4NSJ9","provider":"MANGOPAY","reference":"198536685","type":"INTERNAL"},{"accountName":"My big project","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2021-03-25T15:11:17Z","defaultAsset":"EUR/2","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjEwNDg1ODc3OCJ9","provider":"MANGOPAY","reference":"104858778","type":"INTERNAL"},{"accountName":"Joe Blogs","connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNzg0NmFkYTAtN2UyNy00MDFiLTg3MDMtOGJhNDZiYzYwYTQ2In0=","createdAt":"2023-08-14T09:26:07Z","defaultAsset":"","id":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNzg0NmFkYTAtN2UyNy00MDFiLTg3MDMtOGJhNDZiYzYwYTQ2In0sIlJlZmVyZW5jZSI6IjIwMDUxODA5NSJ9","reference":"200518095","type":"EXTERNAL"}],"PAYMENT_BALANCE":[{"accountID":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjIwMTY2MTE2NiJ9","asset":"EUR/2","balance":30004700,"connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-11-09T17:22:47.330609498Z"},{"accountID":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0sIlJlZmVyZW5jZSI6IjE5ODUzNjY4NSJ9","asset":"USD/2","balance":10058699,"connectorId":"eyJQcm92aWRlciI6Ik1BTkdPUEFZIiwiUmVmZXJlbmNlIjoiNjYwNjc1ODktMDkxZC00YTQxLTg1ZTctYzEyYTRhMWY5ZGY2In0=","createdAt":"2023-11-09T17:22:47.330566616Z"},{"accountID":"eyJDb25uZWN0b3JJRCI6eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6ImYxMmEzMTU4LTRjNzMtNDIzNS04MjY0LWIwMjUxZmExOTgxZSJ9LCJSZWZlcmVuY2UiOiJBMTIxNkdSMSJ9","asset":"GBP/2","balance":28200,"connectorId":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6ImYxMmEzMTU4LTRjNzMtNDIzNS04MjY0LWIwMjUxZmExOTgxZSJ9","createdAt":"2023-11-09T15:40:33.861824482Z"},[{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjE2RUcyIn0=","asset":"GBP/2","balance":3802050,"createdAt":"2023-11-08T12:50:52.121763165Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjE2R1IxIn0=","asset":"GBP/2","balance":28200,"createdAt":"2023-11-08T12:50:52.1217816Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5OU4ifQ==","asset":"GBP/2","balance":69950,"createdAt":"2023-11-08T12:50:52.121792602Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5OVEifQ==","asset":"GBP/2","balance":1099400,"createdAt":"2023-11-08T12:50:52.121805054Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5QjgifQ==","asset":"GBP/2","balance":0,"createdAt":"2023-11-08T12:50:52.12181514Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5QzIifQ==","asset":"GBP/2","balance":0,"createdAt":"2023-11-08T12:50:52.12182285Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5QzUifQ==","asset":"GBP/2","balance":0,"createdAt":"2023-11-08T12:50:52.121833676Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5QzYifQ==","asset":"GBP/2","balance":0,"createdAt":"2023-11-08T12:50:52.121843449Z"},{"accountID":"eyJQcm92aWRlciI6Ik1PRFVMUiIsIlJlZmVyZW5jZSI6IkExMjAwQkQ5QzcifQ==","asset":"GBP/2","balance":0,"createdAt":"2023-11-08T12:50:52.121854829Z"}]]}} \ No newline at end of file diff --git a/cmd/connectors/internal/ingestion/transfer_initiation.go b/cmd/connectors/internal/ingestion/transfer_initiation.go deleted file mode 100644 index 33fdf4db..00000000 --- a/cmd/connectors/internal/ingestion/transfer_initiation.go +++ /dev/null @@ -1,145 +0,0 @@ -package ingestion - -import ( - "context" - "fmt" - "time" - - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" -) - -// In some cases, we want to do the two udpates to the transfer initiations -// (update the payment status and add a related payment id) and send only one -// events for both of them. -func (i *DefaultIngester) UpdateTransferInitiationPayment( - ctx context.Context, - tf *models.TransferInitiation, - paymentID *models.PaymentID, - status models.TransferInitiationStatus, - errorMessage string, - updatedAt time.Time, -) error { - if err := i.addTransferInitiationPaymentID(ctx, tf, paymentID, updatedAt); err != nil { - return err - } - - if err := i.updateTransferInitiationPaymentStatus( - ctx, - tf, - paymentID, - status, - errorMessage, - updatedAt, - ); err != nil { - return err - } - - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedTransferInitiations(tf), - ), - ); err != nil { - return err - } - - return nil -} - -// Updates only the transfer initiation payment status -func (i *DefaultIngester) UpdateTransferInitiationPaymentsStatus(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, status models.TransferInitiationStatus, errorMessage string, updatedAt time.Time) error { - if err := i.updateTransferInitiationPaymentStatus( - ctx, - tf, - paymentID, - status, - errorMessage, - updatedAt, - ); err != nil { - return err - } - - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedTransferInitiations(tf), - ), - ); err != nil { - return err - } - - return nil -} - -// Only adds a related payment id to the transfer initiation -func (i *DefaultIngester) AddTransferInitiationPaymentID(ctx context.Context, tf *models.TransferInitiation, paymentID *models.PaymentID, updatedAt time.Time) error { - if err := i.addTransferInitiationPaymentID(ctx, tf, paymentID, updatedAt); err != nil { - return err - } - - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedTransferInitiations(tf), - ), - ); err != nil { - return err - } - - return nil -} - -func (i *DefaultIngester) updateTransferInitiationPaymentStatus( - ctx context.Context, - tf *models.TransferInitiation, - paymentID *models.PaymentID, - status models.TransferInitiationStatus, - errorMessage string, - updatedAt time.Time, -) error { - adjustment := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: tf.ID, - CreatedAt: updatedAt.UTC(), - Status: status, - Error: errorMessage, - } - - tf.RelatedAdjustments = append([]*models.TransferInitiationAdjustment{adjustment}, tf.RelatedAdjustments...) - - if err := i.store.UpdateTransferInitiationPaymentsStatus(ctx, tf.ID, paymentID, adjustment); err != nil { - return err - } - - return nil -} - -func (i *DefaultIngester) addTransferInitiationPaymentID( - ctx context.Context, - tf *models.TransferInitiation, - paymentID *models.PaymentID, - updatedAt time.Time, -) error { - if paymentID == nil { - return fmt.Errorf("payment id is nil") - } - - tf.RelatedPayments = append(tf.RelatedPayments, &models.TransferInitiationPayment{ - TransferInitiationID: tf.ID, - PaymentID: *paymentID, - CreatedAt: updatedAt.UTC(), - Status: models.TransferInitiationStatusProcessing, - }) - - if err := i.store.AddTransferInitiationPaymentID(ctx, tf.ID, paymentID, updatedAt, tf.Metadata); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/ingestion/transfer_reversal.go b/cmd/connectors/internal/ingestion/transfer_reversal.go deleted file mode 100644 index c5db2cec..00000000 --- a/cmd/connectors/internal/ingestion/transfer_reversal.go +++ /dev/null @@ -1,44 +0,0 @@ -package ingestion - -import ( - "context" - "math/big" - - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" -) - -func (i *DefaultIngester) UpdateTransferReversalStatus(ctx context.Context, tf *models.TransferInitiation, transferReversal *models.TransferReversal) error { - finalAmount := new(big.Int) - isFullyReversed := transferReversal.Status == models.TransferReversalStatusProcessed && - finalAmount.Sub(tf.Amount, transferReversal.Amount).Cmp(big.NewInt(0)) == 0 - - adjustment := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: transferReversal.TransferInitiationID, - CreatedAt: transferReversal.UpdatedAt.UTC(), - Status: transferReversal.Status.ToTransferInitiationStatus(isFullyReversed), - Error: transferReversal.Error, - Metadata: transferReversal.Metadata, - } - - if err := i.store.UpdateTransferReversalStatus(ctx, tf, transferReversal, adjustment); err != nil { - return err - } - - tf.RelatedAdjustments = append([]*models.TransferInitiationAdjustment{adjustment}, tf.RelatedAdjustments...) - - if err := i.publisher.Publish( - events.TopicPayments, - publish.NewMessage( - ctx, - i.messages.NewEventSavedTransferInitiations(tf), - ), - ); err != nil { - return err - } - - return nil -} diff --git a/cmd/connectors/internal/storage/accounts.go b/cmd/connectors/internal/storage/accounts.go deleted file mode 100644 index 15aa8c95..00000000 --- a/cmd/connectors/internal/storage/accounts.go +++ /dev/null @@ -1,85 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) UpsertAccounts(ctx context.Context, accounts []*models.Account) ([]models.AccountID, error) { - if len(accounts) == 0 { - return nil, nil - } - - var idsUpdated []string - err := s.db.NewUpdate(). - With("_data", - s.db.NewValues(&accounts). - Column( - "id", - "default_currency", - "account_name", - "metadata", - ), - ). - Model((*models.Account)(nil)). - TableExpr("_data"). - Set("default_currency = _data.default_currency"). - Set("account_name = _data.account_name"). - Set("metadata = _data.metadata"). - Where(`(account.id = _data.id) AND - (account.default_currency != _data.default_currency OR account.account_name != _data.account_name OR (account.metadata != _data.metadata))`). - Returning("account.id"). - Scan(ctx, &idsUpdated) - if err != nil { - return nil, e("failed to update accounts", err) - } - - idsUpdatedMap := make(map[string]struct{}) - for _, id := range idsUpdated { - idsUpdatedMap[id] = struct{}{} - } - - accountsToInsert := make([]*models.Account, 0, len(accounts)) - for _, account := range accounts { - if _, ok := idsUpdatedMap[account.ID.String()]; !ok { - accountsToInsert = append(accountsToInsert, account) - } - } - - var idsInserted []string - if len(accountsToInsert) > 0 { - err = s.db.NewInsert(). - Model(&accountsToInsert). - On("CONFLICT (id) DO NOTHING"). - Returning("account.id"). - Scan(ctx, &idsInserted) - if err != nil { - return nil, e("failed to create accounts", err) - } - } - - res := make([]models.AccountID, 0, len(idsUpdated)+len(idsInserted)) - for _, id := range idsUpdated { - res = append(res, models.MustAccountIDFromString(id)) - } - for _, id := range idsInserted { - res = append(res, models.MustAccountIDFromString(id)) - } - - return res, nil -} - -func (s *Storage) GetAccount(ctx context.Context, id string) (*models.Account, error) { - var account models.Account - - err := s.db.NewSelect(). - Model(&account). - Where("id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("failed to get account", err) - } - - return &account, nil -} diff --git a/cmd/connectors/internal/storage/accounts_test.go b/cmd/connectors/internal/storage/accounts_test.go deleted file mode 100644 index b31f15af..00000000 --- a/cmd/connectors/internal/storage/accounts_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package storage_test - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -var ( - acc1ID models.AccountID - acc1T = time.Date(2023, 11, 14, 4, 59, 0, 0, time.UTC) - - acc2ID models.AccountID - acc2T = time.Date(2023, 11, 14, 4, 58, 0, 0, time.UTC) - - acc3ID models.AccountID - acc3T = time.Date(2023, 11, 14, 4, 57, 0, 0, time.UTC) -) - -func TestAccounts(t *testing.T) { - store := newStore(t) - - testInstallConnectors(t, store) - testCreateAccounts(t, store) - testUpdateAccounts(t, store) - testUninstallConnectors(t, store) - testAccountsDeletedAfterConnectorUninstall(t, store) -} - -func testCreateAccounts(t *testing.T, store *storage.Storage) { - acc1ID = models.AccountID{ - Reference: "test1", - ConnectorID: connectorID, - } - acc2ID = models.AccountID{ - Reference: "test2", - ConnectorID: connectorID, - } - acc3ID = models.AccountID{ - Reference: "test3", - ConnectorID: connectorID, - } - - acc1 := &models.Account{ - ID: acc1ID, - CreatedAt: acc1T, - Reference: "test1", - ConnectorID: connectorID, - DefaultAsset: "USD", - AccountName: "test1", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo": "bar", - }, - } - - acc2 := &models.Account{ - ID: acc2ID, - CreatedAt: acc2T, - Reference: "test2", - ConnectorID: connectorID, - Type: models.AccountTypeExternal, - } - - acc3 := &models.Account{ - ID: acc3ID, - CreatedAt: acc3T, - Reference: "test3", - ConnectorID: connectorID, - Type: models.AccountTypeInternal, - } - - connectorIDFail := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - accFail := &models.Account{ - ID: models.AccountID{Reference: "test4", ConnectorID: connectorIDFail}, - CreatedAt: acc3T, - Reference: "test4", - ConnectorID: connectorIDFail, - Type: models.AccountTypeInternal, - } - - // Try to insert accounts from a not installed connector - _, err := store.UpsertAccounts( - context.Background(), - []*models.Account{accFail}, - ) - require.Error(t, err) - - idsInserted, err := store.UpsertAccounts( - context.Background(), - []*models.Account{acc1, acc2, acc3}, - ) - require.NoError(t, err) - require.Len(t, idsInserted, 3) - require.Equal(t, acc1ID, idsInserted[0]) - require.Equal(t, acc2ID, idsInserted[1]) - require.Equal(t, acc3ID, idsInserted[2]) - - testGetAccount(t, store, acc1.ID, acc1, false) - testGetAccount(t, store, acc2.ID, acc2, false) - testGetAccount(t, store, acc3.ID, acc3, false) - testGetAccount(t, store, models.AccountID{Reference: "test4", ConnectorID: connectorID}, nil, true) -} - -func testGetAccount( - t *testing.T, - store *storage.Storage, - id models.AccountID, - expectedAccount *models.Account, - expectedError bool, -) { - account, err := store.GetAccount(context.Background(), id.String()) - if expectedError { - require.Error(t, err) - return - } else { - require.NoError(t, err) - } - - account.CreatedAt = account.CreatedAt.UTC() - require.Equal(t, expectedAccount, account) -} - -func testUpdateAccounts(t *testing.T, store *storage.Storage) { - acc1Updated := &models.Account{ - ID: acc1ID, - CreatedAt: time.Date(2023, 11, 14, 5, 59, 0, 0, time.UTC), // New timestamps, but should not be updated in the database - Reference: "test1", - ConnectorID: connectorID, - DefaultAsset: "EUR", - AccountName: "test1-update", - Type: models.AccountTypeInternal, - Metadata: map[string]string{ - "foo2": "bar2", - }, - } - - idsInserted, err := store.UpsertAccounts( - context.Background(), - []*models.Account{acc1Updated}, - ) - require.NoError(t, err) - require.Len(t, idsInserted, 1) - require.Equal(t, acc1ID, idsInserted[0]) - - // CreatedAt should not be updated - acc1Updated.CreatedAt = acc1T - testGetAccount(t, store, acc1Updated.ID, acc1Updated, false) - - // Upsert again with the same values - idsInserted, err = store.UpsertAccounts( - context.Background(), - []*models.Account{acc1Updated}, - ) - require.NoError(t, err) - require.Len(t, idsInserted, 0) // Should not be updated or inserted - - testGetAccount(t, store, acc1Updated.ID, acc1Updated, false) -} - -func testAccountsDeletedAfterConnectorUninstall(t *testing.T, store *storage.Storage) { - // Accounts should be deleted after uninstalling the connector - testGetAccount(t, store, acc1ID, nil, true) - testGetAccount(t, store, acc2ID, nil, true) - testGetAccount(t, store, acc3ID, nil, true) -} diff --git a/cmd/connectors/internal/storage/balance_test.go b/cmd/connectors/internal/storage/balance_test.go deleted file mode 100644 index cfb2b7a9..00000000 --- a/cmd/connectors/internal/storage/balance_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package storage_test - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/stretchr/testify/require" -) - -var ( - b1T = time.Date(2023, 11, 14, 5, 1, 10, 0, time.UTC) - b2T = time.Date(2023, 11, 14, 5, 1, 20, 0, time.UTC) -) - -func TestBalances(t *testing.T) { - store := newStore(t) - - testInstallConnectors(t, store) - testCreateAccounts(t, store) - testCreateBalances(t, store) - testUninstallConnectors(t, store) - testBalancesDeletedAfterConnectorUninstall(t, store) -} - -func testCreateBalances(t *testing.T, store *storage.Storage) { - b1 := &models.Balance{ - AccountID: models.AccountID{ - Reference: "not_existing", - ConnectorID: connectorID, - }, - Asset: "USD", - Balance: big.NewInt(int64(100)), - CreatedAt: b1T, - LastUpdatedAt: b1T, - } - - // Cannot insert balance for non-existing account - err := store.InsertBalances(context.Background(), []*models.Balance{b1}, false) - require.Error(t, err) - - // When inserting with ignore, no error is returned - err = store.InsertBalances(context.Background(), []*models.Balance{b1}, true) - require.NoError(t, err) - - b1.AccountID = acc1ID - err = store.InsertBalances(context.Background(), []*models.Balance{b1}, true) - require.NoError(t, err) - - b2 := &models.Balance{ - AccountID: acc1ID, - Asset: "USD", - Balance: big.NewInt(int64(200)), - CreatedAt: b2T, - LastUpdatedAt: b2T, - } - err = store.InsertBalances(context.Background(), []*models.Balance{b2}, true) - require.NoError(t, err) - - testGetBalance(t, store, acc1ID, []*models.Balance{b2, b1}, nil) -} - -func testGetBalance( - t *testing.T, - store *storage.Storage, - accountID models.AccountID, - expectedBalances []*models.Balance, - expectedError error, -) { - balances, err := store.GetBalancesForAccountID(context.Background(), accountID) - require.NoError(t, err) - require.Len(t, balances, len(expectedBalances)) - for i := range balances { - if i < len(balances)-1 { - require.Equal(t, balances[i+1].LastUpdatedAt.UTC(), balances[i].CreatedAt.UTC()) - } - require.Equal(t, expectedBalances[i].CreatedAt.UTC(), balances[i].CreatedAt.UTC()) - require.Equal(t, expectedBalances[i].AccountID, balances[i].AccountID) - require.Equal(t, expectedBalances[i].Asset, balances[i].Asset) - require.Equal(t, expectedBalances[i].Balance, balances[i].Balance) - } -} - -func testBalancesDeletedAfterConnectorUninstall(t *testing.T, store *storage.Storage) { - balances, err := store.GetBalancesForAccountID(context.Background(), acc1ID) - require.NoError(t, err) - require.Len(t, balances, 0) -} diff --git a/cmd/connectors/internal/storage/balances.go b/cmd/connectors/internal/storage/balances.go deleted file mode 100644 index a58e75d4..00000000 --- a/cmd/connectors/internal/storage/balances.go +++ /dev/null @@ -1,75 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) InsertBalances(ctx context.Context, balances []*models.Balance, checkIfAccountExists bool) error { - if len(balances) == 0 { - return nil - } - - query := s.db.NewInsert(). - Model((*models.Balance)(nil)). - With("cte1", s.db.NewValues(&balances)). - Column( - "created_at", - "account_id", - "balance", - "currency", - "last_updated_at", - ) - if checkIfAccountExists { - query = query.TableExpr(` - (SELECT * - FROM cte1 - WHERE EXISTS (SELECT 1 FROM accounts.account WHERE id = cte1.account_id) - AND cte1.balance != COALESCE((SELECT balance FROM accounts.balances WHERE account_id = cte1.account_id AND last_updated_at < cte1.last_updated_at AND currency = cte1.currency ORDER BY last_updated_at DESC LIMIT 1), cte1.balance+1) - ) data`) - } else { - query = query.TableExpr(` - (SELECT * - FROM cte1 - WHERE cte1.balance != COALESCE((SELECT balance FROM accounts.balances WHERE account_id = cte1.account_id AND last_updated_at < cte1.last_updated_at AND currency = cte1.currency ORDER BY last_updated_at DESC LIMIT 1), cte1.balance+1) - ) data`) - } - - query = query.On("CONFLICT (account_id, created_at, currency) DO NOTHING") - - _, err := query.Exec(ctx) - if err != nil { - return e("failed to create balances", err) - } - - // Always update the previous row in order to keep the balance history consistent. - _, err = s.db.NewUpdate(). - Model((*models.Balance)(nil)). - With("cte1", s.db.NewValues(&balances)). - TableExpr(` - (SELECT (SELECT created_at FROM accounts.balances WHERE last_updated_at < cte1.last_updated_at AND account_id = cte1.account_id AND currency = cte1.currency ORDER BY last_updated_at DESC LIMIT 1), cte1.account_id, cte1.currency, cte1.last_updated_at FROM cte1) data - `). - Set("last_updated_at = data.last_updated_at"). - Where("balance.account_id = data.account_id AND balance.currency = data.currency AND balance.created_at = data.created_at"). - Exec(ctx) - if err != nil { - return e("failed to update balances", err) - } - - return nil -} - -func (s *Storage) GetBalancesForAccountID(ctx context.Context, accountID models.AccountID) ([]*models.Balance, error) { - var balances []*models.Balance - - err := s.db.NewSelect(). - Model(&balances). - Where("account_id = ?", accountID). - Scan(ctx) - if err != nil { - return nil, e("failed to get balances", err) - } - - return balances, nil -} diff --git a/cmd/connectors/internal/storage/bank_accounts.go b/cmd/connectors/internal/storage/bank_accounts.go deleted file mode 100644 index b751a94c..00000000 --- a/cmd/connectors/internal/storage/bank_accounts.go +++ /dev/null @@ -1,134 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -func (s *Storage) CreateBankAccount(ctx context.Context, bankAccount *models.BankAccount) error { - account := models.BankAccount{ - CreatedAt: bankAccount.CreatedAt, - Country: bankAccount.Country, - Name: bankAccount.Name, - Metadata: bankAccount.Metadata, - } - - var id uuid.UUID - err := s.db.NewInsert().Model(&account).Returning("id").Scan(ctx, &id) - if err != nil { - return e("install connector", err) - } - bankAccount.ID = id - - return s.updateBankAccountInformation(ctx, id, bankAccount.AccountNumber, bankAccount.IBAN, bankAccount.SwiftBicCode) -} - -func (s *Storage) AddBankAccountRelatedAccount(ctx context.Context, relatedAccount *models.BankAccountRelatedAccount) error { - _, err := s.db.NewInsert().Model(relatedAccount).Exec(ctx) - if err != nil { - return e("add bank account related account", err) - } - - return nil -} - -func (s *Storage) updateBankAccountInformation(ctx context.Context, id uuid.UUID, accountNumber, iban, swiftBicCode string) error { - _, err := s.db.NewUpdate(). - Model(&models.BankAccount{}). - Set("account_number = pgp_sym_encrypt(?::TEXT, ?, ?)", accountNumber, s.configEncryptionKey, encryptionOptions). - Set("iban = pgp_sym_encrypt(?::TEXT, ?, ?)", iban, s.configEncryptionKey, encryptionOptions). - Set("swift_bic_code = pgp_sym_encrypt(?::TEXT, ?, ?)", swiftBicCode, s.configEncryptionKey, encryptionOptions). - Where("id = ?", id). - Exec(ctx) - if err != nil { - return e("update bank account information", err) - } - - return nil -} - -func (s *Storage) UpdateBankAccountMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return e("update bank account metadata", err) - } - defer tx.Rollback() - - var account models.BankAccount - err = tx.NewSelect(). - Model(&account). - Column("id", "metadata"). - Where("id = ?", id). - Scan(ctx) - if err != nil { - return e("update bank account metadata", err) - } - - if account.Metadata == nil { - account.Metadata = make(map[string]string) - } - - for k, v := range metadata { - account.Metadata[k] = v - } - - _, err = s.db.NewUpdate(). - Model(&account). - Column("metadata"). - Where("id = ?", id). - Exec(ctx) - if err != nil { - return e("update bank account metadata", err) - } - - return e("failed to commit transaction", tx.Commit()) -} - -func (s *Storage) LinkBankAccountWithAccount(ctx context.Context, id uuid.UUID, accountID *models.AccountID) error { - relatedAccount := &models.BankAccountRelatedAccount{ - ID: uuid.New(), - BankAccountID: id, - ConnectorID: accountID.ConnectorID, - AccountID: *accountID, - } - - return s.AddBankAccountRelatedAccount(ctx, relatedAccount) -} - -func (s *Storage) GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { - var account models.BankAccount - query := s.db.NewSelect(). - Model(&account). - Relation("RelatedAccounts"). - Column("id", "created_at", "name", "created_at", "country", "metadata") - - if expand { - query = query.ColumnExpr("pgp_sym_decrypt(account_number, ?, ?) AS decrypted_account_number", s.configEncryptionKey, encryptionOptions). - ColumnExpr("pgp_sym_decrypt(iban, ?, ?) AS decrypted_iban", s.configEncryptionKey, encryptionOptions). - ColumnExpr("pgp_sym_decrypt(swift_bic_code, ?, ?) AS decrypted_swift_bic_code", s.configEncryptionKey, encryptionOptions) - } - - err := query. - Where("id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("get bank account", err) - } - - return &account, nil -} - -func (s *Storage) GetBankAccountRelatedAccounts(ctx context.Context, id uuid.UUID) ([]*models.BankAccountRelatedAccount, error) { - var relatedAccounts []*models.BankAccountRelatedAccount - err := s.db.NewSelect(). - Model(&relatedAccounts). - Where("bank_account_id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("get bank account related accounts", err) - } - - return relatedAccounts, nil -} diff --git a/cmd/connectors/internal/storage/bank_accounts_test.go b/cmd/connectors/internal/storage/bank_accounts_test.go deleted file mode 100644 index 35772a9f..00000000 --- a/cmd/connectors/internal/storage/bank_accounts_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package storage_test - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" -) - -var ( - bankAccount1ID uuid.UUID - bankAccount2ID uuid.UUID - - bankAccount1T = time.Date(2023, 11, 14, 5, 2, 0, 0, time.UTC) - bankAccount2T = time.Date(2023, 11, 14, 5, 1, 0, 0, time.UTC) -) - -func TestBankAccounts(t *testing.T) { - store := newStore(t) - - testInstallConnectors(t, store) - testCreateAccounts(t, store) - testCreateBankAccounts(t, store) - testUpdateBankAccountMetadata(t, store) - testUninstallConnectors(t, store) - testBankAccountsDeletedAfterConnectorUninstall(t, store) -} - -func testCreateBankAccounts(t *testing.T, store *storage.Storage) { - bankAccount1 := &models.BankAccount{ - CreatedAt: bankAccount1T, - Name: "test1", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "BNPAFRPPXXX", - Country: "FR", - } - - err := store.CreateBankAccount(context.Background(), bankAccount1) - require.NoError(t, err) - require.NotEqual(t, uuid.Nil, bankAccount1.ID) - bankAccount1ID = bankAccount1.ID - - bankAccount2 := &models.BankAccount{ - CreatedAt: bankAccount2T, - Name: "test2", - AccountNumber: "123456789", - Country: "FR", - } - - err = store.CreateBankAccount(context.Background(), bankAccount2) - require.NoError(t, err) - require.NotEqual(t, uuid.Nil, bankAccount2.ID) - bankAccount2ID = bankAccount2.ID - - relatedAccount := &models.BankAccountRelatedAccount{ - ID: uuid.New(), - CreatedAt: bankAccount2T, - BankAccountID: bankAccount2ID, - ConnectorID: connectorID, - AccountID: acc1ID, - } - err = store.AddBankAccountRelatedAccount(context.Background(), relatedAccount) - require.NoError(t, err) - bankAccount2.RelatedAccounts = append(bankAccount2.RelatedAccounts, relatedAccount) - - err = store.AddBankAccountRelatedAccount(context.Background(), &models.BankAccountRelatedAccount{ - ID: uuid.New(), - CreatedAt: bankAccount2T, - BankAccountID: bankAccount2ID, - ConnectorID: connectorID, - AccountID: models.AccountID{ - Reference: "not_existing", - ConnectorID: connectorID, - }, - }) - require.Error(t, err) - - testGetBankAccount(t, store, bankAccount1ID, true, bankAccount1, nil) - testGetBankAccount(t, store, bankAccount2ID, true, bankAccount2, nil) -} - -func testGetBankAccount( - t *testing.T, - store *storage.Storage, - bankAccountID uuid.UUID, - expand bool, - expectedBankAccount *models.BankAccount, - expectedError error, -) { - bankAccount, err := store.GetBankAccount(context.Background(), bankAccountID, expand) - if expectedError != nil { - require.EqualError(t, err, expectedError.Error()) - return - } else { - require.NoError(t, err) - } - - require.Equal(t, bankAccount.Country, expectedBankAccount.Country) - require.Equal(t, bankAccount.CreatedAt.UTC(), expectedBankAccount.CreatedAt.UTC()) - require.Equal(t, bankAccount.Name, expectedBankAccount.Name) - - if expand { - require.Equal(t, bankAccount.SwiftBicCode, expectedBankAccount.SwiftBicCode) - require.Equal(t, bankAccount.IBAN, expectedBankAccount.IBAN) - require.Equal(t, bankAccount.AccountNumber, expectedBankAccount.AccountNumber) - } - - require.Len(t, bankAccount.RelatedAccounts, len(expectedBankAccount.RelatedAccounts)) - for i, adj := range bankAccount.RelatedAccounts { - require.Equal(t, adj.BankAccountID, expectedBankAccount.RelatedAccounts[i].BankAccountID) - require.Equal(t, adj.CreatedAt.UTC(), expectedBankAccount.RelatedAccounts[i].CreatedAt.UTC()) - require.Equal(t, adj.ConnectorID, expectedBankAccount.RelatedAccounts[i].ConnectorID) - require.Equal(t, adj.AccountID, expectedBankAccount.RelatedAccounts[i].AccountID) - } -} - -func testUpdateBankAccountMetadata(t *testing.T, store *storage.Storage) { - metadata := map[string]string{ - "key": "value", - } - - err := store.UpdateBankAccountMetadata(context.Background(), bankAccount1ID, metadata) - require.NoError(t, err) - - bankAccount, err := store.GetBankAccount(context.Background(), bankAccount1ID, false) - require.NoError(t, err) - require.Equal(t, metadata, bankAccount.Metadata) - - // Bank account not existing - err = store.UpdateBankAccountMetadata(context.Background(), uuid.New(), metadata) - require.True(t, errors.Is(err, storage.ErrNotFound)) -} - -func testBankAccountsDeletedAfterConnectorUninstall(t *testing.T, store *storage.Storage) { - // Connector has been uninstalled, related adjustments are deleted, but not the bank - // accounts themselves. - bankAccount1 := &models.BankAccount{ - CreatedAt: bankAccount1T, - Name: "test1", - IBAN: "FR7630006000011234567890189", - SwiftBicCode: "BNPAFRPPXXX", - Country: "FR", - } - - bankAccount2 := &models.BankAccount{ - CreatedAt: bankAccount2T, - Name: "test2", - AccountNumber: "123456789", - Country: "FR", - } - - testGetBankAccount(t, store, bankAccount1ID, true, bankAccount1, nil) - testGetBankAccount(t, store, bankAccount2ID, true, bankAccount2, nil) -} diff --git a/cmd/connectors/internal/storage/connectors.go b/cmd/connectors/internal/storage/connectors.go deleted file mode 100644 index a7f6a7f2..00000000 --- a/cmd/connectors/internal/storage/connectors.go +++ /dev/null @@ -1,131 +0,0 @@ -package storage - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) ListConnectors(ctx context.Context) ([]*models.Connector, error) { - var connectors []*models.Connector - - err := s.db.NewSelect(). - Model(&connectors). - ColumnExpr("*, pgp_sym_decrypt(config, ?, ?) AS decrypted_config", s.configEncryptionKey, encryptionOptions). - Scan(ctx) - if err != nil { - return nil, e("list connectors", err) - } - - return connectors, nil -} - -func (s *Storage) ListConnectorsByProvider(ctx context.Context, provider models.ConnectorProvider) ([]*models.Connector, error) { - var connectors []*models.Connector - - err := s.db.NewSelect(). - Model(&connectors). - ColumnExpr("*, pgp_sym_decrypt(config, ?, ?) AS decrypted_config", s.configEncryptionKey, encryptionOptions). - Where("provider = ?", provider). - Scan(ctx) - if err != nil { - return nil, e("list connectors", err) - } - - return connectors, nil -} - -func (s *Storage) GetConfig(ctx context.Context, connectorID models.ConnectorID, destination any) error { - var connector models.Connector - - err := s.db.NewSelect(). - Model(&connector). - ColumnExpr("pgp_sym_decrypt(config, ?, ?) AS decrypted_config", s.configEncryptionKey, encryptionOptions). - Where("id = ?", connectorID). - Scan(ctx) - if err != nil { - return e(fmt.Sprintf("failed to get config for connector %s", connectorID), err) - } - - err = json.Unmarshal(connector.Config, destination) - if err != nil { - return e(fmt.Sprintf("failed to unmarshal config for connector %s", connectorID), err) - } - - return nil -} - -func (s *Storage) IsInstalledByConnectorID(ctx context.Context, connectorID models.ConnectorID) (bool, error) { - exists, err := s.db.NewSelect(). - Model(&models.Connector{}). - Where("id = ?", connectorID). - Exists(ctx) - if err != nil { - return false, e("find connector", err) - } - - return exists, nil -} - -func (s *Storage) IsInstalledByConnectorName(ctx context.Context, name string) (bool, error) { - exists, err := s.db.NewSelect(). - Model(&models.Connector{}). - Where("name = ?", name). - Exists(ctx) - if err != nil { - return false, e("find connector", err) - } - - return exists, nil -} - -func (s *Storage) Install(ctx context.Context, connector *models.Connector, config json.RawMessage) error { - _, err := s.db.NewInsert().Model(connector).Exec(ctx) - if err != nil { - return e("install connector", err) - } - - return s.UpdateConfig(ctx, connector.ID, config) -} - -func (s *Storage) Uninstall(ctx context.Context, connectorID models.ConnectorID) error { - _, err := s.db.NewDelete(). - Model(&models.Connector{}). - Where("id = ?", connectorID). - Exec(ctx) - if err != nil { - return e("uninstall connector", err) - } - - return nil -} - -func (s *Storage) UpdateConfig(ctx context.Context, connectorID models.ConnectorID, config json.RawMessage) error { - _, err := s.db.NewUpdate(). - Model(&models.Connector{}). - Set("config = pgp_sym_encrypt(?::TEXT, ?, ?)", config, s.configEncryptionKey, encryptionOptions). - Where("id = ?", connectorID). // Connector name is unique - Exec(ctx) - if err != nil { - return e("update connector config", err) - } - - return nil -} - -func (s *Storage) GetConnector(ctx context.Context, connectorID models.ConnectorID) (*models.Connector, error) { - var connector models.Connector - - err := s.db.NewSelect(). - Model(&connector). - ColumnExpr("*, pgp_sym_decrypt(config, ?, ?) AS decrypted_config", s.configEncryptionKey, encryptionOptions). - Where("id = ?", connectorID). - Scan(ctx) - if err != nil { - return nil, e("find connector", err) - } - - return &connector, nil -} diff --git a/cmd/connectors/internal/storage/connectors_test.go b/cmd/connectors/internal/storage/connectors_test.go deleted file mode 100644 index f941fc7a..00000000 --- a/cmd/connectors/internal/storage/connectors_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package storage_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -var ( - connectorID = models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } -) - -func TestConnectors(t *testing.T) { - store := newStore(t) - - testInstallConnectors(t, store) - testIsInstalledConnectors(t, store) - testUpdateConfig(t, store) - testUninstallConnectors(t, store) - testAfterInstallationConnectors(t, store) -} - -func testInstallConnectors(t *testing.T, store *storage.Storage) { - connector1 := models.Connector{ - ID: connectorID, - Name: "test1", - Provider: models.ConnectorProviderDummyPay, - } - err := store.Install( - context.Background(), - &connector1, - json.RawMessage([]byte(`{"foo": "bar"}`)), - ) - require.NoError(t, err) - - err = store.Install( - context.Background(), - &connector1, - json.RawMessage([]byte(`{"foo": "bar"}`)), - ) - require.Equal(t, storage.ErrDuplicateKeyValue, err) - - testGetConnector(t, store, connectorID, []byte(`{"foo": "bar"}`)) -} - -func testGetConnector(t *testing.T, store *storage.Storage, connectorID models.ConnectorID, expectedConfig []byte) { - var config json.RawMessage - err := store.GetConfig(context.Background(), connectorID, &config) - require.NoError(t, err) - require.Equal(t, json.RawMessage(expectedConfig), config) -} - -func testUpdateConfig(t *testing.T, store *storage.Storage) { - err := store.UpdateConfig(context.Background(), connectorID, json.RawMessage([]byte(`{"foo2": "bar2"}`))) - require.NoError(t, err) - - testGetConnector(t, store, connectorID, []byte(`{"foo2": "bar2"}`)) -} - -func testIsInstalledConnectors(t *testing.T, store *storage.Storage) { - isInstalled, err := store.IsInstalledByConnectorID( - context.Background(), - models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }) - require.NoError(t, err) - require.False(t, isInstalled) - - isInstalled, err = store.IsInstalledByConnectorID(context.Background(), connectorID) - require.NoError(t, err) - require.True(t, isInstalled) -} - -func testUninstallConnectors(t *testing.T, store *storage.Storage) { - // No error if deleting an unknown connector - err := store.Uninstall(context.Background(), models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - }) - require.NoError(t, err) - - err = store.Uninstall(context.Background(), connectorID) - require.NoError(t, err) -} - -func testAfterInstallationConnectors(t *testing.T, store *storage.Storage) { - isInstalled, err := store.IsInstalledByConnectorID(context.Background(), connectorID) - require.NoError(t, err) - require.False(t, isInstalled) -} diff --git a/cmd/connectors/internal/storage/error.go b/cmd/connectors/internal/storage/error.go deleted file mode 100644 index 3cd51488..00000000 --- a/cmd/connectors/internal/storage/error.go +++ /dev/null @@ -1,29 +0,0 @@ -package storage - -import ( - "database/sql" - "fmt" - - "github.com/jackc/pgx/v5/pgconn" - "github.com/pkg/errors" -) - -var ErrNotFound = errors.New("not found") -var ErrDuplicateKeyValue = errors.New("duplicate key value") - -func e(msg string, err error) error { - if err == nil { - return nil - } - - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { - return ErrDuplicateKeyValue - } - - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - - return fmt.Errorf("%s: %w", msg, err) -} diff --git a/cmd/connectors/internal/storage/main_test.go b/cmd/connectors/internal/storage/main_test.go deleted file mode 100644 index a6ae2b2e..00000000 --- a/cmd/connectors/internal/storage/main_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package storage_test - -import ( - "context" - "crypto/rand" - "testing" - - "github.com/formancehq/go-libs/testing/docker" - "github.com/formancehq/go-libs/testing/utils" - - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/go-libs/testing/platform/pgtesting" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - migrationstorage "github.com/formancehq/payments/internal/storage" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/stdlib" - "github.com/stretchr/testify/require" - "github.com/uptrace/bun" - "github.com/uptrace/bun/dialect/pgdialect" -) - -var ( - srv *pgtesting.PostgresServer -) - -func TestMain(m *testing.M) { - utils.WithTestMain(func(t *utils.TestingTForMain) int { - srv = pgtesting.CreatePostgresServer(t, docker.NewPool(t, logging.Testing())) - - return m.Run() - }) -} - -func newStore(t *testing.T) *storage.Storage { - t.Helper() - - pgServer := srv.NewDatabase(t) - - config, err := pgx.ParseConfig(pgServer.ConnString()) - require.NoError(t, err) - - key := make([]byte, 64) - _, err = rand.Read(key) - require.NoError(t, err) - - db := bun.NewDB(stdlib.OpenDB(*config), pgdialect.New()) - t.Cleanup(func() { - _ = db.Close() - }) - - err = migrationstorage.Migrate(context.Background(), db) - require.NoError(t, err) - - store := storage.NewStorage( - db, - string(key), - ) - - return store -} diff --git a/cmd/connectors/internal/storage/metadata.go b/cmd/connectors/internal/storage/metadata.go deleted file mode 100644 index 723337bd..00000000 --- a/cmd/connectors/internal/storage/metadata.go +++ /dev/null @@ -1,39 +0,0 @@ -package storage - -import ( - "context" - "time" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) UpdatePaymentMetadata(ctx context.Context, paymentID models.PaymentID, metadata map[string]string) error { - var metadataToInsert []models.PaymentMetadata // nolint:prealloc // it's against a map - - for key, value := range metadata { - metadataToInsert = append(metadataToInsert, models.PaymentMetadata{ - PaymentID: paymentID, - Key: key, - Value: value, - Changelog: []models.MetadataChangelog{ - { - CreatedAt: time.Now(), - Value: value, - }, - }, - }) - } - - _, err := s.db.NewInsert(). - Model(&metadataToInsert). - On("CONFLICT (payment_id, key) DO UPDATE"). - Set("value = EXCLUDED.value"). - Set("changelog = metadata.changelog || EXCLUDED.changelog"). - Where("metadata.value != EXCLUDED.value"). - Exec(ctx) - if err != nil { - return e("failed to update payment metadata", err) - } - - return nil -} diff --git a/cmd/connectors/internal/storage/module.go b/cmd/connectors/internal/storage/module.go deleted file mode 100644 index cbdf9517..00000000 --- a/cmd/connectors/internal/storage/module.go +++ /dev/null @@ -1,31 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunconnect" - "github.com/formancehq/go-libs/logging" - "github.com/uptrace/bun" - "go.uber.org/fx" -) - -func Module(connectionOptions bunconnect.ConnectionOptions, configEncryptionKey string, debug bool) fx.Option { - return fx.Options( - fx.Supply(&connectionOptions), - bunconnect.Module(connectionOptions, debug), - fx.Provide(func(db *bun.DB) *Storage { - return NewStorage(db, configEncryptionKey) - }), - fx.Invoke(func(lc fx.Lifecycle, repo *Storage) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - logging.FromContext(ctx).Debug("Ping database...") - - // TODO: Check migrations state and panic if migrations are not applied - - return nil - }, - }) - }), - ) -} diff --git a/cmd/connectors/internal/storage/paginate.go b/cmd/connectors/internal/storage/paginate.go deleted file mode 100644 index ac170979..00000000 --- a/cmd/connectors/internal/storage/paginate.go +++ /dev/null @@ -1,47 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/go-libs/query" - "github.com/uptrace/bun" -) - -type PaginatedQueryOptions[T any] struct { - QueryBuilder query.Builder `json:"qb"` - Sorter Sorter - PageSize uint64 `json:"pageSize"` - Options T `json:"options"` -} - -func (opts PaginatedQueryOptions[T]) WithQueryBuilder(qb query.Builder) PaginatedQueryOptions[T] { - opts.QueryBuilder = qb - - return opts -} - -func (opts PaginatedQueryOptions[T]) WithSorter(sorter Sorter) PaginatedQueryOptions[T] { - opts.Sorter = sorter - - return opts -} - -func (opts PaginatedQueryOptions[T]) WithPageSize(pageSize uint64) PaginatedQueryOptions[T] { - opts.PageSize = pageSize - - return opts -} - -func NewPaginatedQueryOptions[T any](options T) PaginatedQueryOptions[T] { - return PaginatedQueryOptions[T]{ - Options: options, - PageSize: bunpaginate.QueryDefaultPageSize, - } -} - -func PaginateWithOffset[FILTERS any, RETURN any](s *Storage, ctx context.Context, - q *bunpaginate.OffsetPaginatedQuery[FILTERS], builders ...func(query *bun.SelectQuery) *bun.SelectQuery) (*bunpaginate.Cursor[RETURN], error) { - query := s.db.NewSelect() - return bunpaginate.UsingOffset[FILTERS, RETURN](ctx, query, *q, builders...) -} diff --git a/cmd/connectors/internal/storage/payments.go b/cmd/connectors/internal/storage/payments.go deleted file mode 100644 index bcdf0b69..00000000 --- a/cmd/connectors/internal/storage/payments.go +++ /dev/null @@ -1,136 +0,0 @@ -package storage - -import ( - "context" - "fmt" - - "github.com/formancehq/payments/internal/models" -) - -func (s *Storage) GetPayment(ctx context.Context, id string) (*models.Payment, error) { - var payment models.Payment - - err := s.db.NewSelect(). - Model(&payment). - Relation("Connector"). - Relation("Metadata"). - Relation("Adjustments"). - Where("payment.id = ?", id). - Scan(ctx) - if err != nil { - return nil, e(fmt.Sprintf("failed to get payment %s", id), err) - } - - return &payment, nil -} - -func (s *Storage) UpsertPayments(ctx context.Context, payments []*models.Payment) ([]*models.PaymentID, error) { - if len(payments) == 0 { - return nil, nil - } - - var idsUpdated []string - err := s.db.NewUpdate(). - With("_data", - s.db.NewValues(&payments). - Column( - "id", - "amount", - "type", - "scheme", - "asset", - "source_account_id", - "destination_account_id", - "status", - "created_at", - ), - ). - Model((*models.Payment)(nil)). - TableExpr("_data"). - Set("amount = _data.amount"). - Set("type = _data.type"). - Set("scheme = _data.scheme"). - Set("asset = _data.asset"). - Set("source_account_id = _data.source_account_id"). - Set("destination_account_id = _data.destination_account_id"). - Set("status = _data.status"). - Set("created_at = _data.created_at"). - Where(`(payment.id = _data.id) AND - (payment.created_at != _data.created_at OR payment.amount != _data.amount OR payment.type != _data.type OR - payment.scheme != _data.scheme OR payment.asset != _data.asset OR payment.source_account_id != _data.source_account_id OR - payment.destination_account_id != _data.destination_account_id OR payment.status != _data.status)`). - Returning("payment.id"). - Scan(ctx, &idsUpdated) - if err != nil { - return nil, e("failed to update payments", err) - } - - idsUpdatedMap := make(map[string]struct{}) - for _, id := range idsUpdated { - idsUpdatedMap[id] = struct{}{} - } - - paymentsToInsert := make([]*models.Payment, 0, len(payments)) - for _, payment := range payments { - if _, ok := idsUpdatedMap[payment.ID.String()]; !ok { - paymentsToInsert = append(paymentsToInsert, payment) - } - } - - var idsInserted []string - if len(paymentsToInsert) > 0 { - err = s.db.NewInsert(). - Model(&paymentsToInsert). - On("CONFLICT (id) DO NOTHING"). - Returning("payment.id"). - Scan(ctx, &idsInserted) - if err != nil { - return nil, e("failed to create payments", err) - } - } - - res := make([]*models.PaymentID, 0, len(idsUpdated)+len(idsInserted)) - for _, id := range idsUpdated { - res = append(res, models.MustPaymentIDFromString(id)) - } - for _, id := range idsInserted { - res = append(res, models.MustPaymentIDFromString(id)) - } - - return res, nil -} - -func (s *Storage) UpsertPaymentsAdjustments(ctx context.Context, adjustments []*models.PaymentAdjustment) error { - if len(adjustments) == 0 { - return nil - } - - _, err := s.db.NewInsert(). - Model(&adjustments). - On("CONFLICT (reference) DO NOTHING"). - Exec(ctx) - if err != nil { - return e("failed to create adjustments", err) - } - - return nil -} - -func (s *Storage) UpsertPaymentsMetadata(ctx context.Context, metadata []*models.PaymentMetadata) error { - if len(metadata) == 0 { - return nil - } - - _, err := s.db.NewInsert(). - Model(&metadata). - On("CONFLICT (payment_id, key) DO UPDATE"). - Set("value = EXCLUDED.value"). - Set("changelog = payment_metadata.changelog || EXCLUDED.changelog"). - Where("payment_metadata.value != EXCLUDED.value"). - Exec(ctx) - if err != nil { - return e("failed to create metadata", err) - } - - return nil -} diff --git a/cmd/connectors/internal/storage/payments_test.go b/cmd/connectors/internal/storage/payments_test.go deleted file mode 100644 index 3ed03853..00000000 --- a/cmd/connectors/internal/storage/payments_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package storage_test - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/stretchr/testify/require" -) - -var ( - p1ID *models.PaymentID - p1T = time.Date(2023, 11, 14, 4, 55, 0, 0, time.UTC) - p1 *models.Payment - - p2ID *models.PaymentID - p2T = time.Date(2023, 11, 14, 4, 54, 0, 0, time.UTC) - p2 *models.Payment -) - -func TestPayments(t *testing.T) { - store := newStore(t) - - testInstallConnectors(t, store) - testCreatePayments(t, store) - testUpdatePayment(t, store) - testUninstallConnectors(t, store) - testPaymentsDeletedAfterConnectorUninstall(t, store) -} - -func testCreatePayments(t *testing.T, store *storage.Storage) { - p1ID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "test1", - Type: models.PaymentTypePayOut, - }, - ConnectorID: connectorID, - } - p1 = &models.Payment{ - ID: *p1ID, - CreatedAt: p1T, - Reference: "ref1", - Amount: big.NewInt(100), - ConnectorID: connectorID, - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeCardVisa, - Asset: models.Asset("USD/2"), - } - - p2ID = &models.PaymentID{ - PaymentReference: models.PaymentReference{ - Reference: "test2", - Type: models.PaymentTypeTransfer, - }, - ConnectorID: connectorID, - } - p2 = &models.Payment{ - ID: *p2ID, - CreatedAt: p2T, - Reference: "ref2", - Amount: big.NewInt(150), - ConnectorID: connectorID, - Type: models.PaymentTypePayIn, - Status: models.PaymentStatusFailed, - Scheme: models.PaymentSchemeCardVisa, - Asset: models.Asset("EUR/2"), - } - - pFail := &models.Payment{ - ID: *p1ID, - CreatedAt: p1T, - Reference: "ref1", - ConnectorID: connectorID, - Amount: big.NewInt(100), - Type: models.PaymentTypePayOut, - Status: models.PaymentStatusSucceeded, - Scheme: models.PaymentSchemeCardVisa, - Asset: models.Asset("USD/2"), - SourceAccountID: &models.AccountID{ - Reference: "not_existing", - ConnectorID: connectorID, - }, - } - - ids, err := store.UpsertPayments(context.Background(), []*models.Payment{pFail}) - require.Error(t, err) - require.Len(t, ids, 0) - - ids, err = store.UpsertPayments(context.Background(), []*models.Payment{p1}) - require.NoError(t, err) - require.Len(t, ids, 1) - - ids, err = store.UpsertPayments(context.Background(), []*models.Payment{p2}) - require.NoError(t, err) - require.Len(t, ids, 1) - - p1.Status = models.PaymentStatusPending - p2.Status = models.PaymentStatusSucceeded - ids, err = store.UpsertPayments(context.Background(), []*models.Payment{p1, p2}) - require.NoError(t, err) - require.Len(t, ids, 2) - - ids, err = store.UpsertPayments(context.Background(), []*models.Payment{p1, p2}) - require.NoError(t, err) - require.Len(t, ids, 0) - - testGetPayment(t, store, *p1ID, p1, nil) - testGetPayment(t, store, *p2ID, p2, nil) -} - -func testGetPayment( - t *testing.T, - store *storage.Storage, - paymentID models.PaymentID, - expected *models.Payment, - expectedErr error, -) { - payment, err := store.GetPayment(context.Background(), paymentID.String()) - if expectedErr != nil { - require.EqualError(t, err, expectedErr.Error()) - return - } else { - require.NoError(t, err) - } - - payment.CreatedAt = payment.CreatedAt.UTC() - checkPaymentsEqual(t, expected, payment) -} - -func checkPaymentsEqual(t *testing.T, p1, p2 *models.Payment) { - require.Equal(t, p1.ID, p2.ID) - require.Equal(t, p1.CreatedAt.UTC(), p2.CreatedAt.UTC()) - require.Equal(t, p1.Reference, p2.Reference) - require.Equal(t, p1.Amount, p2.Amount) - require.Equal(t, p1.Type, p2.Type) - require.Equal(t, p1.Status, p2.Status) - require.Equal(t, p1.Scheme, p2.Scheme) - require.Equal(t, p1.Asset, p2.Asset) - require.Equal(t, p1.SourceAccountID, p2.SourceAccountID) - require.Equal(t, p1.DestinationAccountID, p2.DestinationAccountID) - require.Equal(t, p1.RawData, p2.RawData) -} - -func testUpdatePayment(t *testing.T, store *storage.Storage) { - p1.CreatedAt = time.Date(2023, 11, 14, 5, 55, 0, 0, time.UTC) - p1.Reference = "ref1_updated" - p1.Amount = big.NewInt(150) - p1.Type = models.PaymentTypePayIn - p1.Status = models.PaymentStatusPending - p1.Scheme = models.PaymentSchemeCardVisa - p1.Asset = models.Asset("USD/2") - - ids, err := store.UpsertPayments(context.Background(), []*models.Payment{p1}) - require.NoError(t, err) - require.Len(t, ids, 1) - - payment, err := store.GetPayment(context.Background(), p1ID.String()) - require.NoError(t, err) - - require.NotEqual(t, p1.Reference, payment.Reference) - - p1.Reference = payment.Reference - testGetPayment(t, store, *p1ID, p1, nil) -} - -func testPaymentsDeletedAfterConnectorUninstall(t *testing.T, store *storage.Storage) { - testGetPayment(t, store, *p1ID, nil, storage.ErrNotFound) - testGetPayment(t, store, *p2ID, nil, storage.ErrNotFound) -} diff --git a/cmd/connectors/internal/storage/ping.go b/cmd/connectors/internal/storage/ping.go deleted file mode 100644 index 2832abb0..00000000 --- a/cmd/connectors/internal/storage/ping.go +++ /dev/null @@ -1,5 +0,0 @@ -package storage - -func (s *Storage) Ping() error { - return s.db.Ping() -} diff --git a/cmd/connectors/internal/storage/repository.go b/cmd/connectors/internal/storage/repository.go deleted file mode 100644 index f342f965..00000000 --- a/cmd/connectors/internal/storage/repository.go +++ /dev/null @@ -1,35 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/uptrace/bun" - "github.com/uptrace/bun/extra/bundebug" -) - -type Storage struct { - db *bun.DB - configEncryptionKey string -} - -const encryptionOptions = "compress-algo=1, cipher-algo=aes256" - -func NewStorage(db *bun.DB, configEncryptionKey string) *Storage { - return &Storage{db: db, configEncryptionKey: configEncryptionKey} -} - -//nolint:unused // used in debug mode -func (s *Storage) debug() { - s.db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) -} - -type Reader interface { - ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) - GetAccount(ctx context.Context, id string) (*models.Account, error) - GetBankAccount(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) - GetWebhook(ctx context.Context, id uuid.UUID) (*models.Webhook, error) - GetPayment(ctx context.Context, id string) (*models.Payment, error) - GetTransferReversal(ctx context.Context, id models.TransferReversalID) (*models.TransferReversal, error) -} diff --git a/cmd/connectors/internal/storage/sort.go b/cmd/connectors/internal/storage/sort.go deleted file mode 100644 index 2ec3d5c0..00000000 --- a/cmd/connectors/internal/storage/sort.go +++ /dev/null @@ -1,33 +0,0 @@ -package storage - -import ( - "fmt" - - "github.com/uptrace/bun" -) - -type SortOrder string - -const ( - SortOrderAsc SortOrder = "asc" - SortOrderDesc SortOrder = "desc" -) - -type sortExpression struct { - Column string `json:"column"` - Order SortOrder `json:"order"` -} - -type Sorter []sortExpression - -func (s Sorter) Add(column string, order SortOrder) Sorter { - return append(s, sortExpression{column, order}) -} - -func (s Sorter) Apply(query *bun.SelectQuery) *bun.SelectQuery { - for _, expr := range s { - query = query.Order(fmt.Sprintf("%s %s", expr.Column, expr.Order)) - } - - return query -} diff --git a/cmd/connectors/internal/storage/task.go b/cmd/connectors/internal/storage/task.go deleted file mode 100644 index 045c2478..00000000 --- a/cmd/connectors/internal/storage/task.go +++ /dev/null @@ -1,165 +0,0 @@ -package storage - -import ( - "context" - "encoding/json" - - "github.com/formancehq/go-libs/bun/bunpaginate" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/uptrace/bun" -) - -func (s *Storage) UpdateTaskStatus(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, status models.TaskStatus, taskError string) error { - _, err := s.db.NewUpdate().Model(&models.Task{}). - Set("status = ?", status). - Set("error = ?", taskError). - Where("descriptor::TEXT = ?::TEXT", descriptor.ToMessage()). - Where("connector_id = ?", connectorID). - Exec(ctx) - if err != nil { - return e("failed to update task", err) - } - - return nil -} - -func (s *Storage) UpdateTaskState(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, state json.RawMessage) error { - _, err := s.db.NewUpdate().Model(&models.Task{}). - Set("state = ?", state). - Where("descriptor::TEXT = ?::TEXT", descriptor.ToMessage()). - Where("connector_id = ?", connectorID). - Exec(ctx) - if err != nil { - return e("failed to update task", err) - } - - return nil -} - -func (s *Storage) FindAndUpsertTask( - ctx context.Context, - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, - status models.TaskStatus, - schedulerOptions models.TaskSchedulerOptions, - taskErr string, -) (*models.Task, error) { - _, err := s.GetTaskByDescriptor(ctx, connectorID, descriptor) - if err != nil && !errors.Is(err, ErrNotFound) { - return nil, e("failed to get task", err) - } - - if err == nil { - err = s.UpdateTaskStatus(ctx, connectorID, descriptor, status, taskErr) - if err != nil { - return nil, e("failed to update task", err) - } - } else { - err = s.CreateTask(ctx, connectorID, descriptor, status, schedulerOptions) - if err != nil { - return nil, e("failed to upsert task", err) - } - } - - return s.GetTaskByDescriptor(ctx, connectorID, descriptor) -} - -func (s *Storage) CreateTask(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, status models.TaskStatus, schedulerOptions models.TaskSchedulerOptions) error { - _, err := s.db.NewInsert().Model(&models.Task{ - ConnectorID: connectorID, - Descriptor: descriptor.ToMessage(), - Status: status, - SchedulerOptions: schedulerOptions, - }).Exec(ctx) - if err != nil { - return e("failed to create task", err) - } - - return nil -} - -func (s *Storage) ListTasksByStatus(ctx context.Context, connectorID models.ConnectorID, status models.TaskStatus) ([]*models.Task, error) { - var tasks []*models.Task - - err := s.db.NewSelect().Model(&tasks). - Where("connector_id = ?", connectorID). - Where("status = ?", status). - Scan(ctx) - if err != nil { - return nil, e("failed to get tasks", err) - } - - return tasks, nil -} - -type TaskQuery struct{} - -type ListTasksQuery bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[TaskQuery]] - -func NewListTasksQuery(opts PaginatedQueryOptions[TaskQuery]) ListTasksQuery { - return ListTasksQuery{ - PageSize: opts.PageSize, - Order: bunpaginate.OrderAsc, - Options: opts, - } -} - -func (s *Storage) ListTasks(ctx context.Context, connectorID models.ConnectorID, q ListTasksQuery) (*bunpaginate.Cursor[models.Task], error) { - return PaginateWithOffset[PaginatedQueryOptions[TaskQuery], models.Task](s, ctx, - (*bunpaginate.OffsetPaginatedQuery[PaginatedQueryOptions[TaskQuery]])(&q), - func(query *bun.SelectQuery) *bun.SelectQuery { - query = query. - Where("connector_id = ?", connectorID). - Order("created_at DESC") - - if q.Options.Sorter != nil { - query = q.Options.Sorter.Apply(query) - } - - return query - }, - ) -} - -func (s *Storage) ReadOldestPendingTask(ctx context.Context, connectorID models.ConnectorID) (*models.Task, error) { - var task models.Task - err := s.db.NewSelect().Model(&task). - Where("connector_id = ?", connectorID). - Where("status = ?", models.TaskStatusPending). - Order("created_at ASC"). - Limit(1). - Scan(ctx) - if err != nil { - return nil, e("failed to get task", err) - } - - return &task, nil -} - -func (s *Storage) GetTask(ctx context.Context, id uuid.UUID) (*models.Task, error) { - var task models.Task - - err := s.db.NewSelect().Model(&task). - Where("id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("failed to get task", err) - } - - return &task, nil -} - -func (s *Storage) GetTaskByDescriptor(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor) (*models.Task, error) { - var task models.Task - err := s.db.NewSelect().Model(&task). - Where("connector_id = ?", connectorID). - Where("descriptor::TEXT = ?::TEXT", descriptor.ToMessage()). - Scan(ctx) - if err != nil { - return nil, e("failed to get task", err) - } - - return &task, nil -} diff --git a/cmd/connectors/internal/storage/transfer_initiation.go b/cmd/connectors/internal/storage/transfer_initiation.go deleted file mode 100644 index cbaa4dd3..00000000 --- a/cmd/connectors/internal/storage/transfer_initiation.go +++ /dev/null @@ -1,182 +0,0 @@ -package storage - -import ( - "context" - "database/sql" - "time" - - "github.com/formancehq/payments/internal/models" - "github.com/pkg/errors" -) - -func (s *Storage) CreateTransferInitiation(ctx context.Context, transferInitiation *models.TransferInitiation) error { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return err - } - defer func() { - _ = tx.Rollback() - }() - - query := tx.NewInsert(). - Column("id", "created_at", "scheduled_at", "description", "type", "destination_account_id", "provider", "connector_id", "initial_amount", "amount", "asset", "metadata"). - Model(transferInitiation) - - if transferInitiation.SourceAccountID != nil { - query = query.Column("source_account_id") - } - - _, err = query.Exec(ctx) - if err != nil { - return e("failed to create transfer initiation", err) - } - - for _, adjustment := range transferInitiation.RelatedAdjustments { - adj := adjustment - if _, err := tx.NewInsert().Model(adj).Exec(ctx); err != nil { - return e("failed to add adjustment", err) - } - } - - return e("failed to commit transaction", tx.Commit()) -} - -func (s *Storage) AddTransferInitiationAdjustment(ctx context.Context, adjustment *models.TransferInitiationAdjustment) error { - if _, err := s.db.NewInsert().Model(adjustment).Exec(ctx); err != nil { - return e("failed to add adjustment", err) - } - - return nil -} - -func (s *Storage) ReadTransferInitiation(ctx context.Context, id models.TransferInitiationID) (*models.TransferInitiation, error) { - var transferInitiation models.TransferInitiation - - query := s.db.NewSelect(). - Column("id", "created_at", "scheduled_at", "description", "type", "source_account_id", "destination_account_id", "provider", "connector_id", "amount", "asset", "metadata"). - Model(&transferInitiation). - Relation("RelatedAdjustments"). - Where("id = ?", id) - - err := query.Scan(ctx) - if err != nil { - return nil, e("failed to get transfer initiation", err) - } - - transferInitiation.SortRelatedAdjustments() - - transferInitiation.RelatedPayments, err = s.ReadTransferInitiationPayments(ctx, id) - if err != nil { - return nil, e("failed to get transfer initiation payments", err) - } - - return &transferInitiation, nil -} - -func (s *Storage) ReadTransferInitiationPayments(ctx context.Context, id models.TransferInitiationID) ([]*models.TransferInitiationPayment, error) { - var payments []*models.TransferInitiationPayment - - query := s.db.NewSelect(). - Column("transfer_initiation_id", "payment_id", "created_at", "status", "error"). - Model(&payments). - Where("transfer_initiation_id = ?", id). - Order("created_at DESC") - - err := query.Scan(ctx) - if err != nil { - return nil, e("failed to get transfer initiation payments", err) - } - - return payments, nil -} - -func (s *Storage) AddTransferInitiationPaymentID(ctx context.Context, id models.TransferInitiationID, paymentID *models.PaymentID, createdAt time.Time, metadata map[string]string) error { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return err - } - defer func() { - _ = tx.Rollback() - }() - - if paymentID == nil { - return errors.New("payment id is nil") - } - - _, err = tx.NewInsert(). - Column("transfer_initiation_id", "payment_id", "created_at", "status"). - Model(&models.TransferInitiationPayment{ - TransferInitiationID: id, - PaymentID: *paymentID, - CreatedAt: createdAt, - Status: models.TransferInitiationStatusProcessing, - }). - Exec(ctx) - if err != nil { - return e("failed to add transfer initiation payment id", err) - } - - if metadata != nil { - _, err := tx.NewUpdate(). - Model((*models.TransferInitiation)(nil)). - Set("metadata = ?", metadata). - Where("id = ?", id). - Exec(ctx) - if err != nil { - return e("failed to add metadata", err) - } - } - - return e("failed to commit transaction", tx.Commit()) -} - -func (s *Storage) UpdateTransferInitiationPaymentsStatus( - ctx context.Context, - id models.TransferInitiationID, - paymentID *models.PaymentID, - adjustment *models.TransferInitiationAdjustment, -) error { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return err - } - defer func() { - _ = tx.Rollback() - }() - - if paymentID != nil { - query := tx.NewUpdate(). - Model((*models.TransferInitiationPayment)(nil)). - Set("status = ?", adjustment.Status) - - if adjustment.Error != "" { - query = query.Set("error = ?", adjustment.Error) - } - - _, err := query. - Where("transfer_initiation_id = ?", id). - Where("payment_id = ?", paymentID). - Exec(ctx) - if err != nil { - return e("failed to update transfer initiation status", err) - } - } - - if _, err = tx.NewInsert().Model(adjustment).Exec(ctx); err != nil { - return e("failed to add adjustment", err) - } - - return e("failed to commit transaction", tx.Commit()) -} - -func (s *Storage) DeleteTransferInitiation(ctx context.Context, id models.TransferInitiationID) error { - _, err := s.db.NewDelete(). - Model((*models.TransferInitiation)(nil)). - Where("id = ?", id). - Exec(ctx) - if err != nil { - return e("failed to delete transfer initiation", err) - } - - return nil -} diff --git a/cmd/connectors/internal/storage/transfer_initiation_test.go b/cmd/connectors/internal/storage/transfer_initiation_test.go deleted file mode 100644 index 1dc127c4..00000000 --- a/cmd/connectors/internal/storage/transfer_initiation_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package storage_test - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -var ( - t1ID models.TransferInitiationID - t1T = time.Date(2023, 11, 14, 5, 8, 0, 0, time.UTC) - t1 *models.TransferInitiation - adjumentID1 = uuid.New() - - t2ID models.TransferInitiationID - t2T = time.Date(2023, 11, 14, 5, 7, 0, 0, time.UTC) - t2 *models.TransferInitiation - adjumentID2 = uuid.New() - - tAddPayments = time.Date(2023, 11, 14, 5, 9, 10, 0, time.UTC) - tUpdateStatus1 = time.Date(2023, 11, 14, 5, 9, 15, 0, time.UTC) - tUpdateStatus2 = time.Date(2023, 11, 14, 5, 9, 16, 0, time.UTC) -) - -func TestTransferInitiations(t *testing.T) { - store := newStore(t) - - testInstallConnectors(t, store) - testCreateAccounts(t, store) - testCreatePayments(t, store) - testCreateTransferInitiations(t, store) - testAddTransferInitiationPayments(t, store) - testUpdateTransferInitiationStatus(t, store) - testDeleteTransferInitiations(t, store) - testUninstallConnectors(t, store) - testTransferInitiationsDeletedAfterConnectorUninstall(t, store) -} - -func testCreateTransferInitiations(t *testing.T, store *storage.Storage) { - t1ID = models.TransferInitiationID{ - Reference: "test1", - ConnectorID: connectorID, - } - t1 = &models.TransferInitiation{ - ID: t1ID, - CreatedAt: t1T, - ScheduledAt: t1T, - Description: "test_description", - Type: models.TransferInitiationTypeTransfer, - ConnectorID: connectorID, - Provider: models.ConnectorProviderDummyPay, - Amount: big.NewInt(100), - Asset: models.Asset("USD/2"), - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: adjumentID1, - TransferInitiationID: t1ID, - CreatedAt: t1T, - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - } - - t2ID = models.TransferInitiationID{ - Reference: "test2", - ConnectorID: connectorID, - } - t2 = &models.TransferInitiation{ - ID: t2ID, - CreatedAt: t2T, - ScheduledAt: t2T, - Description: "test_description2", - Type: models.TransferInitiationTypeTransfer, - ConnectorID: connectorID, - Provider: models.ConnectorProviderDummyPay, - Amount: big.NewInt(150), - Asset: models.Asset("USD/2"), - SourceAccountID: &acc1ID, - DestinationAccountID: acc2ID, - RelatedAdjustments: []*models.TransferInitiationAdjustment{ - { - ID: adjumentID2, - TransferInitiationID: t2ID, - CreatedAt: t2T, - Status: models.TransferInitiationStatusWaitingForValidation, - }, - }, - } - - // Missing source account id and destination account id - err := store.CreateTransferInitiation(context.Background(), t1) - require.Error(t, err) - - t1.SourceAccountID = &acc1ID - t1.DestinationAccountID = acc2ID - err = store.CreateTransferInitiation(context.Background(), t1) - require.NoError(t, err) - - err = store.CreateTransferInitiation(context.Background(), t2) - require.NoError(t, err) - - testGetTransferInitiation(t, store, t1ID, false, t1, nil) - testGetTransferInitiation(t, store, t2ID, false, t2, nil) -} - -func testGetTransferInitiation( - t *testing.T, - store *storage.Storage, - id models.TransferInitiationID, - expand bool, - expected *models.TransferInitiation, - expectedErr error, -) { - tf, err := store.ReadTransferInitiation(context.Background(), id) - if expectedErr != nil { - require.EqualError(t, err, expectedErr.Error()) - return - } else { - require.NoError(t, err) - } - - if expand { - payments, err := store.ReadTransferInitiationPayments(context.Background(), id) - require.NoError(t, err) - tf.RelatedPayments = payments - } - - checkTransferInitiationsEqual(t, expected, tf, true) -} - -func checkTransferInitiationsEqual(t *testing.T, t1, t2 *models.TransferInitiation, checkRelatedAdjusment bool) { - require.Equal(t, t1.ID, t2.ID) - require.Equal(t, t1.CreatedAt.UTC(), t2.CreatedAt.UTC()) - require.Equal(t, t1.ScheduledAt.UTC(), t2.ScheduledAt.UTC()) - require.Equal(t, t1.Description, t2.Description) - require.Equal(t, t1.Type, t2.Type) - require.Equal(t, t1.Provider, t2.Provider) - require.Equal(t, t1.Amount, t2.Amount) - require.Equal(t, t1.Asset, t2.Asset) - require.Equal(t, t1.SourceAccountID, t2.SourceAccountID) - require.Equal(t, t1.DestinationAccountID, t2.DestinationAccountID) - for i := range t1.RelatedPayments { - require.Equal(t, t1.RelatedPayments[i].TransferInitiationID, t2.RelatedPayments[i].TransferInitiationID) - require.Equal(t, t1.RelatedPayments[i].PaymentID, t2.RelatedPayments[i].PaymentID) - require.Equal(t, t1.RelatedPayments[i].CreatedAt.UTC(), t2.RelatedPayments[i].CreatedAt.UTC()) - require.Equal(t, t1.RelatedPayments[i].Status, t2.RelatedPayments[i].Status) - require.Equal(t, t1.RelatedPayments[i].Error, t2.RelatedPayments[i].Error) - } - if checkRelatedAdjusment { - for i := range t1.RelatedAdjustments { - require.Equal(t, t1.RelatedAdjustments[i].TransferInitiationID, t2.RelatedAdjustments[i].TransferInitiationID) - require.Equal(t, t1.RelatedAdjustments[i].CreatedAt.UTC(), t2.RelatedAdjustments[i].CreatedAt.UTC()) - require.Equal(t, t1.RelatedAdjustments[i].Status, t2.RelatedAdjustments[i].Status) - require.Equal(t, t1.RelatedAdjustments[i].Error, t2.RelatedAdjustments[i].Error) - require.Equal(t, t1.RelatedAdjustments[i].Metadata, t2.RelatedAdjustments[i].Metadata) - } - } -} - -func testAddTransferInitiationPayments(t *testing.T, store *storage.Storage) { - err := store.AddTransferInitiationPaymentID( - context.Background(), - t1ID, - p1ID, - tAddPayments, - map[string]string{ - "test": "test", - }, - ) - require.NoError(t, err) - - t1.RelatedPayments = []*models.TransferInitiationPayment{ - { - TransferInitiationID: t1ID, - PaymentID: *p1ID, - CreatedAt: tAddPayments, - Status: models.TransferInitiationStatusProcessing, - Error: "", - }, - } - t1.Metadata = map[string]string{ - "test": "test", - } - testGetTransferInitiation(t, store, t1ID, true, t1, nil) - - err = store.AddTransferInitiationPaymentID( - context.Background(), - t1ID, - nil, - tAddPayments, - nil, - ) - require.Error(t, err) - - err = store.AddTransferInitiationPaymentID( - context.Background(), - models.TransferInitiationID{ - Reference: "not_existing", - ConnectorID: connectorID, - }, - p1ID, - tAddPayments, - nil, - ) - require.Error(t, err) -} - -func testUpdateTransferInitiationStatus(t *testing.T, store *storage.Storage) { - adjustment1 := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: t1ID, - CreatedAt: tUpdateStatus1, - Status: models.TransferInitiationStatusRejected, - Error: "test_error", - } - err := store.UpdateTransferInitiationPaymentsStatus( - context.Background(), - t1ID, - nil, - adjustment1, - ) - require.NoError(t, err) - - t1.RelatedAdjustments = append([]*models.TransferInitiationAdjustment{ - adjustment1, - }, t1.RelatedAdjustments...) - testGetTransferInitiation(t, store, t1ID, true, t1, nil) - - adjustment2 := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: t1ID, - CreatedAt: tUpdateStatus2, - Status: models.TransferInitiationStatusFailed, - Error: "test_error2", - } - err = store.UpdateTransferInitiationPaymentsStatus( - context.Background(), - t1ID, - p1ID, - adjustment2, - ) - require.NoError(t, err) - - t1.RelatedPayments[0].Status = models.TransferInitiationStatusFailed - t1.RelatedPayments[0].Error = "test_error2" - t1.RelatedAdjustments = append([]*models.TransferInitiationAdjustment{ - adjustment2, - }, t1.RelatedAdjustments...) - testGetTransferInitiation(t, store, t1ID, true, t1, nil) -} - -func testDeleteTransferInitiations(t *testing.T, store *storage.Storage) { - err := store.DeleteTransferInitiation(context.Background(), t1ID) - require.NoError(t, err) - - testGetTransferInitiation(t, store, t1ID, false, nil, storage.ErrNotFound) - - // Delete does not generate an error when not existing - err = store.DeleteTransferInitiation(context.Background(), models.TransferInitiationID{ - Reference: "not_existing", - ConnectorID: connectorID, - }) - require.NoError(t, err) -} - -func testTransferInitiationsDeletedAfterConnectorUninstall(t *testing.T, store *storage.Storage) { - testGetTransferInitiation(t, store, t1ID, false, nil, storage.ErrNotFound) - testGetTransferInitiation(t, store, t2ID, false, nil, storage.ErrNotFound) -} diff --git a/cmd/connectors/internal/storage/transfer_reversal.go b/cmd/connectors/internal/storage/transfer_reversal.go deleted file mode 100644 index 3a46dc33..00000000 --- a/cmd/connectors/internal/storage/transfer_reversal.go +++ /dev/null @@ -1,116 +0,0 @@ -package storage - -import ( - "context" - "database/sql" - "math/big" - "time" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" -) - -func (s *Storage) CreateTransferReversal(ctx context.Context, transferReversal *models.TransferReversal) error { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return err - } - defer func() { - _ = tx.Rollback() - }() - - _, err = tx.NewInsert().Model(transferReversal).Exec(ctx) - if err != nil { - return e("failed to create transfer reversal", err) - } - - adjustment := &models.TransferInitiationAdjustment{ - ID: uuid.New(), - TransferInitiationID: transferReversal.TransferInitiationID, - CreatedAt: time.Now().UTC(), - Status: models.TransferInitiationStatusAskReversed, - Error: "", - Metadata: transferReversal.Metadata, - } - - if _, err = tx.NewInsert().Model(adjustment).Exec(ctx); err != nil { - return e("failed to create adjustment", err) - } - - return e("failed to commit transaction", tx.Commit()) -} - -func (s *Storage) UpdateTransferReversalStatus( - ctx context.Context, - transfer *models.TransferInitiation, - transferReversal *models.TransferReversal, - adjustment *models.TransferInitiationAdjustment, -) error { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return err - } - defer func() { - _ = tx.Rollback() - }() - - now := time.Now().UTC() - - _, err = tx.NewUpdate(). - Model(transferReversal). - Set("status = ?", transferReversal.Status). - Set("error = ?", transferReversal.Error). - Set("updated_at = ?", now). - Where("id = ?", transferReversal.ID). - Exec(ctx) - if err != nil { - return e("failed to update transfer reversal status", err) - } - - if transferReversal.Status == models.TransferReversalStatusProcessed { - var amount *big.Int - err = tx.NewUpdate(). - Model((*models.TransferInitiation)(nil)). - Set("amount = amount - ?", transferReversal.Amount). - Where("id = ?", transferReversal.TransferInitiationID). - Returning("amount"). - Scan(ctx, &amount) - if err != nil { - return e("failed to update transfer initiation amount", err) - } - - switch amount.Cmp(big.NewInt(0)) { - case 0: - // amount == 0, so we can mark the transfer as reversed - adjustment.Status = models.TransferInitiationStatusReversed - case 1: - // amount > 0, so we can mark the transfer as partially reversed - adjustment.Status = models.TransferInitiationStatusPartiallyReversed - case -1: - // Should not happened since we have checks in postgres - return errors.New("transfer reversal amount is greater than transfer initiation amount") - } - - transfer.Amount = amount - } - - if _, err := tx.NewInsert().Model(adjustment).Exec(ctx); err != nil { - return e("failed to add adjustment", err) - } - - return e("failed to commit transaction", tx.Commit()) -} - -func (s *Storage) GetTransferReversal(ctx context.Context, id models.TransferReversalID) (*models.TransferReversal, error) { - var ret models.TransferReversal - err := s.db.NewSelect(). - Model(&ret). - Where("id = ?", id). - Scan(ctx) - if err != nil { - return nil, e("failed to get transfer reversal", err) - } - - return &ret, nil -} diff --git a/cmd/connectors/internal/storage/webhooks.go b/cmd/connectors/internal/storage/webhooks.go deleted file mode 100644 index 68d1a67f..00000000 --- a/cmd/connectors/internal/storage/webhooks.go +++ /dev/null @@ -1,41 +0,0 @@ -package storage - -import ( - "context" - - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" -) - -func (s *Storage) CreateWebhook(ctx context.Context, webhook *models.Webhook) error { - _, err := s.db.NewInsert().Model(webhook).Exec(ctx) - if err != nil { - return err - } - - return nil -} - -func (s *Storage) UpdateWebhookRequestBody(ctx context.Context, webhookID uuid.UUID, requestBody []byte) error { - if len(requestBody) == 0 { - return errors.New("requestBody cannot be empty") - } - - _, err := s.db.NewUpdate().Model((*models.Webhook)(nil)).Set("request_body = ?", requestBody).Where("id = ?", webhookID).Exec(ctx) - if err != nil { - return err - } - - return nil -} - -func (s *Storage) GetWebhook(ctx context.Context, id uuid.UUID) (*models.Webhook, error) { - webhook := &models.Webhook{} - err := s.db.NewSelect().Model(webhook).Where("id = ?", id).Scan(ctx) - if err != nil { - return nil, err - } - - return webhook, nil -} diff --git a/cmd/connectors/internal/task/context.go b/cmd/connectors/internal/task/context.go deleted file mode 100644 index 039bba4d..00000000 --- a/cmd/connectors/internal/task/context.go +++ /dev/null @@ -1,42 +0,0 @@ -package task - -import ( - "context" -) - -type ConnectorContext interface { - Context() context.Context - Scheduler() Scheduler -} - -type ConnectorCtx struct { - ctx context.Context - scheduler Scheduler -} - -func (ctx *ConnectorCtx) Context() context.Context { - return ctx.ctx -} - -func (ctx *ConnectorCtx) Scheduler() Scheduler { - return ctx.scheduler -} - -func NewConnectorContext(ctx context.Context, scheduler Scheduler) *ConnectorCtx { - return &ConnectorCtx{ - ctx: ctx, - scheduler: scheduler, - } -} - -type taskContextKey struct{} - -var _taskContextKey = taskContextKey{} - -func ContextWithConnectorContext(ctx context.Context, task ConnectorContext) context.Context { - return context.WithValue(ctx, _taskContextKey, task) -} - -func ConnectorContextFromContext(ctx context.Context) ConnectorContext { - return ctx.Value(_taskContextKey).(ConnectorContext) -} diff --git a/cmd/connectors/internal/task/errors.go b/cmd/connectors/internal/task/errors.go deleted file mode 100644 index 9a1e43d7..00000000 --- a/cmd/connectors/internal/task/errors.go +++ /dev/null @@ -1,13 +0,0 @@ -package task - -import "github.com/pkg/errors" - -var ( - // ErrRetryable will be sent by the task if we can retry the task, - // e.g. if the task failed because of a temporary network issue. - ErrRetryable = errors.New("retryable error") - - // ErrNonRetryable will be sent by the task if we can't retry the task, - // e.g. if the task failed because of a validation error. - ErrNonRetryable = errors.New("non-retryable error") -) diff --git a/cmd/connectors/internal/task/resolver.go b/cmd/connectors/internal/task/resolver.go deleted file mode 100644 index e12057ad..00000000 --- a/cmd/connectors/internal/task/resolver.go +++ /dev/null @@ -1,13 +0,0 @@ -package task - -import "github.com/formancehq/payments/internal/models" - -type Resolver interface { - Resolve(descriptor models.TaskDescriptor) Task -} - -type ResolverFn func(descriptor models.TaskDescriptor) Task - -func (fn ResolverFn) Resolve(descriptor models.TaskDescriptor) Task { - return fn(descriptor) -} diff --git a/cmd/connectors/internal/task/scheduler.go b/cmd/connectors/internal/task/scheduler.go deleted file mode 100644 index 2fd8845e..00000000 --- a/cmd/connectors/internal/task/scheduler.go +++ /dev/null @@ -1,739 +0,0 @@ -package task - -import ( - "context" - "encoding/json" - "fmt" - "runtime/debug" - "sync" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/alitto/pond" - "github.com/formancehq/go-libs/logging" - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "go.uber.org/dig" -) - -var ( - ErrValidation = errors.New("validation error") - ErrAlreadyScheduled = errors.New("already scheduled") - ErrUnableToResolve = errors.New("unable to resolve task") -) - -type Scheduler interface { - Schedule(ctx context.Context, p models.TaskDescriptor, options models.TaskSchedulerOptions) error -} - -type taskHolder struct { - descriptor models.TaskDescriptor - cancel func() - logger logging.Logger - stopChan StopChan -} - -type ContainerCreateFunc func(ctx context.Context, descriptor models.TaskDescriptor, taskID uuid.UUID) (*dig.Container, error) - -type DefaultTaskScheduler struct { - connectorID models.ConnectorID - store Repository - metricsRegistry metrics.MetricsRegistry - containerFactory ContainerCreateFunc - tasks map[string]*taskHolder - mu sync.Mutex - resolver Resolver - stopped bool - workerPool *pond.WorkerPool -} - -func (s *DefaultTaskScheduler) ListTasks(ctx context.Context, q storage.ListTasksQuery) (*bunpaginate.Cursor[models.Task], error) { - return s.store.ListTasks(ctx, s.connectorID, q) -} - -func (s *DefaultTaskScheduler) ReadTask(ctx context.Context, taskID uuid.UUID) (*models.Task, error) { - return s.store.GetTask(ctx, taskID) -} - -func (s *DefaultTaskScheduler) ReadTaskByDescriptor(ctx context.Context, descriptor models.TaskDescriptor) (*models.Task, error) { - taskDescriptor, err := json.Marshal(descriptor) - if err != nil { - return nil, err - } - - return s.store.GetTaskByDescriptor(ctx, s.connectorID, taskDescriptor) -} - -// Schedule schedules a task to be executed. -// Schedule waits for: -// - Context to be done -// - Task creation if the scheduler option is not equal to OPTIONS_RUN_NOW_SYNC -// - Task termination if the scheduler option is equal to OPTIONS_RUN_NOW_SYNC -func (s *DefaultTaskScheduler) Schedule(ctx context.Context, descriptor models.TaskDescriptor, options models.TaskSchedulerOptions) error { - select { - case err := <-s.schedule(ctx, descriptor, options): - return err - case <-ctx.Done(): - return nil - } -} - -// schedule schedules a task to be executed. -// It returns an error chan that will be closed when the task is terminated if -// the scheduler option is equal to OPTIONS_RUN_NOW_SYNC. Otherwise, it will -// return an error chan that will be closed immediately after task creation. -func (s *DefaultTaskScheduler) schedule(ctx context.Context, descriptor models.TaskDescriptor, options models.TaskSchedulerOptions) <-chan error { - s.mu.Lock() - defer s.mu.Unlock() - - returnErrorFunc := func(err error) <-chan error { - errChan := make(chan error, 1) - if err != nil { - errChan <- err - } - close(errChan) - return errChan - } - - taskID, err := descriptor.EncodeToString() - if err != nil { - return returnErrorFunc(err) - } - - if _, ok := s.tasks[taskID]; ok { - switch options.RestartOption { - case models.OPTIONS_STOP_AND_RESTART, models.OPTIONS_RESTART_ALWAYS: - // We still want to restart the task - default: - return returnErrorFunc(ErrAlreadyScheduled) - } - } - - switch options.RestartOption { - case models.OPTIONS_RESTART_NEVER: - _, err := s.ReadTaskByDescriptor(ctx, descriptor) - if err == nil { - return returnErrorFunc(nil) - } - case models.OPTIONS_RESTART_IF_NOT_ACTIVE: - task, err := s.ReadTaskByDescriptor(ctx, descriptor) - if err == nil && task.Status == models.TaskStatusActive { - return nil - } - case models.OPTIONS_STOP_AND_RESTART: - err := s.stopTask(ctx, descriptor) - if err != nil { - return returnErrorFunc(err) - } - case models.OPTIONS_RESTART_ALWAYS: - // Do nothing - } - - errChan := s.startTask(ctx, descriptor, options) - - return errChan -} - -func (s *DefaultTaskScheduler) Shutdown(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.stopped = true - - s.logger(ctx).Infof("Stopping scheduler...") - s.workerPool.Stop() - - for name, task := range s.tasks { - task.logger.Debugf("Stopping task") - - if task.stopChan != nil { - errCh := make(chan struct{}) - task.stopChan <- errCh - select { - case <-errCh: - case <-time.After(time.Second): // TODO: Make configurable - task.logger.Debugf("Stopping using stop chan timeout, canceling context") - task.cancel() - } - } else { - task.cancel() - } - - delete(s.tasks, name) - } - - return nil -} - -func (s *DefaultTaskScheduler) Restore(ctx context.Context) error { - tasks, err := s.store.ListTasksByStatus(ctx, s.connectorID, models.TaskStatusActive) - if err != nil { - return err - } - - for _, task := range tasks { - if task.SchedulerOptions.Restart { - task.SchedulerOptions.RestartOption = models.OPTIONS_RESTART_ALWAYS - } - - errChan := s.startTask(ctx, task.GetDescriptor(), task.SchedulerOptions) - select { - case err := <-errChan: - if err != nil { - s.logger(ctx).Errorf("Unable to restore task %s: %s", task.ID, err) - } - case <-ctx.Done(): - } - } - - return nil -} - -func (s *DefaultTaskScheduler) registerTaskError(ctx context.Context, holder *taskHolder, taskErr any) { - var taskError string - - switch v := taskErr.(type) { - case error: - taskError = v.Error() - default: - taskError = fmt.Sprintf("%s", v) - } - - holder.logger.Errorf("Task terminated with error: %s", taskErr) - - err := s.store.UpdateTaskStatus(ctx, s.connectorID, holder.descriptor, models.TaskStatusFailed, taskError) - if err != nil { - holder.logger.Errorf("Error updating task status: %s", taskError) - } -} - -func (s *DefaultTaskScheduler) deleteTask(ctx context.Context, holder *taskHolder) { - s.mu.Lock() - defer s.mu.Unlock() - - taskID, err := holder.descriptor.EncodeToString() - if err != nil { - holder.logger.Errorf("Error encoding task descriptor: %s", err) - - return - } - - delete(s.tasks, taskID) - - if s.stopped { - return - } - - oldestPendingTask, err := s.store.ReadOldestPendingTask(ctx, s.connectorID) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - return - } - - logging.FromContext(ctx).Error(err) - - return - } - - p := s.resolver.Resolve(oldestPendingTask.GetDescriptor()) - if p == nil { - logging.FromContext(ctx).Errorf("unable to resolve task") - return - } - - errChan := s.startTask(ctx, oldestPendingTask.GetDescriptor(), models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - }) - select { - case err, ok := <-errChan: - if !ok { - return - } - if err != nil { - logging.FromContext(ctx).Error(err) - } - case <-ctx.Done(): - return - } -} - -type StopChan chan chan struct{} - -// Lock should be held when calling this function -func (s *DefaultTaskScheduler) stopTask(ctx context.Context, descriptor models.TaskDescriptor) error { - taskID, err := descriptor.EncodeToString() - if err != nil { - return err - } - - task, ok := s.tasks[taskID] - if !ok { - return nil - } - - task.logger.Infof("Stopping task...") - - if task.stopChan != nil { - errCh := make(chan struct{}) - task.stopChan <- errCh - select { - case <-errCh: - case <-time.After(time.Second): // TODO: Make configurable - task.logger.Debugf("Stopping using stop chan timeout, canceling context") - task.cancel() - } - } else { - task.cancel() - } - - err = s.store.UpdateTaskStatus(ctx, s.connectorID, descriptor, models.TaskStatusStopped, "") - if err != nil { - task.logger.Errorf("Error updating task status: %s", err) - return err - } - - delete(s.tasks, taskID) - - return nil -} - -func (s *DefaultTaskScheduler) startTask(ctx context.Context, descriptor models.TaskDescriptor, options models.TaskSchedulerOptions) <-chan error { - errChan := make(chan error, 1) - - task, err := s.store.FindAndUpsertTask(ctx, s.connectorID, descriptor, - models.TaskStatusActive, options, "") - if err != nil { - errChan <- errors.Wrap(err, "finding task and update") - close(errChan) - return errChan - } - - logger := s.logger(ctx).WithFields(map[string]interface{}{ - "task-id": task.ID, - }) - - taskResolver := s.resolver.Resolve(task.GetDescriptor()) - if taskResolver == nil { - errChan <- ErrUnableToResolve - close(errChan) - return errChan - } - - ctx, cancel := context.WithCancel(ctx) - - holder := &taskHolder{ - cancel: cancel, - logger: logger, - descriptor: descriptor, - } - - container, err := s.containerFactory(ctx, descriptor, task.ID) - if err != nil { - // TODO: Handle error - panic(err) - } - - err = container.Provide(func() context.Context { - return ctx - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() Scheduler { - return s - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() models.ConnectorID { - return s.connectorID - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() models.TaskID { - return models.TaskID(task.ID) - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() StopChan { - s.mu.Lock() - defer s.mu.Unlock() - - holder.stopChan = make(StopChan, 1) - - return holder.stopChan - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() logging.Logger { - return logger - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() metrics.MetricsRegistry { - return s.metricsRegistry - }) - if err != nil { - panic(err) - } - - err = container.Provide(func() StateResolver { - return StateResolverFn(func(ctx context.Context, v any) error { - t, err := s.store.GetTask(ctx, task.ID) - if err != nil { - return err - } - - if t.State == nil || len(t.State) == 0 { - return nil - } - - return json.Unmarshal(t.State, v) - }) - }) - if err != nil { - panic(err) - } - - taskID, err := holder.descriptor.EncodeToString() - if err != nil { - errChan <- err - close(errChan) - return errChan - } - - s.tasks[taskID] = holder - - sendError := false - switch options.ScheduleOption { - case models.OPTIONS_RUN_NOW_SYNC: - sendError = true - fallthrough - case models.OPTIONS_RUN_NOW: - options.Duration = 0 - fallthrough - case models.OPTIONS_RUN_SCHEDULED_AT: - if !options.ScheduleAt.IsZero() { - options.Duration = time.Until(options.ScheduleAt) - if options.Duration < 0 { - options.Duration = 0 - } - } - fallthrough - case models.OPTIONS_RUN_IN_DURATION: - go s.runTaskOnce( - ctx, - logger, - holder, - descriptor, - options, - taskResolver, - container, - sendError, - errChan, - 1, - ) - case models.OPTIONS_RUN_PERIODICALLY: - go s.runTaskPeriodically( - ctx, - logger, - holder, - descriptor, - options, - taskResolver, - container, - ) - } - - if !sendError { - close(errChan) - } - - return errChan -} - -func (s *DefaultTaskScheduler) runTaskOnce( - ctx context.Context, - logger logging.Logger, - holder *taskHolder, - descriptor models.TaskDescriptor, - options models.TaskSchedulerOptions, - taskResolver Task, - container *dig.Container, - sendError bool, - errChan chan error, - attempt int, -) { - // If attempt is > 1, it means that the task is being retried, so no need - // to wait again - if options.Duration > 0 && attempt == 1 { - logger.Infof("Waiting %s before starting task...", options.Duration) - select { - case <-ctx.Done(): - return - case ch := <-holder.stopChan: - logger.Infof("Stopping task...") - close(ch) - return - case <-time.After(options.Duration): - } - } - - logger.Infof("Starting task...") - - defer func() { - defer s.deleteTask(ctx, holder) - - if sendError { - defer close(errChan) - } - - if e := recover(); e != nil { - switch v := e.(type) { - case error: - if errors.Is(v, pond.ErrSubmitOnStoppedPool) { - // Pool is stopped and task is marked as active, - // nothing to do as they will be restarted on - // next startup - return - } - } - - s.registerTaskError(ctx, holder, e) - debug.PrintStack() - - if sendError { - switch v := e.(type) { - case error: - errChan <- v - default: - errChan <- fmt.Errorf("%s", v) - } - } - } - }() - - runF := func() (err error) { - defer func() { - if e := recover(); e != nil { - switch v := e.(type) { - case error: - if errors.Is(v, pond.ErrSubmitOnStoppedPool) { - // In this case, the scheduler is stopped, it means that - // either the connector is uninstalled or the service - // is stopped. In case of the connector being uninstalled, - // it doesn't matter if we send an error or not since - // all data will be deleted. In case of the service being - // stopped, the task should be restarted on next startup, - // so we have to mark it as Retryable. - err = errors.Wrap(ErrRetryable, v.Error()) - return - } else { - panic(e) - } - default: - panic(v) - } - } - }() - - done := make(chan struct{}) - s.workerPool.Submit(func() { - defer close(done) - err = container.Invoke(taskResolver) - }) - select { - case <-done: - case <-ctx.Done(): - return ctx.Err() - } - - return err - } - -loop: - for { - select { - case <-ctx.Done(): - return - default: - } - - err := runF() - switch { - case err == nil: - break loop - case errors.Is(err, ErrRetryable): - logger.Infof("Task terminated with retryable error: %s", err) - continue - case errors.Is(err, ErrNonRetryable): - logger.Infof("Task terminated with non retryable error: %s", err) - fallthrough - default: - if err == context.Canceled { - // Context was canceled, which means the scheduler was stopped - // either by the application being stopped or by the connector - // being removed. In this case, we don't want to update the - // task status, as it will be restarted on next startup. - return - } - - // All other errors - s.registerTaskError(ctx, holder, err) - - if sendError { - errChan <- err - } - - return - } - } - - logger.Infof("Task terminated with success") - - err := s.store.UpdateTaskStatus(ctx, s.connectorID, descriptor, models.TaskStatusTerminated, "") - if err != nil { - logger.Errorf("Error updating task status: %s", err) - if sendError { - errChan <- err - } - } -} - -func (s *DefaultTaskScheduler) runTaskPeriodically( - ctx context.Context, - logger logging.Logger, - holder *taskHolder, - descriptor models.TaskDescriptor, - options models.TaskSchedulerOptions, - taskResolver Task, - container *dig.Container, -) { - defer func() { - defer s.deleteTask(ctx, holder) - - if e := recover(); e != nil { - switch v := e.(type) { - case error: - if errors.Is(v, pond.ErrSubmitOnStoppedPool) { - // In this case, the scheduler is stopped, it means that - // either the connector is uninstalled or the service - // is stopped. In case of the connector being uninstalled, - // it doesn't matter if we send an error or not since - // all data will be deleted. In case of the service being - // stopped, the task should be restarted on next startup, - // so we need to not mark is as an error. - return - } else { - s.registerTaskError(ctx, holder, e) - debug.PrintStack() - } - default: - s.registerTaskError(ctx, holder, e) - debug.PrintStack() - } - - return - } - }() - - processFunc := func() (bool, error) { - var err error - done := make(chan struct{}) - s.workerPool.Submit(func() { - defer close(done) - err = container.Invoke(taskResolver) - }) - select { - case <-done: - case <-ctx.Done(): - return true, nil - case ch := <-holder.stopChan: - logger.Infof("Stopping task...") - close(ch) - return true, nil - } - if err != nil { - return false, err - } - - return false, err - } - - logger.Infof("Starting task...") - ticker := time.NewTicker(options.Duration) - for { - stopped, err := processFunc() - switch { - case err == nil: - // Doing nothing, waiting for the next tick - case errors.Is(err, ErrRetryable): - ticker.Reset(options.Duration) - continue - case errors.Is(err, ErrNonRetryable): - fallthrough - default: - // All other errors - s.registerTaskError(ctx, holder, err) - return - } - - if stopped { - // Task is stopped or context is done - return - } - - select { - case ch := <-holder.stopChan: - logger.Infof("Stopping task...") - close(ch) - return - case <-ctx.Done(): - return - case <-ticker.C: - logger.Infof("Polling trigger, running task...") - } - } -} - -func (s *DefaultTaskScheduler) logger(ctx context.Context) logging.Logger { - return logging.FromContext(ctx).WithFields(map[string]any{ - "component": "scheduler", - "connectorID": s.connectorID, - }) -} - -var _ Scheduler = &DefaultTaskScheduler{} - -func NewDefaultScheduler( - connectorID models.ConnectorID, - store Repository, - containerFactory ContainerCreateFunc, - resolver Resolver, - metricsRegistry metrics.MetricsRegistry, - maxTasks int, -) *DefaultTaskScheduler { - return &DefaultTaskScheduler{ - connectorID: connectorID, - store: store, - metricsRegistry: metricsRegistry, - tasks: map[string]*taskHolder{}, - containerFactory: containerFactory, - resolver: resolver, - workerPool: pond.New(maxTasks, maxTasks), - } -} diff --git a/cmd/connectors/internal/task/scheduler_test.go b/cmd/connectors/internal/task/scheduler_test.go deleted file mode 100644 index 95734b7d..00000000 --- a/cmd/connectors/internal/task/scheduler_test.go +++ /dev/null @@ -1,419 +0,0 @@ -package task - -import ( - "context" - "testing" - "time" - - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" - "go.uber.org/dig" -) - -//nolint:gochecknoglobals // allow in tests -var DefaultContainerFactory = ContainerCreateFunc(func(ctx context.Context, descriptor models.TaskDescriptor, taskID uuid.UUID) (*dig.Container, error) { - return dig.New(), nil -}) - -func newDescriptor() models.TaskDescriptor { - return []byte(uuid.New().String()) -} - -func TaskTerminatedWithStatus( - store *InMemoryStore, - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, - expectedStatus models.TaskStatus, - errString string, -) func() bool { - return func() bool { - status, resultErr, ok := store.Result(connectorID, descriptor) - if !ok { - return false - } - - if resultErr != errString { - return false - } - - return status == expectedStatus - } -} - -func TaskTerminated(store *InMemoryStore, connectorID models.ConnectorID, descriptor models.TaskDescriptor) func() bool { - return TaskTerminatedWithStatus(store, connectorID, descriptor, models.TaskStatusTerminated, "") -} - -func TaskFailed(store *InMemoryStore, connectorID models.ConnectorID, descriptor models.TaskDescriptor, errStr string) func() bool { - return TaskTerminatedWithStatus(store, connectorID, descriptor, models.TaskStatusFailed, errStr) -} - -func TaskPending(store *InMemoryStore, connectorID models.ConnectorID, descriptor models.TaskDescriptor) func() bool { - return TaskTerminatedWithStatus(store, connectorID, descriptor, models.TaskStatusPending, "") -} - -func TaskActive(store *InMemoryStore, connectorID models.ConnectorID, descriptor models.TaskDescriptor) func() bool { - return TaskTerminatedWithStatus(store, connectorID, descriptor, models.TaskStatusActive, "") -} - -func TestTaskScheduler(t *testing.T) { - t.Parallel() - - l := logrus.New() - if testing.Verbose() { - l.SetLevel(logrus.DebugLevel) - } - - t.Run("Nominal", func(t *testing.T) { - t.Parallel() - - store := NewInMemoryStore() - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - done := make(chan struct{}) - scheduler := NewDefaultScheduler(connectorID, store, - DefaultContainerFactory, ResolverFn(func(descriptor models.TaskDescriptor) Task { - return func(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-done: - return nil - } - } - }), metrics.NewNoOpMetricsRegistry(), 1) - - descriptor := newDescriptor() - err := scheduler.Schedule(context.TODO(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - }) - require.NoError(t, err) - - require.Eventually(t, TaskActive(store, connectorID, descriptor), time.Second, 100*time.Millisecond) - close(done) - require.Eventually(t, TaskTerminated(store, connectorID, descriptor), time.Second, 100*time.Millisecond) - }) - - t.Run("Duplicate task", func(t *testing.T) { - t.Parallel() - - store := NewInMemoryStore() - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - scheduler := NewDefaultScheduler(connectorID, store, DefaultContainerFactory, - ResolverFn(func(descriptor models.TaskDescriptor) Task { - return func(ctx context.Context) error { - <-ctx.Done() - - return ctx.Err() - } - }), metrics.NewNoOpMetricsRegistry(), 1) - - descriptor := newDescriptor() - err := scheduler.Schedule(context.TODO(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - }) - require.NoError(t, err) - require.Eventually(t, TaskActive(store, connectorID, descriptor), time.Second, 100*time.Millisecond) - - err = scheduler.Schedule(context.TODO(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - }) - require.Equal(t, ErrAlreadyScheduled, err) - }) - - t.Run("Error", func(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - store := NewInMemoryStore() - scheduler := NewDefaultScheduler(connectorID, store, DefaultContainerFactory, - ResolverFn(func(descriptor models.TaskDescriptor) Task { - return func() error { - return errors.New("test") - } - }), metrics.NewNoOpMetricsRegistry(), 1) - - descriptor := newDescriptor() - err := scheduler.Schedule(context.TODO(), descriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - }) - require.NoError(t, err) - require.Eventually(t, TaskFailed(store, connectorID, descriptor, "test"), time.Second, - 100*time.Millisecond) - }) - - t.Run("Pending", func(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - store := NewInMemoryStore() - descriptor1 := newDescriptor() - descriptor2 := newDescriptor() - - task1Launched := make(chan struct{}) - task2Launched := make(chan struct{}) - - task1Terminated := make(chan struct{}) - task2Terminated := make(chan struct{}) - - scheduler := NewDefaultScheduler(connectorID, store, DefaultContainerFactory, - ResolverFn(func(descriptor models.TaskDescriptor) Task { - switch string(descriptor) { - case string(descriptor1): - return func(ctx context.Context) error { - close(task1Launched) - select { - case <-task1Terminated: - return nil - case <-ctx.Done(): - return ctx.Err() - } - } - case string(descriptor2): - return func(ctx context.Context) error { - close(task2Launched) - select { - case <-task2Terminated: - return nil - case <-ctx.Done(): - return ctx.Err() - } - } - } - - panic("unknown descriptor") - }), metrics.NewNoOpMetricsRegistry(), 1) - - require.NoError(t, scheduler.Schedule(context.TODO(), descriptor1, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.NoError(t, scheduler.Schedule(context.TODO(), descriptor2, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - - select { - case <-task1Launched: - require.Eventually(t, TaskActive(store, connectorID, descriptor1), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, descriptor2), time.Second, 100*time.Millisecond) - close(task1Terminated) - require.Eventually(t, TaskTerminated(store, connectorID, descriptor1), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, descriptor2), time.Second, 100*time.Millisecond) - close(task2Terminated) - require.Eventually(t, TaskTerminated(store, connectorID, descriptor2), time.Second, 100*time.Millisecond) - case <-task2Launched: - require.Eventually(t, TaskActive(store, connectorID, descriptor1), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, descriptor2), time.Second, 100*time.Millisecond) - close(task2Terminated) - require.Eventually(t, TaskTerminated(store, connectorID, descriptor2), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, descriptor1), time.Second, 100*time.Millisecond) - close(task1Terminated) - require.Eventually(t, TaskTerminated(store, connectorID, descriptor1), time.Second, 100*time.Millisecond) - } - }) - - t.Run("Stop scheduler", func(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - store := NewInMemoryStore() - mainDescriptor := newDescriptor() - workerDescriptor := newDescriptor() - - scheduler := NewDefaultScheduler(connectorID, store, DefaultContainerFactory, - ResolverFn(func(descriptor models.TaskDescriptor) Task { - switch string(descriptor) { - case string(mainDescriptor): - return func(ctx context.Context, scheduler Scheduler) { - <-ctx.Done() - require.NoError(t, scheduler.Schedule(ctx, workerDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - } - default: - return func() { - - } - } - }), metrics.NewNoOpMetricsRegistry(), 1) - - require.NoError(t, scheduler.Schedule(context.TODO(), mainDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskActive(store, connectorID, mainDescriptor), time.Second, 100*time.Millisecond) - require.NoError(t, scheduler.Shutdown(context.TODO())) - // the main task should be still marked as active since it failed to - // schedule the worker task because the scheduler was stopped - require.Eventually(t, TaskActive(store, connectorID, mainDescriptor), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, workerDescriptor), time.Second, 100*time.Millisecond) - }) - - t.Run("errors and retryable errors", func(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - store := NewInMemoryStore() - nonRetryableDescriptor := newDescriptor() - retryableDescriptor := newDescriptor() - otherErrorDescriptor := newDescriptor() - noErrorDescriptor := newDescriptor() - - scheduler := NewDefaultScheduler(connectorID, store, DefaultContainerFactory, - ResolverFn(func(descriptor models.TaskDescriptor) Task { - switch string(descriptor) { - case string(nonRetryableDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return ErrNonRetryable - } - case string(retryableDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return ErrRetryable - } - case string(otherErrorDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return errors.New("test") - } - case string(noErrorDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return nil - } - default: - return func() { - - } - } - }), metrics.NewNoOpMetricsRegistry(), 1) - - require.NoError(t, scheduler.Schedule(context.TODO(), nonRetryableDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskFailed(store, connectorID, nonRetryableDescriptor, "non-retryable error"), time.Second, 100*time.Millisecond) - - require.NoError(t, scheduler.Schedule(context.TODO(), otherErrorDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskFailed(store, connectorID, otherErrorDescriptor, "test"), time.Second, 100*time.Millisecond) - - require.NoError(t, scheduler.Schedule(context.TODO(), noErrorDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskTerminated(store, connectorID, noErrorDescriptor), time.Second, 100*time.Millisecond) - - require.NoError(t, scheduler.Schedule(context.TODO(), retryableDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_NOW, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskActive(store, connectorID, retryableDescriptor), time.Second, 100*time.Millisecond) - require.NoError(t, scheduler.Shutdown(context.TODO())) - - require.Eventually(t, TaskFailed(store, connectorID, nonRetryableDescriptor, "non-retryable error"), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskFailed(store, connectorID, otherErrorDescriptor, "test"), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskTerminated(store, connectorID, noErrorDescriptor), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, retryableDescriptor), time.Second, 100*time.Millisecond) - }) - - t.Run("errors and retryable errors", func(t *testing.T) { - t.Parallel() - - connectorID := models.ConnectorID{ - Reference: uuid.New(), - Provider: models.ConnectorProviderDummyPay, - } - store := NewInMemoryStore() - nonRetryableDescriptor := newDescriptor() - retryableDescriptor := newDescriptor() - otherErrorDescriptor := newDescriptor() - noErrorDescriptor := newDescriptor() - - scheduler := NewDefaultScheduler(connectorID, store, DefaultContainerFactory, - ResolverFn(func(descriptor models.TaskDescriptor) Task { - switch string(descriptor) { - case string(nonRetryableDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return ErrNonRetryable - } - case string(retryableDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return ErrRetryable - } - case string(otherErrorDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return errors.New("test") - } - case string(noErrorDescriptor): - return func(ctx context.Context, scheduler Scheduler) error { - return nil - } - default: - return func() { - - } - } - }), metrics.NewNoOpMetricsRegistry(), 1) - - require.NoError(t, scheduler.Schedule(context.TODO(), nonRetryableDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: 1 * time.Second, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskFailed(store, connectorID, nonRetryableDescriptor, "non-retryable error"), time.Second, 100*time.Millisecond) - - require.NoError(t, scheduler.Schedule(context.TODO(), otherErrorDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: 1 * time.Second, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskFailed(store, connectorID, otherErrorDescriptor, "test"), time.Second, 100*time.Millisecond) - - require.NoError(t, scheduler.Schedule(context.TODO(), noErrorDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: 1 * time.Second, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskActive(store, connectorID, noErrorDescriptor), time.Second, 100*time.Millisecond) - - require.NoError(t, scheduler.Schedule(context.TODO(), retryableDescriptor, models.TaskSchedulerOptions{ - ScheduleOption: models.OPTIONS_RUN_PERIODICALLY, - Duration: 1 * time.Second, - RestartOption: models.OPTIONS_RESTART_NEVER, - })) - require.Eventually(t, TaskActive(store, connectorID, retryableDescriptor), time.Second, 100*time.Millisecond) - require.NoError(t, scheduler.Shutdown(context.TODO())) - - require.Eventually(t, TaskFailed(store, connectorID, nonRetryableDescriptor, "non-retryable error"), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskFailed(store, connectorID, otherErrorDescriptor, "test"), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, noErrorDescriptor), time.Second, 100*time.Millisecond) - require.Eventually(t, TaskActive(store, connectorID, retryableDescriptor), time.Second, 100*time.Millisecond) - }) -} diff --git a/cmd/connectors/internal/task/state.go b/cmd/connectors/internal/task/state.go deleted file mode 100644 index f1e811f8..00000000 --- a/cmd/connectors/internal/task/state.go +++ /dev/null @@ -1,39 +0,0 @@ -package task - -import ( - "context" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/pkg/errors" -) - -type StateResolver interface { - ResolveTo(ctx context.Context, v any) error -} -type StateResolverFn func(ctx context.Context, v any) error - -func (fn StateResolverFn) ResolveTo(ctx context.Context, v any) error { - return fn(ctx, v) -} - -func ResolveTo[State any](ctx context.Context, resolver StateResolver, to *State) (*State, error) { - err := resolver.ResolveTo(ctx, to) - if err != nil { - return nil, err - } - - return to, nil -} - -func MustResolveTo[State any](ctx context.Context, resolver StateResolver, to State) State { - state, err := ResolveTo(ctx, resolver, &to) - if errors.Is(err, storage.ErrNotFound) { - return to - } - - if err != nil { - panic(err) - } - - return *state -} diff --git a/cmd/connectors/internal/task/store.go b/cmd/connectors/internal/task/store.go deleted file mode 100644 index 63d7f9a3..00000000 --- a/cmd/connectors/internal/task/store.go +++ /dev/null @@ -1,21 +0,0 @@ -package task - -import ( - "context" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -type Repository interface { - UpdateTaskStatus(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, status models.TaskStatus, err string) error - FindAndUpsertTask(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor, status models.TaskStatus, schedulerOptions models.TaskSchedulerOptions, err string) (*models.Task, error) - ListTasksByStatus(ctx context.Context, connectorID models.ConnectorID, status models.TaskStatus) ([]*models.Task, error) - ListTasks(ctx context.Context, connectorID models.ConnectorID, q storage.ListTasksQuery) (*bunpaginate.Cursor[models.Task], error) - ReadOldestPendingTask(ctx context.Context, connectorID models.ConnectorID) (*models.Task, error) - GetTask(ctx context.Context, taskID uuid.UUID) (*models.Task, error) - GetTaskByDescriptor(ctx context.Context, connectorID models.ConnectorID, descriptor models.TaskDescriptor) (*models.Task, error) -} diff --git a/cmd/connectors/internal/task/storememory.go b/cmd/connectors/internal/task/storememory.go deleted file mode 100644 index 90112c71..00000000 --- a/cmd/connectors/internal/task/storememory.go +++ /dev/null @@ -1,250 +0,0 @@ -package task - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "strings" - "sync" - "time" - - "github.com/formancehq/go-libs/bun/bunpaginate" - - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/formancehq/payments/internal/models" - "github.com/google/uuid" -) - -type InMemoryStore struct { - mu sync.RWMutex - tasks map[uuid.UUID]models.Task - statuses map[string]models.TaskStatus - created map[string]time.Time - errors map[string]string -} - -func (s *InMemoryStore) GetTask(ctx context.Context, id uuid.UUID) (*models.Task, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - task, ok := s.tasks[id] - if !ok { - return nil, storage.ErrNotFound - } - - return &task, nil -} - -func (s *InMemoryStore) GetTaskByDescriptor( - ctx context.Context, - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, -) (*models.Task, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - id, err := descriptor.EncodeToString() - if err != nil { - return nil, err - } - - status, ok := s.statuses[id] - if !ok { - return nil, storage.ErrNotFound - } - - return &models.Task{ - Descriptor: descriptor.ToMessage(), - Status: status, - Error: s.errors[id], - State: nil, - CreatedAt: s.created[id], - }, nil -} - -func (s *InMemoryStore) ListTasks(ctx context.Context, - connectorID models.ConnectorID, - q storage.ListTasksQuery, -) (*bunpaginate.Cursor[models.Task], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - ret := make([]models.Task, 0) - - for id, status := range s.statuses { - if !strings.HasPrefix(id, fmt.Sprintf("%s/", connectorID)) { - continue - } - - var descriptor models.TaskDescriptor - - ret = append(ret, models.Task{ - Descriptor: descriptor.ToMessage(), - Status: status, - Error: s.errors[id], - State: nil, - CreatedAt: s.created[id], - }) - } - - return &bunpaginate.Cursor[models.Task]{ - PageSize: 15, - HasMore: false, - Previous: "", - Next: "", - Data: ret, - }, nil -} - -func (s *InMemoryStore) ReadOldestPendingTask( - ctx context.Context, - connectorID models.ConnectorID, -) (*models.Task, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - var ( - oldestDate time.Time - oldestID string - ) - - for id, status := range s.statuses { - if status != models.TaskStatusPending { - continue - } - - if oldestDate.IsZero() || s.created[id].Before(oldestDate) { - oldestDate = s.created[id] - oldestID = id - } - } - - if oldestDate.IsZero() { - return nil, storage.ErrNotFound - } - - descriptorStr := strings.Split(oldestID, "/")[1] - - var descriptor models.TaskDescriptor - - data, err := base64.StdEncoding.DecodeString(descriptorStr) - if err != nil { - return nil, err - } - - err = json.Unmarshal(data, &descriptor) - if err != nil { - return nil, err - } - - return &models.Task{ - Descriptor: descriptor.ToMessage(), - Status: models.TaskStatusPending, - State: nil, - CreatedAt: s.created[oldestID], - }, nil -} - -func (s *InMemoryStore) ListTasksByStatus( - ctx context.Context, - connectorID models.ConnectorID, - taskStatus models.TaskStatus, -) ([]*models.Task, error) { - cursor, err := s.ListTasks(ctx, connectorID, storage.NewListTasksQuery(storage.NewPaginatedQueryOptions(storage.TaskQuery{}))) - if err != nil { - return nil, err - } - - ret := make([]*models.Task, 0) - - for _, v := range cursor.Data { - if v.Status != taskStatus { - continue - } - - ret = append(ret, &v) - } - - return ret, nil -} - -func (s *InMemoryStore) FindAndUpsertTask( - ctx context.Context, - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, - status models.TaskStatus, - options models.TaskSchedulerOptions, - taskErr string, -) (*models.Task, error) { - err := s.UpdateTaskStatus(ctx, connectorID, descriptor, status, taskErr) - if err != nil { - return nil, err - } - - return &models.Task{ - Descriptor: descriptor.ToMessage(), - Status: status, - Error: taskErr, - State: nil, - }, nil -} - -func (s *InMemoryStore) UpdateTaskStatus( - ctx context.Context, - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, - status models.TaskStatus, - taskError string, -) error { - s.mu.Lock() - defer s.mu.Unlock() - - taskID, err := descriptor.EncodeToString() - if err != nil { - return err - } - - key := fmt.Sprintf("%s/%s", connectorID, taskID) - - s.statuses[key] = status - - s.errors[key] = taskError - if _, ok := s.created[key]; !ok { - s.created[key] = time.Now() - } - - return nil -} - -func (s *InMemoryStore) Result( - connectorID models.ConnectorID, - descriptor models.TaskDescriptor, -) (models.TaskStatus, string, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - taskID, err := descriptor.EncodeToString() - if err != nil { - panic(err) - } - - key := fmt.Sprintf("%s/%s", connectorID, taskID) - - status, ok := s.statuses[key] - if !ok { - return "", "", false - } - - return status, s.errors[key], true -} - -func NewInMemoryStore() *InMemoryStore { - return &InMemoryStore{ - statuses: make(map[string]models.TaskStatus), - errors: make(map[string]string), - created: make(map[string]time.Time), - } -} - -var _ Repository = &InMemoryStore{} diff --git a/cmd/connectors/internal/task/task.go b/cmd/connectors/internal/task/task.go deleted file mode 100644 index ce267322..00000000 --- a/cmd/connectors/internal/task/task.go +++ /dev/null @@ -1,3 +0,0 @@ -package task - -type Task any diff --git a/cmd/connectors/root.go b/cmd/connectors/root.go deleted file mode 100644 index 8c46a05a..00000000 --- a/cmd/connectors/root.go +++ /dev/null @@ -1,46 +0,0 @@ -package connectors - -import ( - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/aws/iam" - "github.com/formancehq/go-libs/otlp" - "github.com/formancehq/go-libs/otlp/otlpmetrics" - "github.com/formancehq/go-libs/otlp/otlptraces" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/go-libs/service" - "github.com/spf13/cobra" -) - -func NewConnectors( - version string, - addAutoMigrateCommandFunc func(cmd *cobra.Command), -) *cobra.Command { - - root := &cobra.Command{ - Use: "connectors", - Short: "connectors", - DisableAutoGenTag: true, - } - - cobra.EnableTraverseRunHooks = true - - server := newServer(version) - addAutoMigrateCommandFunc(server) - root.AddCommand(server) - - server.Flags().BoolP("toggle", "t", false, "Help message for toggle") - server.Flags().String(postgresURIFlag, "postgres://localhost/payments", "PostgreSQL DB address") - server.Flags().String(configEncryptionKeyFlag, "", "Config encryption key") - server.Flags().String(envFlag, "local", "Environment") - server.Flags().String(listenFlag, ":8080", "Listen address") - - service.AddFlags(server.Flags()) - otlp.AddFlags(server.Flags()) - otlptraces.AddFlags(server.Flags()) - otlpmetrics.AddFlags(server.Flags()) - publish.AddFlags(serviceName, server.Flags()) - iam.AddFlags(server.Flags()) - auth.AddFlags(server.Flags()) - - return root -} diff --git a/cmd/connectors/serve.go b/cmd/connectors/serve.go deleted file mode 100644 index 2a9f3649..00000000 --- a/cmd/connectors/serve.go +++ /dev/null @@ -1,94 +0,0 @@ -package connectors - -import ( - "github.com/bombsimon/logrusr/v3" - sharedapi "github.com/formancehq/go-libs/api" - "github.com/formancehq/go-libs/auth" - "github.com/formancehq/go-libs/bun/bunconnect" - "github.com/formancehq/go-libs/otlp/otlpmetrics" - "github.com/formancehq/go-libs/otlp/otlptraces" - "github.com/formancehq/go-libs/publish" - "github.com/formancehq/go-libs/service" - "github.com/formancehq/payments/cmd/connectors/internal/api" - "github.com/formancehq/payments/cmd/connectors/internal/metrics" - "github.com/formancehq/payments/cmd/connectors/internal/storage" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/metric/noop" - "go.uber.org/fx" -) - -const ( - stackURLFlag = "stack-url" - postgresURIFlag = "postgres-uri" - configEncryptionKeyFlag = "config-encryption-key" - envFlag = "env" - listenFlag = "listen" - - serviceName = "Payments" -) - -func newServer(version string) *cobra.Command { - return &cobra.Command{ - Use: "serve", - Aliases: []string{"server"}, - Short: "Launch server", - SilenceUsage: true, - RunE: runServer(version), - } -} - -func runServer(version string) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - setLogger() - - databaseOptions, err := prepareDatabaseOptions(cmd, service.IsDebug(cmd)) - if err != nil { - return err - } - - options := make([]fx.Option, 0) - - options = append(options, databaseOptions) - options = append(options, - otlptraces.FXModuleFromFlags(cmd), - otlpmetrics.FXModuleFromFlags(cmd), - auth.FXModuleFromFlags(cmd), - fx.Provide(fx.Annotate(noop.NewMeterProvider, fx.As(new(metric.MeterProvider)))), - fx.Provide(metrics.RegisterMetricsRegistry), - ) - options = append(options, publish.FXModuleFromFlags(cmd, service.IsDebug(cmd))) - listen, _ := cmd.Flags().GetString(listenFlag) - stackURL, _ := cmd.Flags().GetString(stackURLFlag) - otelTraces, _ := cmd.Flags().GetBool(otlptraces.OtelTracesFlag) - - options = append(options, api.HTTPModule(sharedapi.ServiceInfo{ - Version: version, - Debug: service.IsDebug(cmd), - }, listen, stackURL, otelTraces)) - - return service.New(cmd.OutOrStdout(), options...).Run(cmd) - } -} - -func setLogger() { - // Add a dedicated logger for opentelemetry in case of error - otel.SetLogger(logrusr.New(logrus.New().WithField("component", "otlp"))) -} - -func prepareDatabaseOptions(cmd *cobra.Command, debug bool) (fx.Option, error) { - configEncryptionKey, _ := cmd.Flags().GetString(configEncryptionKeyFlag) - if configEncryptionKey == "" { - return nil, errors.New("missing config encryption key") - } - - connectionOptions, err := bunconnect.ConnectionOptionsFromFlags(cmd) - if err != nil { - return nil, err - } - - return storage.Module(*connectionOptions, configEncryptionKey, debug), nil -} diff --git a/cmd/migrate.go b/cmd/migrate.go index 050f361b..40e84bbf 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -1,8 +1,7 @@ package cmd import ( - "github.com/formancehq/go-libs/bun/bunmigrate" - + "github.com/formancehq/go-libs/v2/bun/bunmigrate" "github.com/formancehq/payments/internal/storage" "github.com/spf13/cobra" "github.com/uptrace/bun" @@ -12,25 +11,26 @@ import ( ) var ( - configEncryptionKeyFlag = "config-encryption-key" - autoMigrateFlag = "auto-migrate" + autoMigrateFlag = "auto-migrate" ) func newMigrate() *cobra.Command { - return bunmigrate.NewDefaultCommand(Migrate, func(cmd *cobra.Command) { - cmd.Flags().String(configEncryptionKeyFlag, "", "Config encryption key") + cmd := bunmigrate.NewDefaultCommand(Migrate, func(cmd *cobra.Command) { + cmd.Flags().String(ConfigEncryptionKeyFlag, "", "Config encryption key") }) + + return cmd } func Migrate(cmd *cobra.Command, args []string, db *bun.DB) error { - cfgEncryptionKey, _ := cmd.Flags().GetString(configEncryptionKeyFlag) + cfgEncryptionKey, _ := cmd.Flags().GetString(ConfigEncryptionKeyFlag) if cfgEncryptionKey == "" { - cfgEncryptionKey = cmd.Flag(configEncryptionKeyFlag).Value.String() + cfgEncryptionKey = cmd.Flag(ConfigEncryptionKeyFlag).Value.String() } if cfgEncryptionKey != "" { storage.EncryptionKey = cfgEncryptionKey } - return storage.Migrate(cmd.Context(), db) + return storage.Migrate(cmd.Context(), db, cfgEncryptionKey) } diff --git a/cmd/root.go b/cmd/root.go index 59ce23bb..1c13b6da 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,20 +1,48 @@ -//nolint:gochecknoglobals,golint,revive // allow for cobra & logrus init package cmd import ( - "github.com/formancehq/go-libs/bun/bunmigrate" - "github.com/formancehq/go-libs/service" + "errors" + "log" + "os" _ "github.com/bombsimon/logrusr/v3" - "github.com/formancehq/payments/cmd/api" - "github.com/formancehq/payments/cmd/connectors" + sharedapi "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/auth" + "github.com/formancehq/go-libs/v2/bun/bunconnect" + "github.com/formancehq/go-libs/v2/bun/bunmigrate" + "github.com/formancehq/go-libs/v2/health" + "github.com/formancehq/go-libs/v2/licence" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/otlp" + "github.com/formancehq/go-libs/v2/otlp/otlpmetrics" + "github.com/formancehq/go-libs/v2/otlp/otlptraces" + "github.com/formancehq/go-libs/v2/profiling" + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/go-libs/v2/service" + "github.com/formancehq/go-libs/v2/temporal" + "github.com/formancehq/payments/internal/api" + v2 "github.com/formancehq/payments/internal/api/v2" + v3 "github.com/formancehq/payments/internal/api/v3" + "github.com/formancehq/payments/internal/connectors/engine" + "github.com/formancehq/payments/internal/connectors/metrics" + "github.com/formancehq/payments/internal/storage" "github.com/spf13/cobra" + "go.uber.org/fx" ) var ( - Version = "develop" - BuildDate = "-" - Commit = "-" + ServiceName = "payments" + Version = "develop" + BuildDate = "-" + Commit = "-" +) + +const ( + ConfigEncryptionKeyFlag = "config-encryption-key" + ListenFlag = "listen" + StackFlag = "stack" + stackPublicURLFlag = "stack-public-url" + temporalMaxConcurrentWorkflowTaskPollersFlag = "temporal-max-concurrent-workflow-task-pollers" ) func NewRootCommand() *cobra.Command { @@ -25,17 +53,25 @@ func NewRootCommand() *cobra.Command { Version: Version, } + root.PersistentFlags().String(ConfigEncryptionKeyFlag, "", "Config encryption key") + version := newVersion() root.AddCommand(version) migrate := newMigrate() root.AddCommand(migrate) - api := api.NewAPI(Version, addAutoMigrateCommand) - root.AddCommand(api) - - connectors := connectors.NewConnectors(Version, addAutoMigrateCommand) - root.AddCommand(connectors) + server := newServer() + addAutoMigrateCommand(server) + server.Flags().String(ListenFlag, ":8080", "Listen address") + server.Flags().String(StackFlag, "", "Stack name") + server.Flags().String(stackPublicURLFlag, "", "Stack public url") + // MaxConcurrentWorkflowTaskPollers should not be set to a number < 2, otherwise + // temporal will panic. + // After meeting with the temporal team, we decided to set it to 20 as per + // their recommendation. + server.Flags().Int(temporalMaxConcurrentWorkflowTaskPollersFlag, 20, "Max concurrent workflow task pollers") + root.AddCommand(server) return root } @@ -54,3 +90,70 @@ func addAutoMigrateCommand(cmd *cobra.Command) { return nil } } + +func commonOptions(cmd *cobra.Command) (fx.Option, error) { + configEncryptionKey, _ := cmd.Flags().GetString(ConfigEncryptionKeyFlag) + if configEncryptionKey == "" { + return nil, errors.New("missing config encryption key") + } + + connectionOptions, err := bunconnect.ConnectionOptionsFromFlags(cmd) + if err != nil { + return nil, err + } + + listen, _ := cmd.Flags().GetString(ListenFlag) + stack, _ := cmd.Flags().GetString(StackFlag) + stackPublicURL, _ := cmd.Flags().GetString(stackPublicURLFlag) + debug, _ := cmd.Flags().GetBool(service.DebugFlag) + jsonFormatter, _ := cmd.Flags().GetBool(logging.JsonFormattingLoggerFlag) + temporalNamespace, _ := cmd.Flags().GetString(temporal.TemporalNamespaceFlag) + temporalMaxConcurrentWorkflowTaskPollers, _ := cmd.Flags().GetInt(temporalMaxConcurrentWorkflowTaskPollersFlag) + + if len(os.Args) < 2 { + // this shouldn't happen as long as this function is called by a subcommand + log.Fatalf("os arguments does not contain command name: %s", os.Args) + } + rawFlags := os.Args[2:] + + return fx.Options( + fx.Provide(func() *bunconnect.ConnectionOptions { + return connectionOptions + }), + fx.Provide(func() sharedapi.ServiceInfo { + return sharedapi.ServiceInfo{ + Version: Version, + } + }), + otlp.FXModuleFromFlags(cmd), + otlptraces.FXModuleFromFlags(cmd), + otlpmetrics.FXModuleFromFlags(cmd), + fx.Provide(metrics.RegisterMetricsRegistry), + fx.Invoke(func(metrics.MetricsRegistry) {}), + temporal.FXModuleFromFlags( + cmd, + engine.Tracer, + temporal.SearchAttributes{ + SearchAttributes: engine.SearchAttributes, + }, + ), + auth.FXModuleFromFlags(cmd), + health.Module(), + publish.FXModuleFromFlags(cmd, service.IsDebug(cmd)), + licence.FXModuleFromFlags(cmd, ServiceName), + storage.Module(cmd, *connectionOptions, configEncryptionKey), + api.NewModule(listen, service.IsDebug(cmd)), + profiling.FXModuleFromFlags(cmd), + engine.Module( + stack, + stackPublicURL, + temporalNamespace, + temporalMaxConcurrentWorkflowTaskPollers, + rawFlags, + debug, + jsonFormatter, + ), + v2.NewModule(), + v3.NewModule(), + ), nil +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 00000000..2354a3c5 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "github.com/bombsimon/logrusr/v3" + "github.com/formancehq/go-libs/v2/auth" + "github.com/formancehq/go-libs/v2/aws/iam" + "github.com/formancehq/go-libs/v2/bun/bunconnect" + "github.com/formancehq/go-libs/v2/licence" + "github.com/formancehq/go-libs/v2/otlp/otlpmetrics" + "github.com/formancehq/go-libs/v2/otlp/otlptraces" + "github.com/formancehq/go-libs/v2/profiling" + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/go-libs/v2/service" + "github.com/formancehq/go-libs/v2/temporal" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.opentelemetry.io/otel" +) + +func newServer() *cobra.Command { + cmd := &cobra.Command{ + Use: "serve", + Aliases: []string{"server"}, + Short: "Launch api server", + SilenceUsage: true, + RunE: runServer(), + } + + service.AddFlags(cmd.Flags()) + otlpmetrics.AddFlags(cmd.Flags()) + otlptraces.AddFlags(cmd.Flags()) + auth.AddFlags(cmd.Flags()) + publish.AddFlags(ServiceName, cmd.Flags()) + bunconnect.AddFlags(cmd.Flags()) + iam.AddFlags(cmd.Flags()) + profiling.AddFlags(cmd.Flags()) + temporal.AddFlags(cmd.Flags()) + licence.AddFlags(cmd.Flags()) + + return cmd +} + +func runServer() func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + setLogger() + + options, err := commonOptions(cmd) + if err != nil { + return err + } + + return service.New(cmd.OutOrStdout(), options).Run(cmd) + } +} + +func setLogger() { + // Add a dedicated logger for opentelemetry in case of error + otel.SetLogger(logrusr.New(logrus.New().WithField("component", "otlp"))) +} diff --git a/docker-compose.yml b/docker-compose.yml index e58c15f7..b9ccd755 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,47 +21,59 @@ services: - ./local_env/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql payments-migrate: - image: golang:1.22.4-alpine3.19 + image: golang:1.22.8-alpine command: go run ./ migrate up depends_on: postgres: condition: service_healthy volumes: - .:/app/components/payments - - ../../libs:/app/libs working_dir: /app/components/payments environment: POSTGRES_URI: postgres://payments:payments@postgres:${POSTGRES_PORT:-5432}/payments?sslmode=disable - payments-api: - image: golang:1.22.4-alpine3.19 - command: go run ./ api server - healthcheck: - test: [ "CMD", "curl", "-f", "http://127.0.0.1:8080/_healthcheck" ] - interval: 10s - timeout: 5s - retries: 5 + temporal: + image: temporalio/auto-setup:1.25.0 depends_on: postgres: condition: service_healthy - payments-migrate: - condition: service_completed_successfully + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: payments + POSTGRES_PWD: payments + POSTGRES_SEEDS: postgres + DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/development-sql.yaml ports: - - "8080:8080" + - 7233:7233 volumes: - - .:/app/components/payments - - ../../libs:/app/libs - working_dir: /app/components/payments + - ./local_env/postgres/temporal-sql.yaml:/etc/temporal/config/dynamicconfig/development-sql.yaml + temporal-admin-tools: + container_name: temporal-admin-tools + depends_on: + - temporal environment: - DEBUG: ${DEBUG:-"true"} - POSTGRES_URI: postgres://payments:payments@postgres:${POSTGRES_PORT:-5432}/payments?sslmode=disable - CONFIG_ENCRYPTION_KEY: mysuperencryptionkey + - TEMPORAL_ADDRESS=temporal:7233 + - TEMPORAL_CLI_ADDRESS=temporal:7233 + image: temporalio/admin-tools:1.25.0-tctl-1.18.1-cli-1.0.0 + stdin_open: true + tty: true + temporal-ui: + container_name: temporal-ui + depends_on: + - temporal + environment: + - TEMPORAL_ADDRESS=temporal:7233 + - TEMPORAL_CORS_ORIGINS=http://localhost:3000 + image: temporalio/ui:2.26.2 + ports: + - 8081:8080 - payments-connectors: - image: golang:1.22.4-alpine3.19 - command: go run ./ connectors server + payments: + image: golang:1.22.8-alpine + command: go run ./ server healthcheck: - test: [ "CMD", "curl", "-f", "http://127.0.0.1:8081/_healthcheck" ] + test: [ "CMD", "curl", "-f", "http://127.0.0.1:8080/_healthcheck" ] interval: 10s timeout: 5s retries: 5 @@ -71,12 +83,15 @@ services: payments-migrate: condition: service_completed_successfully ports: - - "8081:8080" + - "8080:8080" + - "9090:9090" volumes: - - .:/app/components/payments - - ../../libs:/app/libs + - .:/app/components/payments working_dir: /app/components/payments environment: - DEBUG: ${DEBUG:-"true"} + DEBUG: true POSTGRES_URI: postgres://payments:payments@postgres:${POSTGRES_PORT:-5432}/payments?sslmode=disable - CONFIG_ENCRYPTION_KEY: mysuperencryptionkey \ No newline at end of file + CONFIG_ENCRYPTION_KEY: mysuperencryptionkey + TEMPORAL_ADDRESS: temporal:7233 + PLUGIN_MAGIC_COOKIE: mysupercookie + TEMPORAL_INIT_SEARCH_ATTRIBUTES: true diff --git a/go.mod b/go.mod index ce4081d2..bc000cdf 100644 --- a/go.mod +++ b/go.mod @@ -1,47 +1,50 @@ module github.com/formancehq/payments -go 1.22.0 +go 1.22.8 -toolchain go1.22.7 +replace github.com/formancehq/payments/genericclient => ./internal/connectors/plugins/public/generic/client/generated require ( - github.com/ThreeDotsLabs/watermill v1.3.7 + github.com/ThreeDotsLabs/watermill v1.4.1 github.com/adyen/adyen-go-api-library/v7 v7.3.1 - github.com/alitto/pond v1.8.3 github.com/bombsimon/logrusr/v3 v3.1.0 - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/formancehq/go-libs v1.7.1 + github.com/formancehq/go-libs v1.7.2 + github.com/formancehq/go-libs/v2 v2.0.1-0.20241128191336-ae97d7d27bf4 github.com/formancehq/payments/genericclient v0.0.0-00010101000000-000000000000 - github.com/get-momo/atlar-v1-go-client v1.2.1 + github.com/get-momo/atlar-v1-go-client v1.4.0 github.com/gibson042/canonicaljson-go v1.0.3 - github.com/go-openapi/runtime v0.26.0 - github.com/go-openapi/strfmt v0.21.8 - github.com/golang/mock v1.6.0 + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-openapi/runtime v0.28.0 + github.com/go-openapi/strfmt v0.23.0 + github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 - github.com/gorilla/mux v1.8.1 - github.com/hashicorp/golang-lru/v2 v2.0.4 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/invopop/jsonschema v0.12.0 github.com/jackc/pgx/v5 v5.7.1 + github.com/jackc/pgxlisten v0.0.0-20241005155529-9d952acd6a6c github.com/lib/pq v1.10.9 + github.com/nats-io/nats.go v1.37.0 + github.com/onsi/ginkgo/v2 v2.22.0 + github.com/onsi/gomega v1.36.0 github.com/pkg/errors v0.9.1 - github.com/rs/cors v1.11.1 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.9.0 - github.com/stripe/stripe-go/v72 v72.122.0 - github.com/uptrace/bun v1.2.3 - github.com/uptrace/bun/dialect/pgdialect v1.2.3 - github.com/uptrace/bun/extra/bundebug v1.2.3 - go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 - go.opentelemetry.io/otel v1.30.0 - go.opentelemetry.io/otel/metric v1.30.0 - go.opentelemetry.io/otel/trace v1.30.0 - go.uber.org/dig v1.18.0 - go.uber.org/fx v1.22.2 - go.uber.org/mock v0.4.0 + github.com/stretchr/testify v1.10.0 + github.com/stripe/stripe-go/v79 v79.12.0 + github.com/uptrace/bun v1.2.5 + github.com/uptrace/bun/dialect/pgdialect v1.2.5 + github.com/xeipuuv/gojsonschema v1.2.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 + go.opentelemetry.io/otel v1.32.0 + go.opentelemetry.io/otel/metric v1.32.0 + go.opentelemetry.io/otel/trace v1.32.0 + go.temporal.io/api v1.41.0 + go.temporal.io/sdk v1.30.0 + go.uber.org/fx v1.23.0 + go.uber.org/mock v0.5.0 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.8.0 + golang.org/x/sync v0.9.0 ) require ( @@ -53,26 +56,30 @@ require ( github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 // indirect github.com/ThreeDotsLabs/watermill-kafka/v3 v3.0.5 // indirect - github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.1 // indirect + github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.2 // indirect github.com/ajg/form v1.5.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-msk-iam-sasl-signer-go v1.0.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect - github.com/aws/aws-sdk-go-v2/config v1.27.36 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.34 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.4.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect + github.com/aws/aws-sdk-go-v2 v1.32.5 // indirect + github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.23.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.31.0 // indirect - github.com/aws/smithy-go v1.21.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect + github.com/aws/smithy-go v1.22.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/continuity v0.4.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/docker/cli v27.3.1+incompatible // indirect github.com/docker/docker v27.3.1+incompatible // indirect @@ -81,32 +88,38 @@ require ( github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect - github.com/fatih/color v1.17.0 // indirect + github.com/ebitengine/purego v0.8.1 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect - github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/analysis v0.21.4 // indirect - github.com/go-openapi/errors v0.20.4 // indirect - github.com/go-openapi/jsonpointer v0.20.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/loads v0.21.2 // indirect - github.com/go-openapi/spec v0.20.9 // indirect - github.com/go-openapi/swag v0.22.4 // indirect - github.com/go-openapi/validate v0.22.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/validate v0.24.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/golang/mock v1.7.0-rc.1 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect + github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -123,83 +136,96 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx v1.2.30 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/highwayhash v1.0.3 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect github.com/muhlemmer/gu v0.3.1 // indirect github.com/muhlemmer/httpforwarded v0.1.0 // indirect - github.com/nats-io/nats.go v1.37.0 // indirect + github.com/nats-io/jwt/v2 v2.7.0 // indirect + github.com/nats-io/nats-server/v2 v2.10.22 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/nexus-rpc/sdk-go v0.0.11 // indirect github.com/oklog/ulid v1.3.1 // indirect - github.com/onsi/ginkgo/v2 v2.20.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/runc v1.1.14 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/ory/dockertest/v3 v3.11.0 // indirect + github.com/pborman/uuid v1.2.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect - github.com/riandyrn/otelchi v0.10.0 // indirect - github.com/shirou/gopsutil/v4 v4.24.8 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/riandyrn/otelchi v0.11.0 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/shirou/gopsutil/v4 v4.24.10 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.8.0 // indirect + github.com/tklauser/numcpus v0.9.0 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect - github.com/uptrace/bun/extra/bunotel v1.2.3 // indirect + github.com/uptrace/bun/extra/bunotel v1.2.5 // indirect github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 // indirect github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xo/dburl v0.23.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zitadel/oidc/v2 v2.12.2 // indirect - go.mongodb.org/mongo-driver v1.12.0 // indirect - go.opentelemetry.io/contrib/instrumentation/host v0.55.0 // indirect - go.opentelemetry.io/contrib/instrumentation/runtime v0.55.0 // indirect - go.opentelemetry.io/contrib/propagators/b3 v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect + go.opentelemetry.io/contrib/instrumentation/host v0.57.0 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.57.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect go.opentelemetry.io/otel/log v0.6.0 // indirect - go.opentelemetry.io/otel/sdk v1.30.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.30.0 // indirect + go.opentelemetry.io/otel/sdk v1.32.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.temporal.io/sdk/contrib/opentelemetry v0.6.0 // indirect + go.uber.org/dig v1.18.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.27.0 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect - golang.org/x/tools v0.25.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect - google.golang.org/grpc v1.67.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/time v0.7.0 // indirect + golang.org/x/tools v0.26.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect + google.golang.org/grpc v1.68.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/formancehq/payments/genericclient => ./cmd/connectors/internal/connectors/generic/client/generated diff --git a/go.sum b/go.sum index fc175621..3b2084cf 100644 --- a/go.sum +++ b/go.sum @@ -613,14 +613,14 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/ThreeDotsLabs/watermill v1.3.7 h1:NV0PSTmuACVEOV4dMxRnmGXrmbz8U83LENOvpHekN7o= -github.com/ThreeDotsLabs/watermill v1.3.7/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill v1.4.1 h1:gjP6yZH+otMPjV0KsV07pl9TeMm9UQV/gqiuiuG5Drs= +github.com/ThreeDotsLabs/watermill v1.4.1/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 h1:M0iYM5HsGcoxtiQqprRlYZNZnGk3w5LsE9RbC2R8myQ= github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1/go.mod h1:RwGHEzGsEEXC/rQNLWQqR83+WPlABgOgnv2kTB56Y4Y= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.0.5 h1:ud+4txnRgtr3kZXfXZ5+C7kVQEvsLc5HSNUEa0g+X1Q= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.0.5/go.mod h1:t4o+4A6GB+XC8WL3DandhzPwd265zQuyWMQC/I+WIOU= -github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.1 h1:afAkAFzeooBRQvxElR+6xoigXKCukcZXnE9ACxhwlPI= -github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.1/go.mod h1:stjbT+s4u/s5ime5jdIyvPyjBGwGeJewIN7jxH8gp4k= +github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.2 h1:9d7Vb2gepq73Rn/aKaAJWbBiJzS6nDyOm4O353jVsTM= +github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.2/go.mod h1:stjbT+s4u/s5ime5jdIyvPyjBGwGeJewIN7jxH8gp4k= github.com/adyen/adyen-go-api-library/v7 v7.3.1 h1:NToWy5oZDH5Juz45h9GTlidGFldW10xvaihCJIOWZcw= github.com/adyen/adyen-go-api-library/v7 v7.3.1/go.mod h1:z9oHJsUpqgCkBhKa8hpBgQvTU8ObRfvO0NKEYUoocx0= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= @@ -629,50 +629,52 @@ github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= -github.com/alitto/pond v1.8.3 h1:ydIqygCLVPqIX/USe5EaV/aSRXTRXDEI9JwuDdu+/xs= -github.com/alitto/pond v1.8.3/go.mod h1:CmvIIGd5jKLasGI3D87qDkQxjzChdKMmnXMg3fG6M6Q= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= -github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-msk-iam-sasl-signer-go v1.0.0 h1:UyjtGmO0Uwl/K+zpzPwLoXzMhcN9xmnR2nrqJoBrg3c= github.com/aws/aws-msk-iam-sasl-signer-go v1.0.0/go.mod h1:TJAXuFs2HcMib3sN5L0gUC+Q01Qvy3DemvA55WuC+iA= -github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U= -github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA= -github.com/aws/aws-sdk-go-v2/config v1.27.36 h1:4IlvHh6Olc7+61O1ktesh0jOcqmq/4WG6C2Aj5SKXy0= -github.com/aws/aws-sdk-go-v2/config v1.27.36/go.mod h1:IiBpC0HPAGq9Le0Xxb1wpAKzEfAQ3XlYgJLYKEVYcfw= -github.com/aws/aws-sdk-go-v2/credentials v1.17.34 h1:gmkk1l/cDGSowPRzkdxYi8edw+gN4HmVK151D/pqGNc= -github.com/aws/aws-sdk-go-v2/credentials v1.17.34/go.mod h1:4R9OEV3tgFMsok4ZeFpExn7zQaZRa9MRGFYnI/xC/vs= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078= -github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.4.18 h1:k51348zRERIvv01FflXAOQj50NeUiZUGOEedT4Vg+UE= -github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.4.18/go.mod h1:uybY6ESdxsT2dpzwSmpDgZJ3ekCYwVe/ZFYfAaXUbtU= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc= +github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo= +github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0= +github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o= +github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.4.24 h1:HfLyPCysN3MqXSQIP83f/0fNTvb8ELXBv76Jaa3LvCs= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.4.24/go.mod h1:WNDtzVHjS5Ct1HJLcVaclQivrWvK3lQWmQkaT7tzr4M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 h1:QFASJGfT8wMXtuP3D5CRmMjARHv9ZmzFUMJznHDOY3w= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44URTiHNx6PNo0ujDE6ERlsCKJD3u1zfnzAPg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg= -github.com/aws/aws-sdk-go-v2/service/sso v1.23.0 h1:fHySkG0IGj2nepgGJPmmhZYL9ndnsq1Tvc6MeuVQCaQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.23.0/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.0 h1:cU/OeQPNReyMj1JEBgjE29aclYZYtXcsPMXbTkVGMFk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.0/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E= -github.com/aws/aws-sdk-go-v2/service/sts v1.31.0 h1:GNVxIHBTi2EgwCxpNiozhNasMOK+ROUA2Z3X+cSBX58= -github.com/aws/aws-sdk-go-v2/service/sts v1.31.0/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI= -github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA= -github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bombsimon/logrusr/v3 v3.1.0 h1:zORbLM943D+hDMGgyjMhSAz/iDz86ZV72qaak/CA0zQ= github.com/bombsimon/logrusr/v3 v3.1.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -708,6 +710,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ= @@ -726,6 +730,8 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4A github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE= +github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -741,18 +747,23 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/formancehq/go-libs v1.7.1 h1:9D5cxKWFlVtdX5AYDXeUz1Nb9PdoEfQX0f/yeLsU324= -github.com/formancehq/go-libs v1.7.1/go.mod h1:pWTScpoyieF7OoJ6WVmXNG9NhDjbZbAmFqd7UOw85iI= +github.com/formancehq/go-libs v1.7.2 h1:zPLkMVigMxcdPQiA8Q0HLPgA/al/hKmLxLw9muDPM1U= +github.com/formancehq/go-libs v1.7.2/go.mod h1:3+crp7AA/Rllpo9M/ZQslaHkYt9EtXtbE4qYasV201Q= +github.com/formancehq/go-libs/v2 v2.0.1-0.20241128191336-ae97d7d27bf4 h1:6Ubz3Xhrd8vOxE9xH5odFwij2okVDzIJaje5+x8aJfc= +github.com/formancehq/go-libs/v2 v2.0.1-0.20241128191336-ae97d7d27bf4/go.mod h1:yKik5nzTVomyytlW3KwaVdaN/5hbZIQBQ8LZjfi/MYY= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/get-momo/atlar-v1-go-client v1.2.1 h1:sKWd0maMshxBErGXsYVGhGIB+zFxynrWLNHnegB4lXs= -github.com/get-momo/atlar-v1-go-client v1.2.1/go.mod h1:qcLoXEhjTCOeBqAzG2tucpvxGJS2LYNwaU7WnJYnO64= +github.com/get-momo/atlar-v1-go-client v1.4.0 h1:DIRwP3gRfvdXAxEeMNua6HPsScXZzDB9nySdYVv5FD0= +github.com/get-momo/atlar-v1-go-client v1.4.0/go.mod h1:qcLoXEhjTCOeBqAzG2tucpvxGJS2LYNwaU7WnJYnO64= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gibson042/canonicaljson-go v1.0.3 h1:EAyF8L74AWabkyUmrvEFHEt/AGFQeD6RfwbAuf0j1bI= github.com/gibson042/canonicaljson-go v1.0.3/go.mod h1:DsLpJTThXyGNO+KZlI85C1/KDcImpP67k/RKVjcaEqo= @@ -770,8 +781,10 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -780,48 +793,42 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/analysis v0.21.4 h1:ZDFLvSNxpDaomuCueM0BlSXxpANBlFYiBvr+GXrvIHc= -github.com/go-openapi/analysis v0.21.4/go.mod h1:4zQ35W4neeZTqh3ol0rv/O8JBbka9QyAgQRPp9y3pfo= -github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= -github.com/go-openapi/errors v0.20.4 h1:unTcVm6PispJsMECE3zWgvG4xTiKda1LIR5rCRWLG6M= -github.com/go-openapi/errors v0.20.4/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= -github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/loads v0.21.2 h1:r2a/xFIYeZ4Qd2TnGpWDIQNcP80dIaZgf704za8enro= -github.com/go-openapi/loads v0.21.2/go.mod h1:Jq58Os6SSGz0rzh62ptiu8Z31I+OTHqmULx5e/gJbNw= -github.com/go-openapi/runtime v0.26.0 h1:HYOFtG00FM1UvqrcxbEJg/SwvDRvYLQKGhw2zaQjTcc= -github.com/go-openapi/runtime v0.26.0/go.mod h1:QgRGeZwrUcSHdeh4Ka9Glvo0ug1LC5WyE+EV88plZrQ= -github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8= -github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg= -github.com/go-openapi/strfmt v0.21.8 h1:VYBUoKYRLAlgKDrIxR/I0lKrztDQ0tuTDrbhLVP8Erg= -github.com/go-openapi/strfmt v0.21.8/go.mod h1:adeGTkxE44sPyLk0JV235VQAO/ZXUr8KAzYjclFs3ew= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/validate v0.22.2 h1:Lda8nadL/5kIvS5mdXCAIuZ7IVXvKFIppLnw+EZh+n0= -github.com/go-openapi/validate v0.22.2/go.mod h1:kVxh31KbfsxU8ZyoHaDbLBWU5CnMdqBUEtadQ2G4d5M= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= @@ -838,8 +845,9 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -858,7 +866,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -904,15 +913,15 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 h1:c5FlPPgxOn7kJz3VoPLkQYQXGBS3EklQ4Zfi57uOuqQ= -github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -948,11 +957,13 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -969,19 +980,23 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0= -github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/pgxlisten v0.0.0-20241005155529-9d952acd6a6c h1:/u9tWJZ5d+RnlpVuvf352pGb+CzTrJP+r+ETy4JEHyo= +github.com/jackc/pgxlisten v0.0.0-20241005155529-9d952acd6a6c/go.mod h1:EqjCOzkITPCEI0My7BdE2xm3r0fZ7OZycVDP+ki1ASA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -1012,11 +1027,11 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -1027,6 +1042,19 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.30 h1:VKIFrmjYn0z2J51iLPadqoHIVLzvWNa1kCsTqNDHYPA= +github.com/lestrrat-go/jwx v1.2.30/go.mod h1:vMxrwFhunGZ3qddmfmEm2+uced8MSI6QFWGTKygjSzQ= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= @@ -1036,14 +1064,14 @@ github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dt github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -1052,46 +1080,47 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcs github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/nats-io/jwt/v2 v2.7.0 h1:J+ZnaaMGQi3xSB8iOhVM5ipiWCDrQvgEoitTwWFyOYw= github.com/nats-io/jwt/v2 v2.7.0/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A= -github.com/nats-io/nats-server/v2 v2.10.20 h1:CXDTYNHeBiAKBTAIP2gjpgbWap2GhATnTLgP8etyvEI= -github.com/nats-io/nats-server/v2 v2.10.20/go.mod h1:hgcPnoUtMfxz1qVOvLZGurVypQ+Cg6GXVXjG53iHk+M= +github.com/nats-io/nats-server/v2 v2.10.22 h1:Yt63BGu2c3DdMoBZNcR6pjGQwk/asrKU7VX846ibxDA= +github.com/nats-io/nats-server/v2 v2.10.22/go.mod h1:X/m1ye9NYansUXYFrbcDwUi/blHkrgHh2rgCJaakonk= github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE= github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nexus-rpc/sdk-go v0.0.11 h1:qH3Us3spfp50t5ca775V1va2eE6z1zMQDZY4mvbw0CI= +github.com/nexus-rpc/sdk-go v0.0.11/go.mod h1:TpfkM2Cw0Rlk9drGkoiSMpFqflKTiQLWUNyKJjF8mKQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= -github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= -github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= -github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.36.0 h1:Pb12RlruUtj4XUuPUqeEWc6j5DkVVVA49Uf6YLfC95Y= +github.com/onsi/gomega v1.36.0/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/runc v1.1.14 h1:rgSuzbmgz5DUJjeSnw337TxDbRuqjs6iqQck/2weR6w= github.com/opencontainers/runc v1.1.14/go.mod h1:E4C2z+7BxR7GHXp0hAY53mek+x49X1LjPNeMTfRGvOA= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA= github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -1117,40 +1146,40 @@ github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPK github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/riandyrn/otelchi v0.10.0 h1:QMbR/FMDWBOkej6dfyWteYefUKqIFxnyrpaoWRJ9RPQ= -github.com/riandyrn/otelchi v0.10.0/go.mod h1:zBaX2FavWMlsvq4GqHit+QXxF1c5wIMZZFaYyW4+7FA= +github.com/riandyrn/otelchi v0.11.0 h1:x9MFoTgHcwCC2DdWkTEEZ2ZQFkbl6z7GXLQtTANN6Gk= +github.com/riandyrn/otelchi v0.11.0/go.mod h1:FlBYmG9fBQu0jFRvZZrATP4mDvLX2H5gwELfpZvNlxY= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= -github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI= -github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM= +github.com/shirou/gopsutil/v4 v4.24.10/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -1158,28 +1187,28 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stripe/stripe-go/v72 v72.122.0 h1:eRXWqnEwGny6dneQ5BsxGzUCED5n180u8n665JHlut8= -github.com/stripe/stripe-go/v72 v72.122.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stripe/stripe-go/v79 v79.12.0 h1:HQs/kxNEB3gYA7FnkSFkp0kSOeez0fsmCWev6SxftYs= +github.com/stripe/stripe-go/v79 v79.12.0/go.mod h1:cuH6X0zC8peY6f1AubHwgJ/fJSn2dh5pfiCr6CjyKVU= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= -github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= +github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= -github.com/uptrace/bun v1.2.3 h1:6KDc6YiNlXde38j9ATKufb8o7MS8zllhAOeIyELKrk0= -github.com/uptrace/bun v1.2.3/go.mod h1:8frYFHrO/Zol3I4FEjoXam0HoNk+t5k7aJRl3FXp0mk= -github.com/uptrace/bun/dialect/pgdialect v1.2.3 h1:YyCxxqeL0lgFWRZzKCOt6mnxUsjqITcxSo0mLqgwMUA= -github.com/uptrace/bun/dialect/pgdialect v1.2.3/go.mod h1:Vx9TscyEq1iN4tnirn6yYGwEflz0KG3rBZTBCLpKAjc= -github.com/uptrace/bun/extra/bundebug v1.2.3 h1:2QBykz9/u4SkN9dnraImDcbrMk2fUhuq2gL6hkh9qSc= -github.com/uptrace/bun/extra/bundebug v1.2.3/go.mod h1:bihsYJxXxWZXwc1R3qALTHvp+npE0ElgaCvcjzyPPdw= -github.com/uptrace/bun/extra/bunotel v1.2.3 h1:G19QpDE68TXw97x6NciB6nKVDuK0Wb2KgtyMqNIyqBI= -github.com/uptrace/bun/extra/bunotel v1.2.3/go.mod h1:jHRgTqLlX/Zj1KIDokCMDat6JwZHJyErOx0PQ10UFgQ= +github.com/uptrace/bun v1.2.5 h1:gSprL5xiBCp+tzcZHgENzJpXnmQwRM/A6s4HnBF85mc= +github.com/uptrace/bun v1.2.5/go.mod h1:vkQMS4NNs4VNZv92y53uBSHXRqYyJp4bGhMHgaNCQpY= +github.com/uptrace/bun/dialect/pgdialect v1.2.5 h1:dWLUxpjTdglzfBks2x+U2WIi+nRVjuh7Z3DLYVFswJk= +github.com/uptrace/bun/dialect/pgdialect v1.2.5/go.mod h1:stwnlE8/6x8cuQ2aXcZqwDK/d+6jxgO3iQewflJT6C4= +github.com/uptrace/bun/extra/bundebug v1.2.5 h1:DsI/gl4jvq5tQ84yPqnlRYIQ4U6AouTqxJ8Y5Oijfz4= +github.com/uptrace/bun/extra/bundebug v1.2.5/go.mod h1:JFeYvklf5p92ZILXx4siMe2tEVn5JLelww3IGcJb4yA= +github.com/uptrace/bun/extra/bunotel v1.2.5 h1:kkuuTbrG9d5leYZuSBKhq2gtq346lIrxf98Mig2y128= +github.com/uptrace/bun/extra/bunotel v1.2.5/go.mod h1:rCHLszRZwppWE9cGDodO2FCI1qCrLwDjONp38KD3bA8= github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 h1:H8wwQwTe5sL6x30z71lUgNiwBdeCHQjrphCfLwqIHGo= github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2/go.mod h1:/kR4beFhlz2g+V5ik8jW+3PMiMQAPt29y6K64NNY53c= github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c= @@ -1190,12 +1219,12 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -1207,7 +1236,6 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/dburl v0.23.2 h1:Fl88cvayrgE56JA/sqhNMLljCW/b7RmG1mMkKMZUFgA= github.com/xo/dburl v0.23.2/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1221,9 +1249,8 @@ github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zitadel/oidc/v2 v2.12.2 h1:3kpckg4rurgw7w7aLJrq7yvRxb2pkNOtD08RH42vPEs= github.com/zitadel/oidc/v2 v2.12.2/go.mod h1:vhP26g1g4YVntcTi0amMYW3tJuid70nxqxf+kb6XKgg= -go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8= -go.mongodb.org/mongo-driver v1.12.0 h1:aPx33jmn/rQuJXPQLZQ8NtfPQG8CaqgLThFtqRb0PiE= -go.mongodb.org/mongo-driver v1.12.0/go.mod h1:AZkxhPnFJUoH7kZlFkVKucV20K387miPfm7oimrSmK0= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -1232,57 +1259,65 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0 h1:QaNUlLvmettd1vnmFHrgBYQHearxWP3uO4h4F3pVtkM= -go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0/go.mod h1:cJu+5jZwoZfkBOECSFtBZK/O7h/pY5djn0fwnIGnQ4A= -go.opentelemetry.io/contrib/instrumentation/host v0.55.0 h1:V/Cy5A2ydwvyED4ewwXJ441R3QllG+U8tXXVOjPeX4Y= -go.opentelemetry.io/contrib/instrumentation/host v0.55.0/go.mod h1:fsY+EfHPwa1bQcxOUPv1FWaQXAwY+RliLRs6B6qgJes= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI= -go.opentelemetry.io/contrib/instrumentation/runtime v0.55.0 h1:GotCpbh7YkCHdFs+hYMdvAEyGsBZifFognqrOnBwyJM= -go.opentelemetry.io/contrib/instrumentation/runtime v0.55.0/go.mod h1:6b0AS55EEPj7qP44khqF5dqTUq+RkakDMShFaW1EcA4= -go.opentelemetry.io/contrib/propagators/b3 v1.30.0 h1:vumy4r1KMyaoQRltX7cJ37p3nluzALX9nugCjNNefuY= -go.opentelemetry.io/contrib/propagators/b3 v1.30.0/go.mod h1:fRbvRsaeVZ82LIl3u0rIvusIel2UUf+JcaaIpy5taho= -go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= -go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.30.0 h1:WypxHH02KX2poqqbaadmkMYalGyy/vil4HE4PM4nRJc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.30.0/go.mod h1:U79SV99vtvGSEBeeHnpgGJfTsnsdkWLpPN/CcHAzBSI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0 h1:VrMAbeJz4gnVDg2zEzjHG4dEH86j4jO6VYB+NgtGD8s= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0/go.mod h1:qqN/uFdpeitTvm+JDqqnjm517pmQRYxTORbETHq5tOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0 h1:m0yTiGDLUvVYaTFbAvCkVYIYcvwKt3G7OLoN77NUs/8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0/go.mod h1:wBQbT4UekBfegL2nx0Xk1vBcnzyBPsIVm9hRG4fYcr4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0 h1:IyFlqNsi8VT/nwYlLJfdM0y1gavxGpEvnf6FtVfZ6X4= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0/go.mod h1:bxiX8eUeKoAEQmbq/ecUT8UqZwCjZW52yJrXJUSozsk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0 h1:kn1BudCgwtE7PxLqcZkErpD8GKqLZ6BSzeW9QihQJeM= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0/go.mod h1:ljkUDtAMdleoi9tIG1R6dJUpVwDcYjw3J2Q6Q/SuiC0= +go.opentelemetry.io/contrib/instrumentation/host v0.57.0 h1:1gfzOyXEuCrrwCXF81LO3DQ4rll6YBKfAQHPl+03mik= +go.opentelemetry.io/contrib/instrumentation/host v0.57.0/go.mod h1:pHBt+1Rhz99VBX7AQVgwcKPf611zgD6pQy7VwBNMFmE= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= +go.opentelemetry.io/contrib/instrumentation/runtime v0.57.0 h1:kJB5wMVorwre8QzEodzTAbzm9FOOah0zvG+V4abNlEE= +go.opentelemetry.io/contrib/instrumentation/runtime v0.57.0/go.mod h1:Nup4TgnOyEJWmVq9sf/ASH3ZJiAXwWHd5xZCHG7Sg9M= +go.opentelemetry.io/contrib/propagators/b3 v1.32.0 h1:MazJBz2Zf6HTN/nK/s3Ru1qme+VhWU5hm83QxEP+dvw= +go.opentelemetry.io/contrib/propagators/b3 v1.32.0/go.mod h1:B0s70QHYPrJwPOwD1o3V/R8vETNOG9N3qZf4LDYvA30= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM= -go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= -go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= -go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= -go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= -go.opentelemetry.io/otel/sdk/metric v1.30.0 h1:QJLT8Pe11jyHBHfSAgYH7kEmT24eX792jZO1bo4BXkM= -go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y= -go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= -go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.temporal.io/api v1.41.0 h1:VYzyWJjJk1jeB9urntA/t7Hiyo2tHdM5xEdtdib4EO8= +go.temporal.io/api v1.41.0/go.mod h1:1WwYUMo6lao8yl0371xWUm13paHExN5ATYT/B7QtFis= +go.temporal.io/sdk v1.30.0 h1:7jzSFZYk+tQ2kIYEP+dvrM7AW9EsCEP52JHCjVGuwbI= +go.temporal.io/sdk v1.30.0/go.mod h1:Pv45F/fVDgWKx+jhix5t/dGgqROVaI+VjPLd3CHWqq0= +go.temporal.io/sdk/contrib/opentelemetry v0.6.0 h1:rNBArDj5iTUkcMwKocUShoAW59o6HdS7Nq4CTp4ldj8= +go.temporal.io/sdk/contrib/opentelemetry v0.6.0/go.mod h1:Lem8VrE2ks8P+FYcRM3UphPoBr+tfM3v/Kaf0qStzSg= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= -go.uber.org/fx v1.22.2 h1:iPW+OPxv0G8w75OemJ1RAnTUrF55zOJlXlo1TbJ0Buw= -go.uber.org/fx v1.22.2/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= +go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= +go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -1295,14 +1330,13 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1318,6 +1352,8 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -1396,6 +1432,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1421,8 +1458,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1473,12 +1510,13 @@ golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1528,8 +1566,10 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1558,8 +1598,9 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1588,16 +1629,16 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1616,6 +1657,7 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -1656,13 +1698,14 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= -golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1768,6 +1811,7 @@ google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= @@ -1886,15 +1930,15 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go. google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= -google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= -google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= +google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1936,8 +1980,8 @@ google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= -google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= -google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= +google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1957,11 +2001,10 @@ google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= @@ -1969,11 +2012,11 @@ gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKK gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= diff --git a/internal/api/backend/backend.go b/internal/api/backend/backend.go new file mode 100644 index 00000000..75e6a4ef --- /dev/null +++ b/internal/api/backend/backend.go @@ -0,0 +1,88 @@ +package backend + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "github.com/google/uuid" +) + +//go:generate mockgen -source backend.go -destination backend_generated.go -package backend . Backend +type Backend interface { + // Accounts + AccountsCreate(ctx context.Context, account models.Account) error + AccountsList(ctx context.Context, query storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) + AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) + + // Balances + BalancesList(ctx context.Context, query storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) + PoolsBalancesAt(ctx context.Context, poolID uuid.UUID, at time.Time) ([]models.AggregatedBalance, error) + + // Bank Accounts + BankAccountsCreate(ctx context.Context, bankAccount models.BankAccount) error + BankAccountsGet(ctx context.Context, id uuid.UUID) (*models.BankAccount, error) + BankAccountsList(ctx context.Context, query storage.ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error) + BankAccountsUpdateMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error + BankAccountsForwardToConnector(ctx context.Context, bankAccountID uuid.UUID, connectorID models.ConnectorID, waitResult bool) (models.Task, error) + + // Connectors + ConnectorsConfigs() plugins.Configs + ConnectorsConfig(ctx context.Context, connectorID models.ConnectorID) (json.RawMessage, error) + ConnectorsList(ctx context.Context, query storage.ListConnectorsQuery) (*bunpaginate.Cursor[models.Connector], error) + ConnectorsInstall(ctx context.Context, provider string, config json.RawMessage) (models.ConnectorID, error) + ConnectorsUninstall(ctx context.Context, connectorID models.ConnectorID) error + ConnectorsReset(ctx context.Context, connectorID models.ConnectorID) error + + // Payments + PaymentsCreate(ctx context.Context, payment models.Payment) error + PaymentsUpdateMetadata(ctx context.Context, id models.PaymentID, metadata map[string]string) error + PaymentsList(ctx context.Context, query storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) + PaymentsGet(ctx context.Context, id models.PaymentID) (*models.Payment, error) + + // Payment Initiations + PaymentInitiationsCreate(ctx context.Context, paymentInitiation models.PaymentInitiation, sendToPSP bool, waitResult bool) (models.Task, error) + PaymentInitiationsList(ctx context.Context, query storage.ListPaymentInitiationsQuery) (*bunpaginate.Cursor[models.PaymentInitiation], error) + PaymentInitiationsGet(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiation, error) + PaymentInitiationsApprove(ctx context.Context, id models.PaymentInitiationID, waitResult bool) (models.Task, error) + PaymentInitiationsReject(ctx context.Context, id models.PaymentInitiationID) error + PaymentInitiationsRetry(ctx context.Context, id models.PaymentInitiationID, waitResult bool) (models.Task, error) + PaymentInitiationsDelete(ctx context.Context, id models.PaymentInitiationID) error + + // Payment Initiation Reversals + PaymentInitiationReversalsCreate(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) + + // Payment Initiation Adjustments + PaymentInitiationAdjustmentsList(ctx context.Context, id models.PaymentInitiationID, query storage.ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) + PaymentInitiationAdjustmentsListAll(ctx context.Context, id models.PaymentInitiationID) ([]models.PaymentInitiationAdjustment, error) + PaymentInitiationAdjustmentsGetLast(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiationAdjustment, error) + + // Payment Initiatiion Related Payments + PaymentInitiationRelatedPaymentsList(ctx context.Context, id models.PaymentInitiationID, query storage.ListPaymentInitiationRelatedPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) + PaymentInitiationRelatedPaymentListAll(ctx context.Context, id models.PaymentInitiationID) ([]models.Payment, error) + + // Pools + PoolsCreate(ctx context.Context, pool models.Pool) error + PoolsGet(ctx context.Context, id uuid.UUID) (*models.Pool, error) + PoolsList(ctx context.Context, query storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) + PoolsDelete(ctx context.Context, id uuid.UUID) error + PoolsAddAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error + PoolsRemoveAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error + + // Schedules + SchedulesList(ctx context.Context, query storage.ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) + SchedulesGet(ctx context.Context, id string, connectorID models.ConnectorID) (*models.Schedule, error) + + // Tasks + TaskGet(ctx context.Context, id models.TaskID) (*models.Task, error) + + // Webhooks + ConnectorsHandleWebhooks(ctx context.Context, urlPath string, webhook models.Webhook) error + + // Workflows Instances + WorkflowsInstancesList(ctx context.Context, query storage.ListInstancesQuery) (*bunpaginate.Cursor[models.Instance], error) +} diff --git a/internal/api/backend/backend_generated.go b/internal/api/backend/backend_generated.go new file mode 100644 index 00000000..710f314e --- /dev/null +++ b/internal/api/backend/backend_generated.go @@ -0,0 +1,692 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: backend.go +// +// Generated by this command: +// +// mockgen -source backend.go -destination backend_generated.go -package backend . Backend +// + +// Package backend is a generated GoMock package. +package backend + +import ( + context "context" + json "encoding/json" + reflect "reflect" + time "time" + + bunpaginate "github.com/formancehq/go-libs/v2/bun/bunpaginate" + plugins "github.com/formancehq/payments/internal/connectors/plugins" + models "github.com/formancehq/payments/internal/models" + storage "github.com/formancehq/payments/internal/storage" + uuid "github.com/google/uuid" + gomock "go.uber.org/mock/gomock" +) + +// MockBackend is a mock of Backend interface. +type MockBackend struct { + ctrl *gomock.Controller + recorder *MockBackendMockRecorder +} + +// MockBackendMockRecorder is the mock recorder for MockBackend. +type MockBackendMockRecorder struct { + mock *MockBackend +} + +// NewMockBackend creates a new mock instance. +func NewMockBackend(ctrl *gomock.Controller) *MockBackend { + mock := &MockBackend{ctrl: ctrl} + mock.recorder = &MockBackendMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBackend) EXPECT() *MockBackendMockRecorder { + return m.recorder +} + +// AccountsCreate mocks base method. +func (m *MockBackend) AccountsCreate(ctx context.Context, account models.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsCreate", ctx, account) + ret0, _ := ret[0].(error) + return ret0 +} + +// AccountsCreate indicates an expected call of AccountsCreate. +func (mr *MockBackendMockRecorder) AccountsCreate(ctx, account any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsCreate", reflect.TypeOf((*MockBackend)(nil).AccountsCreate), ctx, account) +} + +// AccountsGet mocks base method. +func (m *MockBackend) AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsGet", ctx, id) + ret0, _ := ret[0].(*models.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountsGet indicates an expected call of AccountsGet. +func (mr *MockBackendMockRecorder) AccountsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsGet", reflect.TypeOf((*MockBackend)(nil).AccountsGet), ctx, id) +} + +// AccountsList mocks base method. +func (m *MockBackend) AccountsList(ctx context.Context, query storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Account]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountsList indicates an expected call of AccountsList. +func (mr *MockBackendMockRecorder) AccountsList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsList", reflect.TypeOf((*MockBackend)(nil).AccountsList), ctx, query) +} + +// BalancesList mocks base method. +func (m *MockBackend) BalancesList(ctx context.Context, query storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BalancesList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Balance]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BalancesList indicates an expected call of BalancesList. +func (mr *MockBackendMockRecorder) BalancesList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BalancesList", reflect.TypeOf((*MockBackend)(nil).BalancesList), ctx, query) +} + +// BankAccountsCreate mocks base method. +func (m *MockBackend) BankAccountsCreate(ctx context.Context, bankAccount models.BankAccount) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsCreate", ctx, bankAccount) + ret0, _ := ret[0].(error) + return ret0 +} + +// BankAccountsCreate indicates an expected call of BankAccountsCreate. +func (mr *MockBackendMockRecorder) BankAccountsCreate(ctx, bankAccount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsCreate", reflect.TypeOf((*MockBackend)(nil).BankAccountsCreate), ctx, bankAccount) +} + +// BankAccountsForwardToConnector mocks base method. +func (m *MockBackend) BankAccountsForwardToConnector(ctx context.Context, bankAccountID uuid.UUID, connectorID models.ConnectorID, waitResult bool) (models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsForwardToConnector", ctx, bankAccountID, connectorID, waitResult) + ret0, _ := ret[0].(models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BankAccountsForwardToConnector indicates an expected call of BankAccountsForwardToConnector. +func (mr *MockBackendMockRecorder) BankAccountsForwardToConnector(ctx, bankAccountID, connectorID, waitResult any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsForwardToConnector", reflect.TypeOf((*MockBackend)(nil).BankAccountsForwardToConnector), ctx, bankAccountID, connectorID, waitResult) +} + +// BankAccountsGet mocks base method. +func (m *MockBackend) BankAccountsGet(ctx context.Context, id uuid.UUID) (*models.BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsGet", ctx, id) + ret0, _ := ret[0].(*models.BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BankAccountsGet indicates an expected call of BankAccountsGet. +func (mr *MockBackendMockRecorder) BankAccountsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsGet", reflect.TypeOf((*MockBackend)(nil).BankAccountsGet), ctx, id) +} + +// BankAccountsList mocks base method. +func (m *MockBackend) BankAccountsList(ctx context.Context, query storage.ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.BankAccount]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BankAccountsList indicates an expected call of BankAccountsList. +func (mr *MockBackendMockRecorder) BankAccountsList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsList", reflect.TypeOf((*MockBackend)(nil).BankAccountsList), ctx, query) +} + +// BankAccountsUpdateMetadata mocks base method. +func (m *MockBackend) BankAccountsUpdateMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsUpdateMetadata", ctx, id, metadata) + ret0, _ := ret[0].(error) + return ret0 +} + +// BankAccountsUpdateMetadata indicates an expected call of BankAccountsUpdateMetadata. +func (mr *MockBackendMockRecorder) BankAccountsUpdateMetadata(ctx, id, metadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsUpdateMetadata", reflect.TypeOf((*MockBackend)(nil).BankAccountsUpdateMetadata), ctx, id, metadata) +} + +// ConnectorsConfig mocks base method. +func (m *MockBackend) ConnectorsConfig(ctx context.Context, connectorID models.ConnectorID) (json.RawMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsConfig", ctx, connectorID) + ret0, _ := ret[0].(json.RawMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConnectorsConfig indicates an expected call of ConnectorsConfig. +func (mr *MockBackendMockRecorder) ConnectorsConfig(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsConfig", reflect.TypeOf((*MockBackend)(nil).ConnectorsConfig), ctx, connectorID) +} + +// ConnectorsConfigs mocks base method. +func (m *MockBackend) ConnectorsConfigs() plugins.Configs { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsConfigs") + ret0, _ := ret[0].(plugins.Configs) + return ret0 +} + +// ConnectorsConfigs indicates an expected call of ConnectorsConfigs. +func (mr *MockBackendMockRecorder) ConnectorsConfigs() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsConfigs", reflect.TypeOf((*MockBackend)(nil).ConnectorsConfigs)) +} + +// ConnectorsHandleWebhooks mocks base method. +func (m *MockBackend) ConnectorsHandleWebhooks(ctx context.Context, urlPath string, webhook models.Webhook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsHandleWebhooks", ctx, urlPath, webhook) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorsHandleWebhooks indicates an expected call of ConnectorsHandleWebhooks. +func (mr *MockBackendMockRecorder) ConnectorsHandleWebhooks(ctx, urlPath, webhook any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsHandleWebhooks", reflect.TypeOf((*MockBackend)(nil).ConnectorsHandleWebhooks), ctx, urlPath, webhook) +} + +// ConnectorsInstall mocks base method. +func (m *MockBackend) ConnectorsInstall(ctx context.Context, provider string, config json.RawMessage) (models.ConnectorID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsInstall", ctx, provider, config) + ret0, _ := ret[0].(models.ConnectorID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConnectorsInstall indicates an expected call of ConnectorsInstall. +func (mr *MockBackendMockRecorder) ConnectorsInstall(ctx, provider, config any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsInstall", reflect.TypeOf((*MockBackend)(nil).ConnectorsInstall), ctx, provider, config) +} + +// ConnectorsList mocks base method. +func (m *MockBackend) ConnectorsList(ctx context.Context, query storage.ListConnectorsQuery) (*bunpaginate.Cursor[models.Connector], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Connector]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConnectorsList indicates an expected call of ConnectorsList. +func (mr *MockBackendMockRecorder) ConnectorsList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsList", reflect.TypeOf((*MockBackend)(nil).ConnectorsList), ctx, query) +} + +// ConnectorsReset mocks base method. +func (m *MockBackend) ConnectorsReset(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsReset", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorsReset indicates an expected call of ConnectorsReset. +func (mr *MockBackendMockRecorder) ConnectorsReset(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsReset", reflect.TypeOf((*MockBackend)(nil).ConnectorsReset), ctx, connectorID) +} + +// ConnectorsUninstall mocks base method. +func (m *MockBackend) ConnectorsUninstall(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsUninstall", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorsUninstall indicates an expected call of ConnectorsUninstall. +func (mr *MockBackendMockRecorder) ConnectorsUninstall(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsUninstall", reflect.TypeOf((*MockBackend)(nil).ConnectorsUninstall), ctx, connectorID) +} + +// PaymentInitiationAdjustmentsGetLast mocks base method. +func (m *MockBackend) PaymentInitiationAdjustmentsGetLast(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiationAdjustment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsGetLast", ctx, id) + ret0, _ := ret[0].(*models.PaymentInitiationAdjustment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationAdjustmentsGetLast indicates an expected call of PaymentInitiationAdjustmentsGetLast. +func (mr *MockBackendMockRecorder) PaymentInitiationAdjustmentsGetLast(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsGetLast", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationAdjustmentsGetLast), ctx, id) +} + +// PaymentInitiationAdjustmentsList mocks base method. +func (m *MockBackend) PaymentInitiationAdjustmentsList(ctx context.Context, id models.PaymentInitiationID, query storage.ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsList", ctx, id, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.PaymentInitiationAdjustment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationAdjustmentsList indicates an expected call of PaymentInitiationAdjustmentsList. +func (mr *MockBackendMockRecorder) PaymentInitiationAdjustmentsList(ctx, id, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsList", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationAdjustmentsList), ctx, id, query) +} + +// PaymentInitiationAdjustmentsListAll mocks base method. +func (m *MockBackend) PaymentInitiationAdjustmentsListAll(ctx context.Context, id models.PaymentInitiationID) ([]models.PaymentInitiationAdjustment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsListAll", ctx, id) + ret0, _ := ret[0].([]models.PaymentInitiationAdjustment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationAdjustmentsListAll indicates an expected call of PaymentInitiationAdjustmentsListAll. +func (mr *MockBackendMockRecorder) PaymentInitiationAdjustmentsListAll(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsListAll", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationAdjustmentsListAll), ctx, id) +} + +// PaymentInitiationRelatedPaymentListAll mocks base method. +func (m *MockBackend) PaymentInitiationRelatedPaymentListAll(ctx context.Context, id models.PaymentInitiationID) ([]models.Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationRelatedPaymentListAll", ctx, id) + ret0, _ := ret[0].([]models.Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationRelatedPaymentListAll indicates an expected call of PaymentInitiationRelatedPaymentListAll. +func (mr *MockBackendMockRecorder) PaymentInitiationRelatedPaymentListAll(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationRelatedPaymentListAll", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationRelatedPaymentListAll), ctx, id) +} + +// PaymentInitiationRelatedPaymentsList mocks base method. +func (m *MockBackend) PaymentInitiationRelatedPaymentsList(ctx context.Context, id models.PaymentInitiationID, query storage.ListPaymentInitiationRelatedPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationRelatedPaymentsList", ctx, id, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Payment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationRelatedPaymentsList indicates an expected call of PaymentInitiationRelatedPaymentsList. +func (mr *MockBackendMockRecorder) PaymentInitiationRelatedPaymentsList(ctx, id, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationRelatedPaymentsList", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationRelatedPaymentsList), ctx, id, query) +} + +// PaymentInitiationReversalsCreate mocks base method. +func (m *MockBackend) PaymentInitiationReversalsCreate(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalsCreate", ctx, reversal, waitResult) + ret0, _ := ret[0].(models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationReversalsCreate indicates an expected call of PaymentInitiationReversalsCreate. +func (mr *MockBackendMockRecorder) PaymentInitiationReversalsCreate(ctx, reversal, waitResult any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalsCreate", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationReversalsCreate), ctx, reversal, waitResult) +} + +// PaymentInitiationsApprove mocks base method. +func (m *MockBackend) PaymentInitiationsApprove(ctx context.Context, id models.PaymentInitiationID, waitResult bool) (models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsApprove", ctx, id, waitResult) + ret0, _ := ret[0].(models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsApprove indicates an expected call of PaymentInitiationsApprove. +func (mr *MockBackendMockRecorder) PaymentInitiationsApprove(ctx, id, waitResult any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsApprove", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsApprove), ctx, id, waitResult) +} + +// PaymentInitiationsCreate mocks base method. +func (m *MockBackend) PaymentInitiationsCreate(ctx context.Context, paymentInitiation models.PaymentInitiation, sendToPSP, waitResult bool) (models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsCreate", ctx, paymentInitiation, sendToPSP, waitResult) + ret0, _ := ret[0].(models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsCreate indicates an expected call of PaymentInitiationsCreate. +func (mr *MockBackendMockRecorder) PaymentInitiationsCreate(ctx, paymentInitiation, sendToPSP, waitResult any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsCreate", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsCreate), ctx, paymentInitiation, sendToPSP, waitResult) +} + +// PaymentInitiationsDelete mocks base method. +func (m *MockBackend) PaymentInitiationsDelete(ctx context.Context, id models.PaymentInitiationID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsDelete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationsDelete indicates an expected call of PaymentInitiationsDelete. +func (mr *MockBackendMockRecorder) PaymentInitiationsDelete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsDelete", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsDelete), ctx, id) +} + +// PaymentInitiationsGet mocks base method. +func (m *MockBackend) PaymentInitiationsGet(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsGet", ctx, id) + ret0, _ := ret[0].(*models.PaymentInitiation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsGet indicates an expected call of PaymentInitiationsGet. +func (mr *MockBackendMockRecorder) PaymentInitiationsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsGet", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsGet), ctx, id) +} + +// PaymentInitiationsList mocks base method. +func (m *MockBackend) PaymentInitiationsList(ctx context.Context, query storage.ListPaymentInitiationsQuery) (*bunpaginate.Cursor[models.PaymentInitiation], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.PaymentInitiation]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsList indicates an expected call of PaymentInitiationsList. +func (mr *MockBackendMockRecorder) PaymentInitiationsList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsList", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsList), ctx, query) +} + +// PaymentInitiationsReject mocks base method. +func (m *MockBackend) PaymentInitiationsReject(ctx context.Context, id models.PaymentInitiationID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsReject", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationsReject indicates an expected call of PaymentInitiationsReject. +func (mr *MockBackendMockRecorder) PaymentInitiationsReject(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsReject", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsReject), ctx, id) +} + +// PaymentInitiationsRetry mocks base method. +func (m *MockBackend) PaymentInitiationsRetry(ctx context.Context, id models.PaymentInitiationID, waitResult bool) (models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsRetry", ctx, id, waitResult) + ret0, _ := ret[0].(models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsRetry indicates an expected call of PaymentInitiationsRetry. +func (mr *MockBackendMockRecorder) PaymentInitiationsRetry(ctx, id, waitResult any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsRetry", reflect.TypeOf((*MockBackend)(nil).PaymentInitiationsRetry), ctx, id, waitResult) +} + +// PaymentsCreate mocks base method. +func (m *MockBackend) PaymentsCreate(ctx context.Context, payment models.Payment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsCreate", ctx, payment) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentsCreate indicates an expected call of PaymentsCreate. +func (mr *MockBackendMockRecorder) PaymentsCreate(ctx, payment any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsCreate", reflect.TypeOf((*MockBackend)(nil).PaymentsCreate), ctx, payment) +} + +// PaymentsGet mocks base method. +func (m *MockBackend) PaymentsGet(ctx context.Context, id models.PaymentID) (*models.Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsGet", ctx, id) + ret0, _ := ret[0].(*models.Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentsGet indicates an expected call of PaymentsGet. +func (mr *MockBackendMockRecorder) PaymentsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsGet", reflect.TypeOf((*MockBackend)(nil).PaymentsGet), ctx, id) +} + +// PaymentsList mocks base method. +func (m *MockBackend) PaymentsList(ctx context.Context, query storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Payment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentsList indicates an expected call of PaymentsList. +func (mr *MockBackendMockRecorder) PaymentsList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsList", reflect.TypeOf((*MockBackend)(nil).PaymentsList), ctx, query) +} + +// PaymentsUpdateMetadata mocks base method. +func (m *MockBackend) PaymentsUpdateMetadata(ctx context.Context, id models.PaymentID, metadata map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsUpdateMetadata", ctx, id, metadata) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentsUpdateMetadata indicates an expected call of PaymentsUpdateMetadata. +func (mr *MockBackendMockRecorder) PaymentsUpdateMetadata(ctx, id, metadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsUpdateMetadata", reflect.TypeOf((*MockBackend)(nil).PaymentsUpdateMetadata), ctx, id, metadata) +} + +// PoolsAddAccount mocks base method. +func (m *MockBackend) PoolsAddAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsAddAccount", ctx, id, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsAddAccount indicates an expected call of PoolsAddAccount. +func (mr *MockBackendMockRecorder) PoolsAddAccount(ctx, id, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsAddAccount", reflect.TypeOf((*MockBackend)(nil).PoolsAddAccount), ctx, id, accountID) +} + +// PoolsBalancesAt mocks base method. +func (m *MockBackend) PoolsBalancesAt(ctx context.Context, poolID uuid.UUID, at time.Time) ([]models.AggregatedBalance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsBalancesAt", ctx, poolID, at) + ret0, _ := ret[0].([]models.AggregatedBalance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PoolsBalancesAt indicates an expected call of PoolsBalancesAt. +func (mr *MockBackendMockRecorder) PoolsBalancesAt(ctx, poolID, at any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsBalancesAt", reflect.TypeOf((*MockBackend)(nil).PoolsBalancesAt), ctx, poolID, at) +} + +// PoolsCreate mocks base method. +func (m *MockBackend) PoolsCreate(ctx context.Context, pool models.Pool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsCreate", ctx, pool) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsCreate indicates an expected call of PoolsCreate. +func (mr *MockBackendMockRecorder) PoolsCreate(ctx, pool any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsCreate", reflect.TypeOf((*MockBackend)(nil).PoolsCreate), ctx, pool) +} + +// PoolsDelete mocks base method. +func (m *MockBackend) PoolsDelete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsDelete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsDelete indicates an expected call of PoolsDelete. +func (mr *MockBackendMockRecorder) PoolsDelete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsDelete", reflect.TypeOf((*MockBackend)(nil).PoolsDelete), ctx, id) +} + +// PoolsGet mocks base method. +func (m *MockBackend) PoolsGet(ctx context.Context, id uuid.UUID) (*models.Pool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsGet", ctx, id) + ret0, _ := ret[0].(*models.Pool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PoolsGet indicates an expected call of PoolsGet. +func (mr *MockBackendMockRecorder) PoolsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsGet", reflect.TypeOf((*MockBackend)(nil).PoolsGet), ctx, id) +} + +// PoolsList mocks base method. +func (m *MockBackend) PoolsList(ctx context.Context, query storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Pool]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PoolsList indicates an expected call of PoolsList. +func (mr *MockBackendMockRecorder) PoolsList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsList", reflect.TypeOf((*MockBackend)(nil).PoolsList), ctx, query) +} + +// PoolsRemoveAccount mocks base method. +func (m *MockBackend) PoolsRemoveAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsRemoveAccount", ctx, id, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsRemoveAccount indicates an expected call of PoolsRemoveAccount. +func (mr *MockBackendMockRecorder) PoolsRemoveAccount(ctx, id, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsRemoveAccount", reflect.TypeOf((*MockBackend)(nil).PoolsRemoveAccount), ctx, id, accountID) +} + +// SchedulesGet mocks base method. +func (m *MockBackend) SchedulesGet(ctx context.Context, id string, connectorID models.ConnectorID) (*models.Schedule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesGet", ctx, id, connectorID) + ret0, _ := ret[0].(*models.Schedule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SchedulesGet indicates an expected call of SchedulesGet. +func (mr *MockBackendMockRecorder) SchedulesGet(ctx, id, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesGet", reflect.TypeOf((*MockBackend)(nil).SchedulesGet), ctx, id, connectorID) +} + +// SchedulesList mocks base method. +func (m *MockBackend) SchedulesList(ctx context.Context, query storage.ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Schedule]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SchedulesList indicates an expected call of SchedulesList. +func (mr *MockBackendMockRecorder) SchedulesList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesList", reflect.TypeOf((*MockBackend)(nil).SchedulesList), ctx, query) +} + +// TaskGet mocks base method. +func (m *MockBackend) TaskGet(ctx context.Context, id models.TaskID) (*models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TaskGet", ctx, id) + ret0, _ := ret[0].(*models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TaskGet indicates an expected call of TaskGet. +func (mr *MockBackendMockRecorder) TaskGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskGet", reflect.TypeOf((*MockBackend)(nil).TaskGet), ctx, id) +} + +// WorkflowsInstancesList mocks base method. +func (m *MockBackend) WorkflowsInstancesList(ctx context.Context, query storage.ListInstancesQuery) (*bunpaginate.Cursor[models.Instance], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WorkflowsInstancesList", ctx, query) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Instance]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WorkflowsInstancesList indicates an expected call of WorkflowsInstancesList. +func (mr *MockBackendMockRecorder) WorkflowsInstancesList(ctx, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WorkflowsInstancesList", reflect.TypeOf((*MockBackend)(nil).WorkflowsInstancesList), ctx, query) +} diff --git a/internal/api/module.go b/internal/api/module.go new file mode 100644 index 00000000..e8a1aaab --- /dev/null +++ b/internal/api/module.go @@ -0,0 +1,38 @@ +package api + +import ( + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/auth" + "github.com/formancehq/go-libs/v2/health" + "github.com/formancehq/go-libs/v2/httpserver" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/api/services" + "github.com/formancehq/payments/internal/connectors/engine" + "github.com/formancehq/payments/internal/storage" + "github.com/go-chi/chi/v5" + "go.uber.org/fx" +) + +func TagVersion() fx.Annotation { + return fx.ResultTags(`group:"apiVersions"`) +} + +func NewModule(bind string, debug bool) fx.Option { + return fx.Options( + fx.Invoke(func(m *chi.Mux, lc fx.Lifecycle) { + lc.Append(httpserver.NewHook(m, httpserver.WithAddress(bind))) + }), + fx.Provide(fx.Annotate(func( + backend backend.Backend, + info api.ServiceInfo, + healthController *health.HealthController, + a auth.Authenticator, + versions ...Version, + ) *chi.Mux { + return NewRouter(backend, info, healthController, a, debug, versions...) + }, fx.ParamTags(``, ``, ``, ``, `group:"apiVersions"`))), + fx.Provide(func(storage storage.Storage, engine engine.Engine) backend.Backend { + return services.New(storage, engine) + }), + ) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 00000000..d64f5610 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,64 @@ +package api + +import ( + "fmt" + "net/http" + "sort" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/auth" + "github.com/formancehq/go-libs/v2/health" + "github.com/formancehq/payments/internal/api/backend" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +type Version struct { + Version int + Builder func(backend backend.Backend, a auth.Authenticator, debug bool) *chi.Mux +} + +type versionsSlice []Version + +func (v versionsSlice) Len() int { + return len(v) +} + +func (v versionsSlice) Less(i, j int) bool { + return v[i].Version < v[j].Version +} + +func (v versionsSlice) Swap(i, j int) { + v[i], v[j] = v[j], v[i] +} + +func NewRouter( + backend backend.Backend, + info api.ServiceInfo, + healthController *health.HealthController, + a auth.Authenticator, + debug bool, + versions ...Version) *chi.Mux { + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + handler.ServeHTTP(w, r) + }) + }) + r.Get("/_healthcheck", healthController.Check) + r.Get("/_info", api.InfoHandler(info)) + + sortedVersions := versionsSlice(versions) + sort.Stable(sortedVersions) + + for _, version := range sortedVersions[1:] { + prefix := fmt.Sprintf("/v%d", version.Version) + r.Handle(prefix+"/*", http.StripPrefix(prefix, version.Builder(backend, a, debug))) + } + + r.Handle("/*", versions[0].Builder(backend, a, debug)) // V1 and V2 have no prefix + + return r +} diff --git a/internal/api/services/accounts_create.go b/internal/api/services/accounts_create.go new file mode 100644 index 00000000..d0ab1888 --- /dev/null +++ b/internal/api/services/accounts_create.go @@ -0,0 +1,11 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) AccountsCreate(ctx context.Context, account models.Account) error { + return handleEngineErrors(s.engine.CreateFormanceAccount(ctx, account)) +} diff --git a/internal/api/services/accounts_get.go b/internal/api/services/accounts_get.go new file mode 100644 index 00000000..7e9579c1 --- /dev/null +++ b/internal/api/services/accounts_get.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) { + account, err := s.storage.AccountsGet(ctx, id) + if err != nil { + return nil, newStorageError(err, "cannot get account") + } + + return account, nil +} diff --git a/internal/api/services/accounts_list.go b/internal/api/services/accounts_list.go new file mode 100644 index 00000000..edd768c6 --- /dev/null +++ b/internal/api/services/accounts_list.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) AccountsList(ctx context.Context, query storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { + accounts, err := s.storage.AccountsList(ctx, query) + if err != nil { + return nil, newStorageError(err, "cannot list accounts") + } + + return accounts, nil +} diff --git a/internal/api/services/balances_list.go b/internal/api/services/balances_list.go new file mode 100644 index 00000000..500a1ea3 --- /dev/null +++ b/internal/api/services/balances_list.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) BalancesList(ctx context.Context, query storage.ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { + balances, err := s.storage.BalancesList(ctx, query) + if err != nil { + return nil, newStorageError(err, "cannot list balances") + } + + return balances, nil +} diff --git a/internal/api/services/bank_accounts_create.go b/internal/api/services/bank_accounts_create.go new file mode 100644 index 00000000..ae227da7 --- /dev/null +++ b/internal/api/services/bank_accounts_create.go @@ -0,0 +1,11 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) BankAccountsCreate(ctx context.Context, bankAccount models.BankAccount) error { + return newStorageError(s.storage.BankAccountsUpsert(ctx, bankAccount), "cannot create bank account") +} diff --git a/internal/api/services/bank_accounts_forward_to_connector.go b/internal/api/services/bank_accounts_forward_to_connector.go new file mode 100644 index 00000000..f469f232 --- /dev/null +++ b/internal/api/services/bank_accounts_forward_to_connector.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (s *Service) BankAccountsForwardToConnector(ctx context.Context, bankAccountID uuid.UUID, connectorID models.ConnectorID, waitResult bool) (models.Task, error) { + task, err := s.engine.ForwardBankAccount(ctx, bankAccountID, connectorID, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil +} diff --git a/internal/api/services/bank_accounts_get.go b/internal/api/services/bank_accounts_get.go new file mode 100644 index 00000000..629fd02c --- /dev/null +++ b/internal/api/services/bank_accounts_get.go @@ -0,0 +1,17 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (s *Service) BankAccountsGet(ctx context.Context, id uuid.UUID) (*models.BankAccount, error) { + ba, err := s.storage.BankAccountsGet(ctx, id, true) + if err != nil { + return nil, newStorageError(err, "cannot get bank account") + } + + return ba, nil +} diff --git a/internal/api/services/bank_accounts_list.go b/internal/api/services/bank_accounts_list.go new file mode 100644 index 00000000..efdd3d91 --- /dev/null +++ b/internal/api/services/bank_accounts_list.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) BankAccountsList(ctx context.Context, query storage.ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error) { + bas, err := s.storage.BankAccountsList(ctx, query) + if err != nil { + return nil, newStorageError(err, "cannot list bank accounts") + } + + return bas, nil +} diff --git a/internal/api/services/bank_accounts_update_metadata.go b/internal/api/services/bank_accounts_update_metadata.go new file mode 100644 index 00000000..fb8a13b0 --- /dev/null +++ b/internal/api/services/bank_accounts_update_metadata.go @@ -0,0 +1,11 @@ +package services + +import ( + "context" + + "github.com/google/uuid" +) + +func (s *Service) BankAccountsUpdateMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error { + return newStorageError(s.storage.BankAccountsUpdateMetadata(ctx, id, metadata), "cannot update bank account metadata") +} diff --git a/internal/api/services/connector_configs.go b/internal/api/services/connector_configs.go new file mode 100644 index 00000000..e0c6ce8f --- /dev/null +++ b/internal/api/services/connector_configs.go @@ -0,0 +1,22 @@ +package services + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) ConnectorsConfigs() plugins.Configs { + return plugins.GetConfigs() +} + +func (s *Service) ConnectorsConfig(ctx context.Context, connectorID models.ConnectorID) (json.RawMessage, error) { + connector, err := s.storage.ConnectorsGet(ctx, connectorID) + if err != nil { + return nil, newStorageError(err, "get connector") + } + + return connector.Config, nil +} diff --git a/internal/api/services/connectors_handle_webhooks.go b/internal/api/services/connectors_handle_webhooks.go new file mode 100644 index 00000000..5304e648 --- /dev/null +++ b/internal/api/services/connectors_handle_webhooks.go @@ -0,0 +1,15 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) ConnectorsHandleWebhooks( + ctx context.Context, + urlPath string, + webhook models.Webhook, +) error { + return handleEngineErrors(s.engine.HandleWebhook(ctx, urlPath, webhook)) +} diff --git a/internal/api/services/connectors_install.go b/internal/api/services/connectors_install.go new file mode 100644 index 00000000..9484c089 --- /dev/null +++ b/internal/api/services/connectors_install.go @@ -0,0 +1,13 @@ +package services + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) ConnectorsInstall(ctx context.Context, provider string, config json.RawMessage) (models.ConnectorID, error) { + connectorID, err := s.engine.InstallConnector(ctx, provider, config) + return connectorID, handleEngineErrors(err) +} diff --git a/internal/api/services/connectors_list.go b/internal/api/services/connectors_list.go new file mode 100644 index 00000000..6bef8ed8 --- /dev/null +++ b/internal/api/services/connectors_list.go @@ -0,0 +1,14 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) ConnectorsList(ctx context.Context, query storage.ListConnectorsQuery) (*bunpaginate.Cursor[models.Connector], error) { + cursor, err := s.storage.ConnectorsList(ctx, query) + return cursor, newStorageError(err, "failed to list connectors") +} diff --git a/internal/api/services/connectors_reset.go b/internal/api/services/connectors_reset.go new file mode 100644 index 00000000..b43a222d --- /dev/null +++ b/internal/api/services/connectors_reset.go @@ -0,0 +1,12 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) ConnectorsReset(ctx context.Context, connectorID models.ConnectorID) error { + err := s.engine.ResetConnector(ctx, connectorID) + return handleEngineErrors(err) +} diff --git a/internal/api/services/connectors_uninstall.go b/internal/api/services/connectors_uninstall.go new file mode 100644 index 00000000..4c546c5f --- /dev/null +++ b/internal/api/services/connectors_uninstall.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) ConnectorsUninstall(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.storage.ConnectorsGet(ctx, connectorID) + if err != nil { + return newStorageError(err, "get connector") + } + + return handleEngineErrors(s.engine.UninstallConnector(ctx, connectorID)) +} diff --git a/internal/api/services/errors.go b/internal/api/services/errors.go new file mode 100644 index 00000000..6d22fd34 --- /dev/null +++ b/internal/api/services/errors.go @@ -0,0 +1,56 @@ +package services + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine" + "github.com/pkg/errors" +) + +var ( + ErrValidation = errors.New("validation error") + ErrNotFound = errors.New("not found") +) + +type storageError struct { + err error + msg string +} + +func (e *storageError) Error() string { + return fmt.Sprintf("%s: %s", e.msg, e.err) +} + +func (e *storageError) Is(err error) bool { + _, ok := err.(*storageError) + return ok +} + +func (e *storageError) Unwrap() error { + return e.err +} + +func newStorageError(err error, msg string) error { + if err == nil { + return nil + } + return &storageError{ + err: err, + msg: msg, + } +} + +func handleEngineErrors(err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, engine.ErrValidation): + return errors.Wrap(ErrValidation, err.Error()) + case errors.Is(err, engine.ErrNotFound): + return errors.Wrap(ErrNotFound, err.Error()) + default: + return err + } +} diff --git a/internal/api/services/payment_initiation_adjustments_get.go b/internal/api/services/payment_initiation_adjustments_get.go new file mode 100644 index 00000000..fd4759ef --- /dev/null +++ b/internal/api/services/payment_initiation_adjustments_get.go @@ -0,0 +1,28 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "github.com/pkg/errors" +) + +func (s *Service) PaymentInitiationAdjustmentsGetLast(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiationAdjustment, error) { + q := storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(1), + ) + + cursor, err := s.storage.PaymentInitiationAdjustmentsList(ctx, id, q) + if err != nil { + return nil, newStorageError(err, "cannot list payment initiation's adjustments") + } + + if len(cursor.Data) == 0 { + return nil, errors.New("payment initiation's adjustments not found") + } + + return &cursor.Data[0], nil +} diff --git a/internal/api/services/payment_initiation_adjustments_list.go b/internal/api/services/payment_initiation_adjustments_list.go new file mode 100644 index 00000000..ba4ccd81 --- /dev/null +++ b/internal/api/services/payment_initiation_adjustments_list.go @@ -0,0 +1,44 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) PaymentInitiationAdjustmentsList(ctx context.Context, id models.PaymentInitiationID, query storage.ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) { + cursor, err := s.storage.PaymentInitiationAdjustmentsList(ctx, id, query) + return cursor, newStorageError(err, "failed to list payment initiation adjustments") +} + +func (s *Service) PaymentInitiationAdjustmentsListAll(ctx context.Context, id models.PaymentInitiationID) ([]models.PaymentInitiationAdjustment, error) { + q := storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(50), + ) + var next string + adjustments := []models.PaymentInitiationAdjustment{} + for { + if next != "" { + err := bunpaginate.UnmarshalCursor(next, &q) + if err != nil { + return nil, err + } + } + + cursor, err := s.storage.PaymentInitiationAdjustmentsList(ctx, id, q) + if err != nil { + return nil, newStorageError(err, "cannot list payment initiation's adjustments") + } + + adjustments = append(adjustments, cursor.Data...) + + if cursor.Next == "" { + break + } + } + + return adjustments, nil +} diff --git a/internal/api/services/payment_initiation_related_payments_list.go b/internal/api/services/payment_initiation_related_payments_list.go new file mode 100644 index 00000000..d4e4b8c9 --- /dev/null +++ b/internal/api/services/payment_initiation_related_payments_list.go @@ -0,0 +1,44 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) PaymentInitiationRelatedPaymentsList(ctx context.Context, id models.PaymentInitiationID, query storage.ListPaymentInitiationRelatedPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + cursor, err := s.storage.PaymentInitiationRelatedPaymentsList(ctx, id, query) + return cursor, newStorageError(err, "failed to list payment initiation related payments") +} + +func (s *Service) PaymentInitiationRelatedPaymentListAll(ctx context.Context, id models.PaymentInitiationID) ([]models.Payment, error) { + q := storage.NewListPaymentInitiationRelatedPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationRelatedPaymentsQuery{}). + WithPageSize(50), + ) + var next string + relatedPayment := []models.Payment{} + for { + if next != "" { + err := bunpaginate.UnmarshalCursor(next, &q) + if err != nil { + return nil, err + } + } + + cursor, err := s.storage.PaymentInitiationRelatedPaymentsList(ctx, id, q) + if err != nil { + return nil, newStorageError(err, "cannot list payment initiation's adjustments") + } + + relatedPayment = append(relatedPayment, cursor.Data...) + + if cursor.Next == "" { + break + } + } + + return relatedPayment, nil +} diff --git a/internal/api/services/payment_initiations_approve.go b/internal/api/services/payment_initiations_approve.go new file mode 100644 index 00000000..8f262e25 --- /dev/null +++ b/internal/api/services/payment_initiations_approve.go @@ -0,0 +1,57 @@ +package services + +import ( + "context" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "github.com/pkg/errors" +) + +func (s *Service) PaymentInitiationsApprove(ctx context.Context, id models.PaymentInitiationID, waitResult bool) (models.Task, error) { + cursor, err := s.storage.PaymentInitiationAdjustmentsList( + ctx, + id, + storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(1), + ), + ) + if err != nil { + return models.Task{}, newStorageError(err, "cannot list payment initiation's adjustments") + } + + if len(cursor.Data) == 0 { + return models.Task{}, errors.New("payment initiation's adjustments not found") + } + + lastAdjustment := cursor.Data[0] + + if lastAdjustment.Status != models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION { + return models.Task{}, fmt.Errorf("cannot approve an already approved payment initiation: %w", ErrValidation) + } + + pi, err := s.storage.PaymentInitiationsGet(ctx, id) + if err != nil { + return models.Task{}, newStorageError(err, "cannot get payment initiation") + } + + switch pi.Type { + case models.PAYMENT_INITIATION_TYPE_TRANSFER: + task, err := s.engine.CreateTransfer(ctx, pi.ID, 1, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + case models.PAYMENT_INITIATION_TYPE_PAYOUT: + task, err := s.engine.CreatePayout(ctx, pi.ID, 1, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + } + + return models.Task{}, nil +} diff --git a/internal/api/services/payment_initiations_create.go b/internal/api/services/payment_initiations_create.go new file mode 100644 index 00000000..8d92de87 --- /dev/null +++ b/internal/api/services/payment_initiations_create.go @@ -0,0 +1,47 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PaymentInitiationsCreate(ctx context.Context, paymentInitiation models.PaymentInitiation, sendToPSP bool, waitResult bool) (models.Task, error) { + waitingForValidationAdjustment := models.PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: paymentInitiation.ID, + CreatedAt: paymentInitiation.CreatedAt, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION, + }, + PaymentInitiationID: paymentInitiation.ID, + CreatedAt: paymentInitiation.CreatedAt, + Amount: paymentInitiation.Amount, + Asset: &paymentInitiation.Asset, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION, + } + + if !sendToPSP { + return models.Task{}, newStorageError(s.storage.PaymentInitiationsUpsert(ctx, paymentInitiation, waitingForValidationAdjustment), "cannot create payment initiation") + } + + if err := s.storage.PaymentInitiationsUpsert(ctx, paymentInitiation, waitingForValidationAdjustment); err != nil { + return models.Task{}, newStorageError(err, "cannot create payment initiation") + } + + switch paymentInitiation.Type { + case models.PAYMENT_INITIATION_TYPE_TRANSFER: + task, err := s.engine.CreateTransfer(ctx, paymentInitiation.ID, 1, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + case models.PAYMENT_INITIATION_TYPE_PAYOUT: + task, err := s.engine.CreatePayout(ctx, paymentInitiation.ID, 1, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + } + + return models.Task{}, nil +} diff --git a/internal/api/services/payment_initiations_delete.go b/internal/api/services/payment_initiations_delete.go new file mode 100644 index 00000000..b3cf231a --- /dev/null +++ b/internal/api/services/payment_initiations_delete.go @@ -0,0 +1,37 @@ +package services + +import ( + "context" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "github.com/pkg/errors" +) + +func (s *Service) PaymentInitiationsDelete(ctx context.Context, id models.PaymentInitiationID) error { + cursor, err := s.storage.PaymentInitiationAdjustmentsList( + ctx, + id, + storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(1), + ), + ) + if err != nil { + return newStorageError(err, "cannot list payment initiation's adjustments") + } + + if len(cursor.Data) == 0 { + return errors.New("payment initiation's adjustments not found") + } + + lastAdjustment := cursor.Data[0] + + if lastAdjustment.Status != models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION { + return fmt.Errorf("cannot delete an already approved payment initiation: %w", ErrValidation) + } + + return newStorageError(s.storage.PaymentInitiationsDelete(ctx, id), "cannot delete payment initiation") +} diff --git a/internal/api/services/payment_initiations_get.go b/internal/api/services/payment_initiations_get.go new file mode 100644 index 00000000..c129e89e --- /dev/null +++ b/internal/api/services/payment_initiations_get.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PaymentInitiationsGet(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiation, error) { + pi, err := s.storage.PaymentInitiationsGet(ctx, id) + if err != nil { + return nil, newStorageError(err, "cannot get payment initiation") + } + + return pi, nil +} diff --git a/internal/api/services/payment_initiations_list.go b/internal/api/services/payment_initiations_list.go new file mode 100644 index 00000000..eb4b2955 --- /dev/null +++ b/internal/api/services/payment_initiations_list.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) PaymentInitiationsList(ctx context.Context, query storage.ListPaymentInitiationsQuery) (*bunpaginate.Cursor[models.PaymentInitiation], error) { + pis, err := s.storage.PaymentInitiationsList(ctx, query) + if err != nil { + return nil, newStorageError(err, "cannot list payment initiations") + } + + return pis, nil +} diff --git a/internal/api/services/payment_initiations_reject.go b/internal/api/services/payment_initiations_reject.go new file mode 100644 index 00000000..84fb2f1d --- /dev/null +++ b/internal/api/services/payment_initiations_reject.go @@ -0,0 +1,51 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "github.com/pkg/errors" +) + +func (s *Service) PaymentInitiationsReject(ctx context.Context, id models.PaymentInitiationID) error { + cursor, err := s.storage.PaymentInitiationAdjustmentsList( + ctx, + id, + storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(1), + ), + ) + if err != nil { + return newStorageError(err, "cannot list payment initiation's adjustments") + } + + if len(cursor.Data) == 0 { + return errors.New("payment initiation's adjustments not found") + } + + lastAdjustment := cursor.Data[0] + + if lastAdjustment.Status != models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION { + return fmt.Errorf("cannot reject an already approved payment initiation: %w", ErrValidation) + } + + now := time.Now().UTC() + return newStorageError(s.storage.PaymentInitiationAdjustmentsUpsert( + ctx, + models.PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: id, + CreatedAt: now, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED, + }, + PaymentInitiationID: id, + CreatedAt: now, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED, + }, + ), "cannot reject payment initiation") +} diff --git a/internal/api/services/payment_initiations_retry.go b/internal/api/services/payment_initiations_retry.go new file mode 100644 index 00000000..fee5108b --- /dev/null +++ b/internal/api/services/payment_initiations_retry.go @@ -0,0 +1,105 @@ +package services + +import ( + "context" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "github.com/pkg/errors" +) + +func (s *Service) PaymentInitiationsRetry(ctx context.Context, id models.PaymentInitiationID, waitResult bool) (models.Task, error) { + adjustments, err := s.getAllPaymentInitiationAdjustments(ctx, id) + if err != nil { + return models.Task{}, err + } + + if len(adjustments) == 0 { + return models.Task{}, errors.New("payment initiation's adjustments not found") + } + + lastAdjustment := adjustments[0] + + isReversed := false + switch lastAdjustment.Status { + case models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED: + case models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED: + isReversed = true + default: + return models.Task{}, fmt.Errorf("cannot retry an already processed payment initiation: %w", ErrValidation) + } + + pi, err := s.storage.PaymentInitiationsGet(ctx, id) + if err != nil { + return models.Task{}, newStorageError(err, "cannot get payment initiation") + } + + attempts := getAttemps(adjustments, isReversed) + + if isReversed { + // TODO(polo): implement the reverse retry + return models.Task{}, fmt.Errorf("cannot retry a reversed payment initiation: %w", ErrValidation) + } else { + switch pi.Type { + case models.PAYMENT_INITIATION_TYPE_TRANSFER: + task, err := s.engine.CreateTransfer(ctx, pi.ID, attempts+1, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + case models.PAYMENT_INITIATION_TYPE_PAYOUT: + task, err := s.engine.CreatePayout(ctx, pi.ID, attempts+1, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + } + } + + return models.Task{}, nil +} + +func getAttemps(adjustments []models.PaymentInitiationAdjustment, isReversed bool) int { + attempts := 0 + for _, adjustment := range adjustments { + if isReversed && adjustment.Status == models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED { + attempts++ + } else if !isReversed && adjustment.Status == models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED { + attempts++ + } + } + + return attempts +} + +func (s *Service) getAllPaymentInitiationAdjustments(ctx context.Context, id models.PaymentInitiationID) ([]models.PaymentInitiationAdjustment, error) { + adjustments := []models.PaymentInitiationAdjustment{} + q := storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(50), + ) + var next string + for { + if next != "" { + err := bunpaginate.UnmarshalCursor(next, &q) + if err != nil { + return nil, err + } + } + + cursor, err := s.storage.PaymentInitiationAdjustmentsList(ctx, id, q) + if err != nil { + return nil, newStorageError(err, "cannot list payment initiation's adjustments") + } + + adjustments = append(adjustments, cursor.Data...) + + if cursor.Next == "" { + break + } + } + + return adjustments, nil +} diff --git a/internal/api/services/payment_initiations_reversal_create.go b/internal/api/services/payment_initiations_reversal_create.go new file mode 100644 index 00000000..6d7befba --- /dev/null +++ b/internal/api/services/payment_initiations_reversal_create.go @@ -0,0 +1,55 @@ +package services + +import ( + "context" + "fmt" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PaymentInitiationReversalsCreate(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) { + pi, err := s.storage.PaymentInitiationsGet(ctx, reversal.PaymentInitiationID) + if err != nil { + return models.Task{}, newStorageError(err, "cannot create payment initiation reversal") + } + + if pi.Asset != reversal.Asset { + return models.Task{}, fmt.Errorf("invalid asset for payment initiation reversal: %w", ErrValidation) + } + + if err := s.storage.PaymentInitiationReversalsUpsert( + ctx, + reversal, + []models.PaymentInitiationReversalAdjustment{ + { + ID: models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reversal.ID, + CreatedAt: reversal.CreatedAt, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, + }, + PaymentInitiationReversalID: reversal.ID, + CreatedAt: reversal.CreatedAt, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, + }, + }, + ); err != nil { + return models.Task{}, newStorageError(err, "cannot create payment initiation reversal") + } + + switch pi.Type { + case models.PAYMENT_INITIATION_TYPE_TRANSFER: + task, err := s.engine.ReverseTransfer(ctx, reversal, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + case models.PAYMENT_INITIATION_TYPE_PAYOUT: + task, err := s.engine.ReversePayout(ctx, reversal, waitResult) + if err != nil { + return models.Task{}, handleEngineErrors(err) + } + return task, nil + } + + return models.Task{}, nil +} diff --git a/internal/api/services/payments_create.go b/internal/api/services/payments_create.go new file mode 100644 index 00000000..b767685a --- /dev/null +++ b/internal/api/services/payments_create.go @@ -0,0 +1,11 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PaymentsCreate(ctx context.Context, payment models.Payment) error { + return handleEngineErrors(s.engine.CreateFormancePayment(ctx, payment)) +} diff --git a/internal/api/services/payments_get.go b/internal/api/services/payments_get.go new file mode 100644 index 00000000..1c687bf6 --- /dev/null +++ b/internal/api/services/payments_get.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PaymentsGet(ctx context.Context, id models.PaymentID) (*models.Payment, error) { + p, err := s.storage.PaymentsGet(ctx, id) + if err != nil { + return nil, newStorageError(err, "cannot get payment") + } + + return p, nil +} diff --git a/internal/api/services/payments_list.go b/internal/api/services/payments_list.go new file mode 100644 index 00000000..09249c7d --- /dev/null +++ b/internal/api/services/payments_list.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) PaymentsList(ctx context.Context, query storage.ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + ps, err := s.storage.PaymentsList(ctx, query) + if err != nil { + return nil, newStorageError(err, "cannot list payments") + } + + return ps, nil +} diff --git a/internal/api/services/payments_update_metadata.go b/internal/api/services/payments_update_metadata.go new file mode 100644 index 00000000..dc9a3ed4 --- /dev/null +++ b/internal/api/services/payments_update_metadata.go @@ -0,0 +1,11 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PaymentsUpdateMetadata(ctx context.Context, id models.PaymentID, metadata map[string]string) error { + return newStorageError(s.storage.PaymentsUpdateMetadata(ctx, id, metadata), "cannot update payment metadata") +} diff --git a/internal/api/services/pools_add_account.go b/internal/api/services/pools_add_account.go new file mode 100644 index 00000000..f7cca80e --- /dev/null +++ b/internal/api/services/pools_add_account.go @@ -0,0 +1,13 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (s *Service) PoolsAddAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + err := s.engine.AddAccountToPool(ctx, id, accountID) + return handleEngineErrors(err) +} diff --git a/internal/api/services/pools_balances_at.go b/internal/api/services/pools_balances_at.go new file mode 100644 index 00000000..4d4c6efe --- /dev/null +++ b/internal/api/services/pools_balances_at.go @@ -0,0 +1,44 @@ +package services + +import ( + "context" + "math/big" + "time" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (s *Service) PoolsBalancesAt(ctx context.Context, poolID uuid.UUID, at time.Time) ([]models.AggregatedBalance, error) { + pool, err := s.storage.PoolsGet(ctx, poolID) + if err != nil { + return nil, newStorageError(err, "getting pool") + } + res := make(map[string]*big.Int) + for _, poolAccount := range pool.PoolAccounts { + balances, err := s.storage.BalancesGetAt(ctx, poolAccount.AccountID, at) + if err != nil { + return nil, newStorageError(err, "getting balances") + } + + for _, balance := range balances { + amount, ok := res[balance.Asset] + if !ok { + amount = big.NewInt(0) + } + + amount.Add(amount, balance.Balance) + res[balance.Asset] = amount + } + } + + balances := make([]models.AggregatedBalance, 0, len(res)) + for asset, amount := range res { + balances = append(balances, models.AggregatedBalance{ + Asset: asset, + Amount: amount, + }) + } + + return balances, nil +} diff --git a/internal/api/services/pools_create.go b/internal/api/services/pools_create.go new file mode 100644 index 00000000..929cd942 --- /dev/null +++ b/internal/api/services/pools_create.go @@ -0,0 +1,12 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) PoolsCreate(ctx context.Context, pool models.Pool) error { + err := s.engine.CreatePool(ctx, pool) + return handleEngineErrors(err) +} diff --git a/internal/api/services/pools_delete.go b/internal/api/services/pools_delete.go new file mode 100644 index 00000000..2e41a23b --- /dev/null +++ b/internal/api/services/pools_delete.go @@ -0,0 +1,12 @@ +package services + +import ( + "context" + + "github.com/google/uuid" +) + +func (s *Service) PoolsDelete(ctx context.Context, id uuid.UUID) error { + err := s.engine.DeletePool(ctx, id) + return handleEngineErrors(err) +} diff --git a/internal/api/services/pools_get.go b/internal/api/services/pools_get.go new file mode 100644 index 00000000..89563489 --- /dev/null +++ b/internal/api/services/pools_get.go @@ -0,0 +1,17 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (s *Service) PoolsGet(ctx context.Context, id uuid.UUID) (*models.Pool, error) { + p, err := s.storage.PoolsGet(ctx, id) + if err != nil { + return nil, newStorageError(err, "cannot get pool") + } + + return p, nil +} diff --git a/internal/api/services/pools_list.go b/internal/api/services/pools_list.go new file mode 100644 index 00000000..12f76d48 --- /dev/null +++ b/internal/api/services/pools_list.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) PoolsList(ctx context.Context, query storage.ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { + ps, err := s.storage.PoolsList(ctx, query) + if err != nil { + return nil, newStorageError(err, "cannot list pools") + } + + return ps, nil +} diff --git a/internal/api/services/pools_remove_account.go b/internal/api/services/pools_remove_account.go new file mode 100644 index 00000000..84163cb4 --- /dev/null +++ b/internal/api/services/pools_remove_account.go @@ -0,0 +1,13 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (s *Service) PoolsRemoveAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + err := s.engine.RemoveAccountFromPool(ctx, id, accountID) + return handleEngineErrors(err) +} diff --git a/internal/api/services/schedules_get.go b/internal/api/services/schedules_get.go new file mode 100644 index 00000000..38ac4646 --- /dev/null +++ b/internal/api/services/schedules_get.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) SchedulesGet(ctx context.Context, id string, connectorID models.ConnectorID) (*models.Schedule, error) { + schedule, err := s.storage.SchedulesGet(ctx, id, connectorID) + if err != nil { + return nil, newStorageError(err, "cannot get schedule") + } + + return schedule, nil +} diff --git a/internal/api/services/schedules_list.go b/internal/api/services/schedules_list.go new file mode 100644 index 00000000..2e9f6768 --- /dev/null +++ b/internal/api/services/schedules_list.go @@ -0,0 +1,14 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) SchedulesList(ctx context.Context, query storage.ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) { + cursor, err := s.storage.SchedulesList(ctx, query) + return cursor, newStorageError(err, "failed to list schedules") +} diff --git a/internal/api/services/services.go b/internal/api/services/services.go new file mode 100644 index 00000000..88b34eec --- /dev/null +++ b/internal/api/services/services.go @@ -0,0 +1,19 @@ +package services + +import ( + "github.com/formancehq/payments/internal/connectors/engine" + "github.com/formancehq/payments/internal/storage" +) + +type Service struct { + storage storage.Storage + + engine engine.Engine +} + +func New(storage storage.Storage, engine engine.Engine) *Service { + return &Service{ + storage: storage, + engine: engine, + } +} diff --git a/internal/api/services/tasks_get.go b/internal/api/services/tasks_get.go new file mode 100644 index 00000000..1c0ad55c --- /dev/null +++ b/internal/api/services/tasks_get.go @@ -0,0 +1,16 @@ +package services + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (s *Service) TaskGet(ctx context.Context, id models.TaskID) (*models.Task, error) { + task, err := s.storage.TasksGet(ctx, id) + if err != nil { + return nil, newStorageError(err, "cannot get task") + } + + return task, nil +} diff --git a/internal/api/services/workflows_instances_list.go b/internal/api/services/workflows_instances_list.go new file mode 100644 index 00000000..b6848bbe --- /dev/null +++ b/internal/api/services/workflows_instances_list.go @@ -0,0 +1,14 @@ +package services + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" +) + +func (s *Service) WorkflowsInstancesList(ctx context.Context, query storage.ListInstancesQuery) (*bunpaginate.Cursor[models.Instance], error) { + cursor, err := s.storage.InstancesList(ctx, query) + return cursor, newStorageError(err, "failed to list instances") +} diff --git a/internal/api/v2/errors.go b/internal/api/v2/errors.go new file mode 100644 index 00000000..dc7a216a --- /dev/null +++ b/internal/api/v2/errors.go @@ -0,0 +1,35 @@ +package v2 + +import ( + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/services" + "github.com/formancehq/payments/internal/storage" +) + +const ( + ErrUniqueReference = "CONFLICT" + ErrNotFound = "NOT_FOUND" + ErrInvalidID = "INVALID_ID" + ErrMissingOrInvalidBody = "MISSING_OR_INVALID_BODY" + ErrValidation = "VALIDATION" +) + +func handleServiceErrors(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrDuplicateKeyValue): + api.BadRequest(w, ErrUniqueReference, err) + case errors.Is(err, storage.ErrNotFound): + api.NotFound(w, err) + case errors.Is(err, storage.ErrValidation): + api.BadRequest(w, ErrValidation, err) + case errors.Is(err, services.ErrValidation): + api.BadRequest(w, ErrValidation, err) + case errors.Is(err, services.ErrNotFound): + api.NotFound(w, err) + default: + api.InternalServerError(w, r, err) + } +} diff --git a/internal/api/v2/handler_accounts_balances.go b/internal/api/v2/handler_accounts_balances.go new file mode 100644 index 00000000..afef4160 --- /dev/null +++ b/internal/api/v2/handler_accounts_balances.go @@ -0,0 +1,140 @@ +package v2 + +import ( + "encoding/json" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type balancesResponse struct { + AccountID string `json:"accountId"` + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Asset string `json:"asset"` + Balance *big.Int `json:"balance"` +} + +func accountsBalances(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_accountsBalances") + defer span.End() + + balanceQuery, err := populateBalanceQueryFromRequest(span, r) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + query, err := bunpaginate.Extract[storage.ListBalancesQuery](r, func() (*storage.ListBalancesQuery, error) { + options, err := getPagination(span, r, balanceQuery) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListBalancesQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.BalancesList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + ret := cursor.Data + data := make([]*balancesResponse, len(ret)) + + for i := range ret { + data[i] = &balancesResponse{ + AccountID: ret[i].AccountID.String(), + CreatedAt: ret[i].CreatedAt, + Asset: ret[i].Asset, + Balance: ret[i].Balance, + LastUpdatedAt: ret[i].LastUpdatedAt, + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[*balancesResponse]{ + Cursor: &bunpaginate.Cursor[*balancesResponse]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func populateBalanceQueryFromRequest(span trace.Span, r *http.Request) (storage.BalanceQuery, error) { + var balanceQuery storage.BalanceQuery + + balanceQuery = balanceQuery.WithAsset(r.URL.Query().Get("asset")) + span.SetAttributes(attribute.String("asset", balanceQuery.Asset)) + + span.SetAttributes(attribute.String("accountID", accountID(r))) + accountID, err := models.AccountIDFromString(accountID(r)) + if err != nil { + return balanceQuery, err + } + balanceQuery = balanceQuery.WithAccountID(&accountID) + + var startTimeParsed, endTimeParsed time.Time + + from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") + if from != "" { + startTimeParsed, err = time.Parse(time.RFC3339Nano, from) + if err != nil { + return balanceQuery, err + } + } + if to != "" { + endTimeParsed, err = time.Parse(time.RFC3339Nano, to) + if err != nil { + return balanceQuery, err + } + } + + switch { + case startTimeParsed.IsZero() && endTimeParsed.IsZero(): + balanceQuery = balanceQuery. + WithTo(time.Now()) + case !startTimeParsed.IsZero() && endTimeParsed.IsZero(): + balanceQuery = balanceQuery. + WithFrom(startTimeParsed). + WithTo(time.Now()) + case startTimeParsed.IsZero() && !endTimeParsed.IsZero(): + balanceQuery = balanceQuery. + WithTo(endTimeParsed) + default: + balanceQuery = balanceQuery. + WithFrom(startTimeParsed). + WithTo(endTimeParsed) + } + + span.SetAttributes(attribute.String("from", balanceQuery.From.Format(time.RFC3339Nano))) + span.SetAttributes(attribute.String("to", balanceQuery.To.Format(time.RFC3339Nano))) + + return balanceQuery, nil +} diff --git a/internal/api/v2/handler_accounts_balances_test.go b/internal/api/v2/handler_accounts_balances_test.go new file mode 100644 index 00000000..de89c4f4 --- /dev/null +++ b/internal/api/v2/handler_accounts_balances_test.go @@ -0,0 +1,65 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Accounts Balances", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + }) + + Context("list balances", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsBalances(m) + }) + + It("should return a validation request error when account ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().BalancesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Balance]{}, fmt.Errorf("balances list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().BalancesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Balance]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_accounts_create.go b/internal/api/v2/handler_accounts_create.go new file mode 100644 index 00000000..4f976231 --- /dev/null +++ b/internal/api/v2/handler_accounts_create.go @@ -0,0 +1,157 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type CreateAccountRequest struct { + Reference string `json:"reference"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + DefaultAsset string `json:"defaultAsset"` + AccountName string `json:"accountName"` + Type string `json:"type"` + Metadata map[string]string `json:"metadata"` +} + +func (r *CreateAccountRequest) validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.ConnectorID == "" { + return errors.New("connectorID is required") + } + + if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { + return errors.New("createdAt is empty or in the future") + } + + if r.AccountName == "" { + return errors.New("accountName is required") + } + + if r.Type == "" { + return errors.New("type is required") + } + + _, err := models.ConnectorIDFromString(r.ConnectorID) + if err != nil { + return errors.New("connectorID is invalid") + } + + switch r.Type { + case string(models.ACCOUNT_TYPE_EXTERNAL): + case string(models.ACCOUNT_TYPE_INTERNAL): + default: + return errors.New("type is invalid") + } + + return nil +} + +func accountsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_accountsCreate") + defer span.End() + + var req CreateAccountRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromAccountCreateRequest(span, req) + + if err := req.validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectorID := models.MustConnectorIDFromString(req.ConnectorID) + raw, err := json.Marshal(req) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + + account := models.Account{ + ID: models.AccountID{ + Reference: req.Reference, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Reference: req.Reference, + CreatedAt: req.CreatedAt, + Type: models.AccountType(req.Type), + Name: &req.AccountName, + DefaultAsset: &req.DefaultAsset, + Metadata: req.Metadata, + Raw: raw, + } + + err = backend.AccountsCreate(ctx, account) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + // Compatibility with old API + res := accountResponse{ + ID: account.ID.String(), + Reference: account.Reference, + CreatedAt: account.CreatedAt, + ConnectorID: account.ConnectorID.String(), + Provider: account.ConnectorID.Provider, + Type: string(account.Type), + Metadata: account.Metadata, + Raw: account.Raw, + } + + if account.DefaultAsset != nil { + res.DefaultCurrency = *account.DefaultAsset + res.DefaultAsset = *account.DefaultAsset + } + + if account.Name != nil { + res.AccountName = *account.Name + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[accountResponse]{ + Data: &res, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func populateSpanFromAccountCreateRequest(span trace.Span, req CreateAccountRequest) { + span.SetAttributes(attribute.String("reference", req.Reference)) + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + span.SetAttributes(attribute.String("createdAt", req.CreatedAt.String())) + span.SetAttributes(attribute.String("defaultAsset", req.DefaultAsset)) + span.SetAttributes(attribute.String("accountName", req.AccountName)) + span.SetAttributes(attribute.String("type", req.Type)) + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v2/handler_accounts_create_test.go b/internal/api/v2/handler_accounts_create_test.go new file mode 100644 index 00000000..b6c2fe6d --- /dev/null +++ b/internal/api/v2/handler_accounts_create_test.go @@ -0,0 +1,92 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Accounts Create", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("create accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + cra CreateAccountRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(cra CreateAccountRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &cra)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", CreateAccountRequest{}), + Entry("connectorID missing", CreateAccountRequest{Reference: "reference"}), + Entry("createdAt missing", CreateAccountRequest{Reference: "reference", ConnectorID: "id"}), + Entry("accountName missing", CreateAccountRequest{Reference: "reference", ConnectorID: "id", CreatedAt: time.Now()}), + Entry("type missing", CreateAccountRequest{ + Reference: "reference", ConnectorID: "id", CreatedAt: time.Now(), AccountName: "accountName", + }), + Entry("connectorID invalid", CreateAccountRequest{ + Reference: "reference", ConnectorID: "id", CreatedAt: time.Now(), AccountName: "accountName", Type: "type", + }), + Entry("type invalid", CreateAccountRequest{ + Reference: "reference", ConnectorID: connID.String(), CreatedAt: time.Now(), AccountName: "accountName", Type: "type", + }), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("account create err") + m.EXPECT().AccountsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + cra = CreateAccountRequest{ + Reference: "reference", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + AccountName: "accountName", + Type: string(models.ACCOUNT_TYPE_EXTERNAL), + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cra)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status created on success", func(ctx SpecContext) { + m.EXPECT().AccountsCreate(gomock.Any(), gomock.Any()).Return(nil) + cra = CreateAccountRequest{ + Reference: "reference", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + AccountName: "accountName", + Type: string(models.ACCOUNT_TYPE_EXTERNAL), + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cra)) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_accounts_get.go b/internal/api/v2/handler_accounts_get.go new file mode 100644 index 00000000..e4f75a5f --- /dev/null +++ b/internal/api/v2/handler_accounts_get.go @@ -0,0 +1,63 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func accountsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_accountsGet") + defer span.End() + + span.SetAttributes(attribute.String("accountID", accountID(r))) + id, err := models.AccountIDFromString(accountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + account, err := backend.AccountsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := &accountResponse{ + ID: account.ID.String(), + Reference: account.Reference, + CreatedAt: account.CreatedAt, + ConnectorID: account.ConnectorID.String(), + Provider: account.ConnectorID.Provider, + Type: string(account.Type), + Metadata: account.Metadata, + Raw: account.Raw, + } + + if account.DefaultAsset != nil { + data.DefaultCurrency = *account.DefaultAsset + data.DefaultAsset = *account.DefaultAsset + } + + if account.Name != nil { + data.AccountName = *account.Name + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[accountResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_accounts_get_test.go b/internal/api/v2/handler_accounts_get_test.go new file mode 100644 index 00000000..e3a8f597 --- /dev/null +++ b/internal/api/v2/handler_accounts_get_test.go @@ -0,0 +1,64 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Accounts", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + }) + + Context("get accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsGet(m) + }) + + It("should return an invalid ID error when account ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().AccountsGet(gomock.Any(), accID).Return( + &models.Account{}, fmt.Errorf("accounts get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().AccountsGet(gomock.Any(), accID).Return( + &models.Account{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_accounts_list.go b/internal/api/v2/handler_accounts_list.go new file mode 100644 index 00000000..14e0aa73 --- /dev/null +++ b/internal/api/v2/handler_accounts_list.go @@ -0,0 +1,97 @@ +package v2 + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +// NOTE: in order to maintain previous version compatibility, we need to keep the +// same response structure as the previous version of the API +type accountResponse struct { + ID string `json:"id"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + ConnectorID string `json:"connectorID"` + Provider string `json:"provider"` + DefaultCurrency string `json:"defaultCurrency"` // Deprecated: should be removed soon + DefaultAsset string `json:"defaultAsset"` + AccountName string `json:"accountName"` + Type string `json:"type"` + Metadata map[string]string `json:"metadata"` + // TODO(polo): add pools + // Pools []uuid.UUID `json:"pools"` + Raw interface{} `json:"raw"` +} + +func accountsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_accountsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListAccountsQuery](r, func() (*storage.ListAccountsQuery, error) { + options, err := getPagination(span, r, storage.AccountQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListAccountsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.AccountsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]*accountResponse, len(cursor.Data)) + for i := range cursor.Data { + data[i] = &accountResponse{ + ID: cursor.Data[i].ID.String(), + Reference: cursor.Data[i].Reference, + CreatedAt: cursor.Data[i].CreatedAt, + ConnectorID: cursor.Data[i].ConnectorID.String(), + Provider: cursor.Data[i].ConnectorID.Provider, + Type: string(cursor.Data[i].Type), + Metadata: cursor.Data[i].Metadata, + Raw: cursor.Data[i].Raw, + } + + if cursor.Data[i].DefaultAsset != nil { + data[i].DefaultCurrency = *cursor.Data[i].DefaultAsset + data[i].DefaultAsset = *cursor.Data[i].DefaultAsset + } + + if cursor.Data[i].Name != nil { + data[i].AccountName = *cursor.Data[i].Name + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[*accountResponse]{ + Cursor: &bunpaginate.Cursor[*accountResponse]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_accounts_list_test.go b/internal/api/v2/handler_accounts_list_test.go new file mode 100644 index 00000000..2dd3b179 --- /dev/null +++ b/internal/api/v2/handler_accounts_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Accounts List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().AccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Account]{}, fmt.Errorf("accounts list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().AccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Account]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_bank_accounts_create.go b/internal/api/v2/handler_bank_accounts_create.go new file mode 100644 index 00000000..2192e904 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_create.go @@ -0,0 +1,187 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// NOTE: in order to maintain previous version compatibility, we need to keep the +// same response structure as the previous version of the API +type bankAccountRelatedAccountsResponse struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + AccountID string `json:"accountID"` + ConnectorID string `json:"connectorID"` + Provider string `json:"provider"` +} + +type BankAccountResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Country string `json:"country"` + Iban string `json:"iban,omitempty"` + AccountNumber string `json:"accountNumber,omitempty"` + SwiftBicCode string `json:"swiftBicCode,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + RelatedAccounts []*bankAccountRelatedAccountsResponse `json:"relatedAccounts,omitempty"` +} + +type BankAccountsCreateRequest struct { + Name string `json:"name"` + + AccountNumber *string `json:"accountNumber"` + IBAN *string `json:"iban"` + SwiftBicCode *string `json:"swiftBicCode"` + Country *string `json:"country"` + ConnectorID *string `json:"connectorID"` + + Metadata map[string]string `json:"metadata"` +} + +func (r *BankAccountsCreateRequest) Validate() error { + if r.AccountNumber == nil && r.IBAN == nil { + return errors.New("either accountNumber or iban must be provided") + } + + if r.Name == "" { + return errors.New("name must be provided") + } + + return nil +} + +func bankAccountsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_bankAccountsCreate") + defer span.End() + + var req BankAccountsCreateRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromBankAccountCreateRequest(span, req) + + if err := req.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + var connectorID *models.ConnectorID + if req.ConnectorID != nil { + c, err := models.ConnectorIDFromString(*req.ConnectorID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + connectorID = &c + } + + bankAccount := &models.BankAccount{ + ID: uuid.New(), + CreatedAt: time.Now().UTC(), + Name: req.Name, + AccountNumber: req.AccountNumber, + IBAN: req.IBAN, + SwiftBicCode: req.SwiftBicCode, + Country: req.Country, + Metadata: req.Metadata, + } + + err = backend.BankAccountsCreate(ctx, *bankAccount) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + if connectorID != nil { + _, err = backend.BankAccountsForwardToConnector(ctx, bankAccount.ID, *connectorID, true) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + bankAccount, err = backend.BankAccountsGet(ctx, bankAccount.ID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + } + + data := &BankAccountResponse{ + ID: bankAccount.ID.String(), + Name: bankAccount.Name, + CreatedAt: bankAccount.CreatedAt, + Metadata: bankAccount.Metadata, + } + + if bankAccount.IBAN != nil { + data.Iban = *bankAccount.IBAN + } + + if bankAccount.AccountNumber != nil { + data.AccountNumber = *bankAccount.AccountNumber + } + + if bankAccount.SwiftBicCode != nil { + data.SwiftBicCode = *bankAccount.SwiftBicCode + } + + if bankAccount.Country != nil { + data.Country = *bankAccount.Country + } + + for _, relatedAccount := range bankAccount.RelatedAccounts { + data.RelatedAccounts = append(data.RelatedAccounts, &bankAccountRelatedAccountsResponse{ + ID: "", + CreatedAt: relatedAccount.CreatedAt, + AccountID: relatedAccount.AccountID.String(), + ConnectorID: relatedAccount.ConnectorID.String(), + Provider: relatedAccount.ConnectorID.Provider, + }) + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[BankAccountResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func populateSpanFromBankAccountCreateRequest(span trace.Span, req BankAccountsCreateRequest) { + span.SetAttributes(attribute.String("name", req.Name)) + + // Do not record sensitive information + + if req.Country != nil { + span.SetAttributes(attribute.String("country", *req.Country)) + } + + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v2/handler_bank_accounts_create_test.go b/internal/api/v2/handler_bank_accounts_create_test.go new file mode 100644 index 00000000..1a3ebea7 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_create_test.go @@ -0,0 +1,77 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Bank Accounts Create", func() { + var ( + handlerFn http.HandlerFunc + accountNumber string + iban string + ) + BeforeEach(func() { + accountNumber = "1232434" + iban = "DE89370400440532013000" + }) + + Context("create bank accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + bac BankAccountsCreateRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(bac BankAccountsCreateRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &bac)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("account number missing", BankAccountsCreateRequest{}), + Entry("iban missing", BankAccountsCreateRequest{AccountNumber: &accountNumber}), + Entry("name missing", BankAccountsCreateRequest{AccountNumber: &accountNumber, IBAN: &iban}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("bank account create err") + m.EXPECT().BankAccountsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + bac = BankAccountsCreateRequest{ + Name: "reference", + IBAN: &iban, + AccountNumber: &accountNumber, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &bac)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().BankAccountsCreate(gomock.Any(), gomock.Any()).Return(nil) + bac = BankAccountsCreateRequest{ + Name: "reference", + IBAN: &iban, + AccountNumber: &accountNumber, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &bac)) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_bank_accounts_forward_to_connector.go b/internal/api/v2/handler_bank_accounts_forward_to_connector.go new file mode 100644 index 00000000..8577fe53 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_forward_to_connector.go @@ -0,0 +1,121 @@ +package v2 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +type BankAccountsForwardToConnectorRequest struct { + ConnectorID string `json:"connectorID"` +} + +func (f *BankAccountsForwardToConnectorRequest) Validate() error { + if f.ConnectorID == "" { + return errors.New("connectorID must be provided") + } + + return nil +} + +func bankAccountsForwardToConnector(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_bankAccountsForwardToConnector") + defer span.End() + + span.SetAttributes(attribute.String("bankAccountID", bankAccountID(r))) + id, err := uuid.Parse(bankAccountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var req BankAccountsForwardToConnectorRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + + err = req.Validate() + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + connectorID, err := models.ConnectorIDFromString(req.ConnectorID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + _, err = backend.BankAccountsForwardToConnector(ctx, id, connectorID, true) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + bankAccount, err := backend.BankAccountsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := &BankAccountResponse{ + ID: bankAccount.ID.String(), + Name: bankAccount.Name, + CreatedAt: bankAccount.CreatedAt, + Metadata: bankAccount.Metadata, + } + + if bankAccount.IBAN != nil { + data.Iban = *bankAccount.IBAN + } + + if bankAccount.AccountNumber != nil { + data.AccountNumber = *bankAccount.AccountNumber + } + + if bankAccount.SwiftBicCode != nil { + data.SwiftBicCode = *bankAccount.SwiftBicCode + } + + if bankAccount.Country != nil { + data.Country = *bankAccount.Country + } + + for _, relatedAccount := range bankAccount.RelatedAccounts { + data.RelatedAccounts = append(data.RelatedAccounts, &bankAccountRelatedAccountsResponse{ + ID: "", + CreatedAt: relatedAccount.CreatedAt, + AccountID: relatedAccount.AccountID.String(), + ConnectorID: relatedAccount.ConnectorID.String(), + Provider: relatedAccount.ConnectorID.Provider, + }) + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[BankAccountResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_bank_accounts_forward_to_connector_test.go b/internal/api/v2/handler_bank_accounts_forward_to_connector_test.go new file mode 100644 index 00000000..7e44ba48 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_forward_to_connector_test.go @@ -0,0 +1,76 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Bank Accounts ForwardToConnector", func() { + var ( + handlerFn http.HandlerFunc + bankAccountID uuid.UUID + connID models.ConnectorID + ) + BeforeEach(func() { + bankAccountID = uuid.New() + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("forward bank accounts to connector", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + freq BankAccountsForwardToConnectorRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsForwardToConnector(m) + }) + + DescribeTable("validation errors", + func(expected string, freq BankAccountsForwardToConnectorRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "bankAccountID", bankAccountID.String(), &freq)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, expected) + }, + Entry("connector ID missing", ErrMissingOrInvalidBody, BankAccountsForwardToConnectorRequest{}), + Entry("connector ID invalid", ErrValidation, BankAccountsForwardToConnectorRequest{ConnectorID: "blah"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + m.EXPECT().BankAccountsForwardToConnector(gomock.Any(), bankAccountID, connID, true).Return( + models.Task{}, + fmt.Errorf("bank account forward err"), + ) + freq = BankAccountsForwardToConnectorRequest{ + ConnectorID: connID.String(), + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "bankAccountID", bankAccountID.String(), &freq)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().BankAccountsForwardToConnector(gomock.Any(), bankAccountID, connID, true).Return( + models.Task{}, + nil, + ) + m.EXPECT().BankAccountsGet(gomock.Any(), bankAccountID).Return( + &models.BankAccount{}, + nil, + ) + freq = BankAccountsForwardToConnectorRequest{ + ConnectorID: connID.String(), + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "bankAccountID", bankAccountID.String(), &freq)) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_bank_accounts_get.go b/internal/api/v2/handler_bank_accounts_get.go new file mode 100644 index 00000000..0c48ab11 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_get.go @@ -0,0 +1,76 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func bankAccountsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_bankAccountsGet") + defer span.End() + + span.SetAttributes(attribute.String("bankAccountID", bankAccountID(r))) + id, err := uuid.Parse(bankAccountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + bankAccount, err := backend.BankAccountsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := &BankAccountResponse{ + ID: bankAccount.ID.String(), + Name: bankAccount.Name, + CreatedAt: bankAccount.CreatedAt, + Metadata: bankAccount.Metadata, + } + + if bankAccount.IBAN != nil { + data.Iban = *bankAccount.IBAN + } + + if bankAccount.AccountNumber != nil { + data.AccountNumber = *bankAccount.AccountNumber + } + + if bankAccount.SwiftBicCode != nil { + data.SwiftBicCode = *bankAccount.SwiftBicCode + } + + if bankAccount.Country != nil { + data.Country = *bankAccount.Country + } + + for _, relatedAccount := range bankAccount.RelatedAccounts { + data.RelatedAccounts = append(data.RelatedAccounts, &bankAccountRelatedAccountsResponse{ + ID: "", + CreatedAt: relatedAccount.CreatedAt, + AccountID: relatedAccount.AccountID.String(), + ConnectorID: relatedAccount.ConnectorID.String(), + Provider: relatedAccount.ConnectorID.Provider, + }) + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[BankAccountResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_bank_accounts_get_test.go b/internal/api/v2/handler_bank_accounts_get_test.go new file mode 100644 index 00000000..940c2caf --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_get_test.go @@ -0,0 +1,63 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Bank Accounts", func() { + var ( + handlerFn http.HandlerFunc + accID uuid.UUID + ) + BeforeEach(func() { + accID = uuid.New() + }) + + Context("get bank accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsGet(m) + }) + + It("should return an invalid ID error when account ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "bankAccountID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "bankAccountID", accID.String()) + m.EXPECT().BankAccountsGet(gomock.Any(), accID).Return( + &models.BankAccount{}, fmt.Errorf("bank accounts get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "bankAccountID", accID.String()) + m.EXPECT().BankAccountsGet(gomock.Any(), accID).Return( + &models.BankAccount{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_bank_accounts_list.go b/internal/api/v2/handler_bank_accounts_list.go new file mode 100644 index 00000000..992e5416 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_list.go @@ -0,0 +1,93 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func bankAccountsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_bankAccountsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListBankAccountsQuery](r, func() (*storage.ListBankAccountsQuery, error) { + options, err := getPagination(span, r, storage.BankAccountQuery{}) + if err != nil { + otel.RecordError(span, err) + return nil, err + } + return pointer.For(storage.NewListBankAccountsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.BankAccountsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]*BankAccountResponse, len(cursor.Data)) + for i := range cursor.Data { + data[i] = &BankAccountResponse{ + ID: cursor.Data[i].ID.String(), + Name: cursor.Data[i].Name, + CreatedAt: cursor.Data[i].CreatedAt, + Metadata: cursor.Data[i].Metadata, + } + + if cursor.Data[i].IBAN != nil { + data[i].Iban = *cursor.Data[i].IBAN + } + + if cursor.Data[i].AccountNumber != nil { + data[i].AccountNumber = *cursor.Data[i].AccountNumber + } + + if cursor.Data[i].SwiftBicCode != nil { + data[i].SwiftBicCode = *cursor.Data[i].SwiftBicCode + } + + if cursor.Data[i].Country != nil { + data[i].Country = *cursor.Data[i].Country + } + + data[i].RelatedAccounts = make([]*bankAccountRelatedAccountsResponse, len(cursor.Data[i].RelatedAccounts)) + for j := range cursor.Data[i].RelatedAccounts { + data[i].RelatedAccounts[j] = &bankAccountRelatedAccountsResponse{ + ID: "", + CreatedAt: cursor.Data[i].RelatedAccounts[j].CreatedAt, + AccountID: cursor.Data[i].RelatedAccounts[j].AccountID.String(), + ConnectorID: cursor.Data[i].RelatedAccounts[j].ConnectorID.String(), + Provider: cursor.Data[i].RelatedAccounts[j].ConnectorID.Provider, + } + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[*BankAccountResponse]{ + Cursor: &bunpaginate.Cursor[*BankAccountResponse]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_bank_accounts_list_test.go b/internal/api/v2/handler_bank_accounts_list_test.go new file mode 100644 index 00000000..e27969dc --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Bank Accounts List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list bank accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().BankAccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.BankAccount]{}, fmt.Errorf("bank accounts list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().BankAccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.BankAccount]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_bank_accounts_update_metadata.go b/internal/api/v2/handler_bank_accounts_update_metadata.go new file mode 100644 index 00000000..96118090 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_update_metadata.go @@ -0,0 +1,73 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type BankAccountsUpdateMetadataRequest struct { + Metadata map[string]string `json:"metadata"` +} + +func (u *BankAccountsUpdateMetadataRequest) Validate() error { + if len(u.Metadata) == 0 { + return errors.New("metadata must be provided") + } + + return nil +} + +func bankAccountsUpdateMetadata(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_bankAccountsUpdateMetadata") + defer span.End() + + id, err := uuid.Parse(bankAccountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var req BankAccountsUpdateMetadataRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromUpdateMetadataRequest(span, req.Metadata) + + err = req.Validate() + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + err = backend.BankAccountsUpdateMetadata(ctx, id, req.Metadata) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} + +func populateSpanFromUpdateMetadataRequest(span trace.Span, metadata map[string]string) { + for k, v := range metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v2/handler_bank_accounts_update_metadata_test.go b/internal/api/v2/handler_bank_accounts_update_metadata_test.go new file mode 100644 index 00000000..b3e81d54 --- /dev/null +++ b/internal/api/v2/handler_bank_accounts_update_metadata_test.go @@ -0,0 +1,70 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Bank Accounts Update Metadata", func() { + var ( + handlerFn http.HandlerFunc + bankAccountID uuid.UUID + ) + BeforeEach(func() { + bankAccountID = uuid.New() + }) + + Context("update bank account metadata", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + bau BankAccountsUpdateMetadataRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsUpdateMetadata(m) + }) + + It("should return a bad request error when bank account is invalid", func(ctx SpecContext) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", "invalid", &bau)) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + DescribeTable("validation errors", + func(bau BankAccountsUpdateMetadataRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", bankAccountID.String(), &bau)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("metadata missing", BankAccountsUpdateMetadataRequest{}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("bank account create err") + m.EXPECT().BankAccountsUpdateMetadata(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + bau = BankAccountsUpdateMetadataRequest{ + Metadata: map[string]string{"meta": "data"}, + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", bankAccountID.String(), &bau)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + metadata := map[string]string{"meta": "data"} + m.EXPECT().BankAccountsUpdateMetadata(gomock.Any(), gomock.Any(), metadata).Return(nil) + bau = BankAccountsUpdateMetadataRequest{ + Metadata: metadata, + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", bankAccountID.String(), &bau)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_config.go b/internal/api/v2/handler_connectors_config.go new file mode 100644 index 00000000..9b8069bf --- /dev/null +++ b/internal/api/v2/handler_connectors_config.go @@ -0,0 +1,35 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsConfig(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_connectorsConfig") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + config, err := backend.ConnectorsConfig(ctx, connectorID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, config) + } +} diff --git a/internal/api/v2/handler_connectors_config_test.go b/internal/api/v2/handler_connectors_config_test.go new file mode 100644 index 00000000..d8cf9ac9 --- /dev/null +++ b/internal/api/v2/handler_connectors_config_test.go @@ -0,0 +1,64 @@ +package v2 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connectors Config", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("get connectors config", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsConfig(m) + }) + + It("should return an invalid ID error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String()) + m.EXPECT().ConnectorsConfig(gomock.Any(), connID).Return( + json.RawMessage("{}"), fmt.Errorf("connector configs get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String()) + m.EXPECT().ConnectorsConfig(gomock.Any(), connID).Return( + json.RawMessage("{}"), nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_configs.go b/internal/api/v2/handler_connectors_configs.go new file mode 100644 index 00000000..9ba7c99c --- /dev/null +++ b/internal/api/v2/handler_connectors_configs.go @@ -0,0 +1,29 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/otel" +) + +func connectorsConfigs(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, span := otel.Tracer().Start(r.Context(), "v2_connectorsConfigs") + defer span.End() + + configs := backend.ConnectorsConfigs() + + err := json.NewEncoder(w).Encode(api.BaseResponse[plugins.Configs]{ + Data: &configs, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_connectors_configs_test.go b/internal/api/v2/handler_connectors_configs_test.go new file mode 100644 index 00000000..e26af6f9 --- /dev/null +++ b/internal/api/v2/handler_connectors_configs_test.go @@ -0,0 +1,38 @@ +package v2 + +import ( + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/connectors/plugins" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connectors Configs", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("get connectors configs", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsConfigs(m) + }) + + It("should return data object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().ConnectorsConfigs().Return(plugins.Configs{}) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_install.go b/internal/api/v2/handler_connectors_install.go new file mode 100644 index 00000000..3287a3b3 --- /dev/null +++ b/internal/api/v2/handler_connectors_install.go @@ -0,0 +1,45 @@ +package v2 + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsInstall(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_connectorsInstall") + defer span.End() + + span.SetAttributes(attribute.String("provider", connectorProvider(r))) + provider := strings.ToLower(connectorProvider(r)) + if provider == "" { + otel.RecordError(span, errors.New("provider is required")) + api.BadRequest(w, ErrValidation, errors.New("provider is required")) + return + } + + config, err := io.ReadAll(r.Body) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + span.SetAttributes(attribute.String("config", string(config))) + + connectorID, err := backend.ConnectorsInstall(ctx, provider, config) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Created(w, connectorID.String()) + } +} diff --git a/internal/api/v2/handler_connectors_install_test.go b/internal/api/v2/handler_connectors_install_test.go new file mode 100644 index 00000000..47549462 --- /dev/null +++ b/internal/api/v2/handler_connectors_install_test.go @@ -0,0 +1,56 @@ +package v2 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connector Install", func() { + var ( + handlerFn http.HandlerFunc + conn string + config json.RawMessage + ) + BeforeEach(func() { + conn = "psp" + config = json.RawMessage("{}") + }) + + Context("install connector", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsInstall(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + m.EXPECT().ConnectorsInstall(gomock.Any(), conn, config).Return( + models.ConnectorID{}, + fmt.Errorf("connector install err"), + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connector", conn, &config)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status created on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsInstall(gomock.Any(), conn, config).Return( + models.ConnectorID{}, + nil, + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connector", conn, &config)) + assertExpectedResponse(w.Result(), http.StatusCreated, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_list.go b/internal/api/v2/handler_connectors_list.go new file mode 100644 index 00000000..ec48d139 --- /dev/null +++ b/internal/api/v2/handler_connectors_list.go @@ -0,0 +1,65 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +// NOTE: in order to maintain previous version compatibility, we need to keep the +// same response structure as the previous version of the API +type connectorsListElement struct { + Provider string `json:"provider"` + ConnectorID string `json:"connectorID"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + ScheduledForDeletion bool `json:"scheduledForDeletion"` +} + +func connectorsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_connectorsList") + defer span.End() + + connectors, err := backend.ConnectorsList( + ctx, + storage.NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.ConnectorQuery{}). + // NOTE: previous version of payments did not have pagination, so + // fetch everything and return it all + WithPageSize(1000), + ), + ) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]*connectorsListElement, len(connectors.Data)) + for i := range connectors.Data { + data[i] = &connectorsListElement{ + Provider: connectors.Data[i].Provider, + ConnectorID: connectors.Data[i].ID.String(), + Name: connectors.Data[i].Name, + ScheduledForDeletion: connectors.Data[i].ScheduledForDeletion, + Enabled: true, + } + } + + err = json.NewEncoder(w).Encode( + api.BaseResponse[[]*connectorsListElement]{ + Data: &data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_connectors_list_test.go b/internal/api/v2/handler_connectors_list_test.go new file mode 100644 index 00000000..e7178f3d --- /dev/null +++ b/internal/api/v2/handler_connectors_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connectors List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list connectors", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Connector]{}, fmt.Errorf("connectors list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Connector]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_reset.go b/internal/api/v2/handler_connectors_reset.go new file mode 100644 index 00000000..fcec8062 --- /dev/null +++ b/internal/api/v2/handler_connectors_reset.go @@ -0,0 +1,34 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsReset(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_connectorsReset") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + if err := backend.ConnectorsReset(ctx, connectorID); err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_connectors_reset_test.go b/internal/api/v2/handler_connectors_reset_test.go new file mode 100644 index 00000000..aabf3bd6 --- /dev/null +++ b/internal/api/v2/handler_connectors_reset_test.go @@ -0,0 +1,56 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connectors reset", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("reset connectors", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsReset(m) + }) + + It("should return a bad request error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("connectors reset err") + m.EXPECT().ConnectorsReset(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsReset(gomock.Any(), connID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_uninstall.go b/internal/api/v2/handler_connectors_uninstall.go new file mode 100644 index 00000000..df624233 --- /dev/null +++ b/internal/api/v2/handler_connectors_uninstall.go @@ -0,0 +1,34 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsUninstall(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_connectorsUninstall") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + if err := backend.ConnectorsUninstall(ctx, connectorID); err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_connectors_uninstall_test.go b/internal/api/v2/handler_connectors_uninstall_test.go new file mode 100644 index 00000000..5bc40b9e --- /dev/null +++ b/internal/api/v2/handler_connectors_uninstall_test.go @@ -0,0 +1,56 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connectors uninstall", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("uninstall connectors", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsUninstall(m) + }) + + It("should return a bad request error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("connectors uninstall err") + m.EXPECT().ConnectorsUninstall(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsUninstall(gomock.Any(), connID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_connectors_webhooks.go b/internal/api/v2/handler_connectors_webhooks.go new file mode 100644 index 00000000..85f483d4 --- /dev/null +++ b/internal/api/v2/handler_connectors_webhooks.go @@ -0,0 +1,64 @@ +package v2 + +import ( + "io" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsWebhooks(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_connectorsWebhooks") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil && err != io.EOF { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + headers := r.Header + queryValues := r.URL.Query() + path := r.URL.Path + username, password, ok := r.BasicAuth() + + webhook := models.Webhook{ + ID: uuid.New().String(), + ConnectorID: connectorID, + QueryValues: queryValues, + Headers: headers, + Body: body, + } + + if ok { + webhook.BasicAuth = &models.BasicAuth{ + Username: username, + Password: password, + } + } + + err = backend.ConnectorsHandleWebhooks(ctx, path, webhook) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RawOk(w, nil) + } +} diff --git a/internal/api/v2/handler_connectors_webhooks_test.go b/internal/api/v2/handler_connectors_webhooks_test.go new file mode 100644 index 00000000..27dfe012 --- /dev/null +++ b/internal/api/v2/handler_connectors_webhooks_test.go @@ -0,0 +1,58 @@ +package v2 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Connector Webhooks", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + config json.RawMessage + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + config = json.RawMessage("{}") + }) + + Context("webhooks connector", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsWebhooks(m) + }) + + It("should return a bad request error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + m.EXPECT().ConnectorsHandleWebhooks(gomock.Any(), "/", gomock.Any()).Return(fmt.Errorf("connector webhooks err")) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connectorID", connID.String(), &config)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsHandleWebhooks(gomock.Any(), "/", gomock.Any()).Return(nil) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connectorID", connID.String(), &config)) + assertExpectedResponse(w.Result(), http.StatusOK, "") + }) + }) +}) diff --git a/internal/api/v2/handler_payments_create.go b/internal/api/v2/handler_payments_create.go new file mode 100644 index 00000000..e7a33b68 --- /dev/null +++ b/internal/api/v2/handler_payments_create.go @@ -0,0 +1,250 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type CreatePaymentRequest struct { + Reference string `json:"reference"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + Type string `json:"type"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Scheme string `json:"scheme"` + Status string `json:"status"` + SourceAccountID *string `json:"sourceAccountID"` + DestinationAccountID *string `json:"destinationAccountID"` + Metadata map[string]string `json:"metadata"` +} + +func (r *CreatePaymentRequest) validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.ConnectorID == "" { + return errors.New("connectorID is required") + } + + if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { + return errors.New("createdAt is empty or in the future") + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Type == "" { + return errors.New("type is required") + } + + if _, err := models.PaymentTypeFromString(r.Type); err != nil { + return fmt.Errorf("invalid type: %w", err) + } + + if r.Scheme == "" { + return errors.New("scheme is required") + } + + if _, err := models.PaymentSchemeFromString(r.Scheme); err != nil { + return fmt.Errorf("invalid scheme: %w", err) + } + + if r.Asset == "" { + return errors.New("asset is required") + } + + if r.Status == "" { + return errors.New("status is required") + } + + if _, err := models.PaymentStatusFromString(r.Status); err != nil { + return fmt.Errorf("invalid status: %w", err) + } + + if r.SourceAccountID != nil { + _, err := models.AccountIDFromString(*r.SourceAccountID) + if err != nil { + return fmt.Errorf("invalid sourceAccountID: %w", err) + } + } + + if r.DestinationAccountID != nil { + _, err := models.AccountIDFromString(*r.DestinationAccountID) + if err != nil { + return fmt.Errorf("invalid destinationAccountID: %w", err) + } + } + + return nil +} + +func paymentsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_paymentsCreate") + defer span.End() + + var req CreatePaymentRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromPaymentCreateRequest(span, req) + + if err := req.validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectorID := models.MustConnectorIDFromString(req.ConnectorID) + paymentType := models.MustPaymentTypeFromString(req.Type) + status := models.MustPaymentStatusFromString(req.Status) + raw, err := json.Marshal(req) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + pid := models.PaymentID{ + PaymentReference: models.PaymentReference{ + Reference: req.Reference, + Type: paymentType, + }, + ConnectorID: connectorID, + } + + payment := models.Payment{ + ID: pid, + ConnectorID: connectorID, + Reference: req.Reference, + CreatedAt: req.CreatedAt.UTC(), + Type: paymentType, + InitialAmount: req.Amount, + Amount: req.Amount, + Asset: req.Asset, + Scheme: models.MustPaymentSchemeFromString(req.Scheme), + SourceAccountID: func() *models.AccountID { + if req.SourceAccountID == nil { + return nil + } + return pointer.For(models.MustAccountIDFromString(*req.SourceAccountID)) + }(), + DestinationAccountID: func() *models.AccountID { + if req.DestinationAccountID == nil { + return nil + } + return pointer.For(models.MustAccountIDFromString(*req.DestinationAccountID)) + }(), + Metadata: req.Metadata, + } + + // Create adjustments from main payments to keep the compatibility with the old API + payment.Adjustments = []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pid, + Reference: req.Reference, + CreatedAt: req.CreatedAt, + Status: status, + }, + PaymentID: pid, + Reference: req.Reference, + CreatedAt: req.CreatedAt, + Status: status, + Amount: req.Amount, + Asset: &req.Asset, + Metadata: req.Metadata, + Raw: raw, + }, + } + + err = backend.PaymentsCreate(ctx, payment) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + // Compatibility with old API + data := PaymentResponse{ + ID: payment.ID.String(), + Reference: payment.Reference, + Type: payment.Type.String(), + Provider: payment.ConnectorID.Provider, + ConnectorID: payment.ConnectorID.String(), + Status: payment.Status.String(), + Amount: payment.Amount, + InitialAmount: payment.InitialAmount, + Scheme: payment.Scheme.String(), + Asset: payment.Asset, + CreatedAt: payment.CreatedAt, + Metadata: payment.Metadata, + } + + if payment.SourceAccountID != nil { + data.SourceAccountID = payment.SourceAccountID.String() + } + + if payment.DestinationAccountID != nil { + data.DestinationAccountID = payment.DestinationAccountID.String() + } + + data.Adjustments = make([]paymentAdjustment, len(payment.Adjustments)) + for i := range payment.Adjustments { + data.Adjustments[i] = paymentAdjustment{ + Reference: payment.Adjustments[i].ID.Reference, + CreatedAt: payment.Adjustments[i].CreatedAt, + Status: payment.Adjustments[i].Status.String(), + Amount: payment.Adjustments[i].Amount, + Raw: payment.Adjustments[i].Raw, + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[PaymentResponse]{ + Data: &data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func populateSpanFromPaymentCreateRequest(span trace.Span, req CreatePaymentRequest) { + span.SetAttributes(attribute.String("reference", req.Reference)) + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + span.SetAttributes(attribute.String("createdAt", req.CreatedAt.String())) + span.SetAttributes(attribute.String("type", req.Type)) + span.SetAttributes(attribute.String("amount", req.Amount.String())) + span.SetAttributes(attribute.String("asset", req.Asset)) + span.SetAttributes(attribute.String("scheme", req.Scheme)) + span.SetAttributes(attribute.String("status", req.Status)) + if req.SourceAccountID != nil { + span.SetAttributes(attribute.String("sourceAccountID", *req.SourceAccountID)) + } + if req.DestinationAccountID != nil { + span.SetAttributes(attribute.String("destinationAccountID", *req.DestinationAccountID)) + } + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v2/handler_payments_create_test.go b/internal/api/v2/handler_payments_create_test.go new file mode 100644 index 00000000..03a73966 --- /dev/null +++ b/internal/api/v2/handler_payments_create_test.go @@ -0,0 +1,95 @@ +package v2 + +import ( + "errors" + "math/big" + "net/http" + "net/http/httptest" + "time" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payments Create", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("create payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + cpr CreatePaymentRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(cpr CreatePaymentRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", CreatePaymentRequest{}), + Entry("connectorID missing", CreatePaymentRequest{Reference: "ref"}), + Entry("createdAt missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id"}), + Entry("amount missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now()}), + Entry("payment type missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467)}), + Entry("payment type invalid", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "invalid"}), + Entry("scheme missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT"}), + Entry("scheme invalid", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT", Scheme: "invalid"}), + Entry("asset missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT", Scheme: "CARD_VISA"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment create err") + m.EXPECT().PaymentsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + cpr = CreatePaymentRequest{ + Reference: "reference-err", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + Amount: big.NewInt(3500), + Asset: "JPY", + Status: models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT.String(), + Type: models.PAYMENT_TYPE_PAYIN.String(), + Scheme: models.PAYMENT_SCHEME_CARD_AMEX.String(), + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status OK on success", func(ctx SpecContext) { + m.EXPECT().PaymentsCreate(gomock.Any(), gomock.Any()).Return(nil) + cpr = CreatePaymentRequest{ + Reference: "reference-ok", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + Amount: big.NewInt(3500), + Asset: "JPY", + Status: models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT.String(), + Type: models.PAYMENT_TYPE_PAYIN.String(), + Scheme: models.PAYMENT_SCHEME_CARD_AMEX.String(), + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_payments_get.go b/internal/api/v2/handler_payments_get.go new file mode 100644 index 00000000..6f78e8e4 --- /dev/null +++ b/internal/api/v2/handler_payments_get.go @@ -0,0 +1,77 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_paymentsGet") + defer span.End() + + span.SetAttributes(attribute.String("paymentID", paymentID(r))) + id, err := models.PaymentIDFromString(paymentID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + payment, err := backend.PaymentsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := PaymentResponse{ + ID: payment.ID.String(), + Reference: payment.Reference, + Type: payment.Type.String(), + Provider: payment.ConnectorID.Provider, + ConnectorID: payment.ConnectorID.String(), + Status: payment.Status.String(), + Amount: payment.Amount, + InitialAmount: payment.InitialAmount, + Scheme: payment.Scheme.String(), + Asset: payment.Asset, + CreatedAt: payment.CreatedAt, + Metadata: payment.Metadata, + } + + if payment.SourceAccountID != nil { + data.SourceAccountID = payment.SourceAccountID.String() + } + + if payment.DestinationAccountID != nil { + data.DestinationAccountID = payment.DestinationAccountID.String() + } + + data.Adjustments = make([]paymentAdjustment, len(payment.Adjustments)) + for i := range payment.Adjustments { + data.Adjustments[i] = paymentAdjustment{ + Reference: payment.Adjustments[i].ID.Reference, + CreatedAt: payment.Adjustments[i].CreatedAt, + Status: payment.Adjustments[i].Status.String(), + Amount: payment.Adjustments[i].Amount, + Raw: payment.Adjustments[i].Raw, + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[PaymentResponse]{ + Data: &data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_payments_get_test.go b/internal/api/v2/handler_payments_get_test.go new file mode 100644 index 00000000..2e0e952e --- /dev/null +++ b/internal/api/v2/handler_payments_get_test.go @@ -0,0 +1,65 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payments", func() { + var ( + handlerFn http.HandlerFunc + payID models.PaymentID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + payRef := models.PaymentReference{Reference: "ref", Type: models.PAYMENT_TYPE_TRANSFER} + payID = models.PaymentID{PaymentReference: payRef, ConnectorID: connID} + }) + + Context("get payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsGet(m) + }) + + It("should return an invalid ID error when payment ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentID", payID.String()) + m.EXPECT().PaymentsGet(gomock.Any(), payID).Return( + &models.Payment{}, fmt.Errorf("payments get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentID", payID.String()) + m.EXPECT().PaymentsGet(gomock.Any(), payID).Return( + &models.Payment{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_payments_list.go b/internal/api/v2/handler_payments_list.go new file mode 100644 index 00000000..17242bc5 --- /dev/null +++ b/internal/api/v2/handler_payments_list.go @@ -0,0 +1,123 @@ +package v2 + +import ( + "encoding/json" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +type PaymentResponse struct { + ID string `json:"id"` + Reference string `json:"reference"` + SourceAccountID string `json:"sourceAccountID"` + DestinationAccountID string `json:"destinationAccountID"` + Type string `json:"type"` + Provider string `json:"provider"` + ConnectorID string `json:"connectorID"` + Status string `json:"status"` + Amount *big.Int `json:"amount"` + InitialAmount *big.Int `json:"initialAmount"` + Scheme string `json:"scheme"` + Asset string `json:"asset"` + CreatedAt time.Time `json:"createdAt"` + Raw interface{} `json:"raw"` + Adjustments []paymentAdjustment `json:"adjustments"` + Metadata map[string]string `json:"metadata"` +} + +type paymentAdjustment struct { + Reference string `json:"reference" bson:"reference"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + Status string `json:"status" bson:"status"` + Amount *big.Int `json:"amount" bson:"amount"` + Raw interface{} `json:"raw" bson:"raw"` +} + +func paymentsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_paymentsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPaymentsQuery](r, func() (*storage.ListPaymentsQuery, error) { + options, err := getPagination(span, r, storage.PaymentQuery{}) + if err != nil { + otel.RecordError(span, err) + return nil, err + } + return pointer.For(storage.NewListPaymentsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.PaymentsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]*PaymentResponse, len(cursor.Data)) + for i := range cursor.Data { + data[i] = &PaymentResponse{ + ID: cursor.Data[i].ID.String(), + Reference: cursor.Data[i].Reference, + Type: cursor.Data[i].Type.String(), + Provider: cursor.Data[i].ConnectorID.Provider, + ConnectorID: cursor.Data[i].ConnectorID.String(), + Status: cursor.Data[i].Status.String(), + Amount: cursor.Data[i].Amount, + InitialAmount: cursor.Data[i].InitialAmount, + Scheme: cursor.Data[i].Scheme.String(), + Asset: cursor.Data[i].Asset, + CreatedAt: cursor.Data[i].CreatedAt, + Adjustments: []paymentAdjustment{}, + Metadata: cursor.Data[i].Metadata, + } + + if cursor.Data[i].SourceAccountID != nil { + data[i].SourceAccountID = cursor.Data[i].SourceAccountID.String() + } + + if cursor.Data[i].DestinationAccountID != nil { + data[i].DestinationAccountID = cursor.Data[i].DestinationAccountID.String() + } + + data[i].Adjustments = make([]paymentAdjustment, len(cursor.Data[i].Adjustments)) + for j := range cursor.Data[i].Adjustments { + data[i].Adjustments[j] = paymentAdjustment{ + Reference: cursor.Data[i].Adjustments[j].ID.Reference, + CreatedAt: cursor.Data[i].Adjustments[j].CreatedAt, + Status: cursor.Data[i].Adjustments[j].Status.String(), + Amount: cursor.Data[i].Adjustments[j].Amount, + Raw: cursor.Data[i].Adjustments[j].Raw, + } + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[*PaymentResponse]{ + Cursor: &bunpaginate.Cursor[*PaymentResponse]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_payments_list_test.go b/internal/api/v2/handler_payments_list_test.go new file mode 100644 index 00000000..2060a162 --- /dev/null +++ b/internal/api/v2/handler_payments_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payments List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Payment]{}, fmt.Errorf("payments list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Payment]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_payments_update_metadata.go b/internal/api/v2/handler_payments_update_metadata.go new file mode 100644 index 00000000..7abb6a5a --- /dev/null +++ b/internal/api/v2/handler_payments_update_metadata.go @@ -0,0 +1,60 @@ +package v2 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func validatePaymentsMetadata(metadata map[string]string) error { + if len(metadata) == 0 { + return errors.New("metadata must be provided") + } + return nil +} + +func paymentsUpdateMetadata(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_paymentsUpdateMetadata") + defer span.End() + + span.SetAttributes(attribute.String("paymentID", paymentID(r))) + id, err := models.PaymentIDFromString(paymentID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var metadata map[string]string + err = json.NewDecoder(r.Body).Decode(&metadata) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + populateSpanFromUpdateMetadataRequest(span, metadata) + + if err := validatePaymentsMetadata(metadata); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + err = backend.PaymentsUpdateMetadata(ctx, id, metadata) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_payments_update_metadata_test.go b/internal/api/v2/handler_payments_update_metadata_test.go new file mode 100644 index 00000000..25d3aaa1 --- /dev/null +++ b/internal/api/v2/handler_payments_update_metadata_test.go @@ -0,0 +1,68 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payments Update Metadata", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + payRef := models.PaymentReference{Reference: "ref", Type: models.PAYMENT_TYPE_TRANSFER} + paymentID = models.PaymentID{PaymentReference: payRef, ConnectorID: connID} + }) + + Context("update payment metadata", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsUpdateMetadata(m) + }) + + It("should return a bad request error when paymentID is invalid", func(ctx SpecContext) { + payload := map[string]string{} + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", "invalid", &payload)) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + DescribeTable("validation errors", + func(payload map[string]string) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", paymentID.String(), &payload)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("metadata missing", nil), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment update metadata err") + m.EXPECT().PaymentsUpdateMetadata(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + metadata := map[string]string{"meta": "data"} + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", paymentID.String(), &metadata)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + metadata := map[string]string{"meta": "data"} + m.EXPECT().PaymentsUpdateMetadata(gomock.Any(), gomock.Any(), metadata).Return(nil) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", paymentID.String(), &metadata)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_add_account.go b/internal/api/v2/handler_pools_add_account.go new file mode 100644 index 00000000..6d095694 --- /dev/null +++ b/internal/api/v2/handler_pools_add_account.go @@ -0,0 +1,73 @@ +package v2 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +type poolsAddAccountRequest struct { + AccountID string `json:"accountID"` +} + +func (c *poolsAddAccountRequest) Validate() error { + if c.AccountID == "" { + return errors.New("accountID is required") + } + + return nil +} + +func poolsAddAccount(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolsAddAccount") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var poolsAddAccountRequest poolsAddAccountRequest + err = json.NewDecoder(r.Body).Decode(&poolsAddAccountRequest) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + span.SetAttributes(attribute.String("accountID", poolsAddAccountRequest.AccountID)) + + if err := poolsAddAccountRequest.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + accountID, err := models.AccountIDFromString(poolsAddAccountRequest.AccountID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PoolsAddAccount(ctx, id, accountID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_pools_add_account_test.go b/internal/api/v2/handler_pools_add_account_test.go new file mode 100644 index 00000000..f62e0e41 --- /dev/null +++ b/internal/api/v2/handler_pools_add_account_test.go @@ -0,0 +1,72 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 pools add account", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + poolID uuid.UUID + paar poolsAddAccountRequest + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + poolID = uuid.New() + }) + + Context("pool add account", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsAddAccount(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "poolID", poolID.String(), nil)) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }) + + It("should return a bad request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("pool add account err") + m.EXPECT().PoolsAddAccount(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + paar = poolsAddAccountRequest{ + AccountID: accID.String(), + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "poolID", poolID.String(), &paar)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PoolsAddAccount(gomock.Any(), poolID, accID).Return(nil) + paar = poolsAddAccountRequest{ + AccountID: accID.String(), + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "poolID", poolID.String(), &paar)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_balances_at.go b/internal/api/v2/handler_pools_balances_at.go new file mode 100644 index 00000000..19daa10b --- /dev/null +++ b/internal/api/v2/handler_pools_balances_at.go @@ -0,0 +1,83 @@ +package v2 + +import ( + "encoding/json" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "github.com/pkg/errors" + "go.opentelemetry.io/otel/attribute" +) + +// NOTE: in order to maintain previous version compatibility, we need to keep the +// same response structure as the previous version of the API +type poolBalancesResponse struct { + Balances []*poolBalanceResponse `json:"balances"` +} + +type poolBalanceResponse struct { + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` +} + +func poolsBalancesAt(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolsBalancesAt") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + span.SetAttributes(attribute.String("at", r.URL.Query().Get("at"))) + atTime := r.URL.Query().Get("at") + if atTime == "" { + otel.RecordError(span, errors.New("missing atTime")) + api.BadRequest(w, ErrValidation, errors.New("missing atTime")) + return + } + + at, err := time.Parse(time.RFC3339, atTime) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, errors.Wrap(err, "invalid atTime")) + return + } + + balances, err := backend.PoolsBalancesAt(ctx, id, at) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := &poolBalancesResponse{ + Balances: make([]*poolBalanceResponse, len(balances)), + } + + for i := range balances { + data.Balances[i] = &poolBalanceResponse{ + Amount: balances[i].Amount, + Asset: balances[i].Asset, + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[poolBalancesResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_pools_balances_at_test.go b/internal/api/v2/handler_pools_balances_at_test.go new file mode 100644 index 00000000..589a11c1 --- /dev/null +++ b/internal/api/v2/handler_pools_balances_at_test.go @@ -0,0 +1,77 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Pools Balances At", func() { + var ( + handlerFn http.HandlerFunc + poolID uuid.UUID + now time.Time + ) + BeforeEach(func() { + poolID = uuid.New() + now = time.Now().UTC().Truncate(time.Second) + }) + + Context("pools balances at", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsBalancesAt(m) + }) + + It("should return a validation request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequestWithPath("/", "poolID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a validation request error when at param is missing", func(ctx SpecContext) { + req := prepareQueryRequestWithPath("/", "poolID", poolID.String()) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + path := fmt.Sprintf("/?at=%s", now.Format(time.RFC3339)) + req := prepareQueryRequestWithPath(path, "poolID", poolID.String()) + m.EXPECT().PoolsBalancesAt(gomock.Any(), gomock.Any(), gomock.Any()).Return( + []models.AggregatedBalance{}, + fmt.Errorf("balances list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a data object", func(ctx SpecContext) { + path := fmt.Sprintf("/?at=%s", now.Format(time.RFC3339)) + req := prepareQueryRequestWithPath(path, "poolID", poolID.String()) + m.EXPECT().PoolsBalancesAt(gomock.Any(), poolID, now).Return( + []models.AggregatedBalance{}, + nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_create.go b/internal/api/v2/handler_pools_create.go new file mode 100644 index 00000000..6dbb922a --- /dev/null +++ b/internal/api/v2/handler_pools_create.go @@ -0,0 +1,109 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type createPoolRequest struct { + Name string `json:"name"` + AccountIDs []string `json:"accountIDs"` +} + +func (r *createPoolRequest) Validate() error { + if len(r.AccountIDs) == 0 { + return errors.New("one or more account id required") + } + return nil +} + +type poolResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Accounts []string `json:"accounts"` +} + +func poolsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolsBalancesAt") + defer span.End() + + var createPoolRequest createPoolRequest + err := json.NewDecoder(r.Body).Decode(&createPoolRequest) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromCreatePoolRequest(span, createPoolRequest) + + if err := createPoolRequest.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + pool := models.Pool{ + ID: uuid.New(), + Name: createPoolRequest.Name, + CreatedAt: time.Now().UTC(), + } + + accounts := make([]models.PoolAccounts, len(createPoolRequest.AccountIDs)) + for i, accountID := range createPoolRequest.AccountIDs { + aID, err := models.AccountIDFromString(accountID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + accounts[i] = models.PoolAccounts{ + PoolID: pool.ID, + AccountID: aID, + } + } + pool.PoolAccounts = accounts + + err = backend.PoolsCreate(ctx, pool) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := &poolResponse{ + ID: pool.ID.String(), + Name: pool.Name, + Accounts: createPoolRequest.AccountIDs, + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[poolResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func populateSpanFromCreatePoolRequest(span trace.Span, req createPoolRequest) { + span.SetAttributes(attribute.String("name", req.Name)) + for i, acc := range req.AccountIDs { + span.SetAttributes(attribute.String(fmt.Sprintf("accountIDs[%d]", i), acc)) + } +} diff --git a/internal/api/v2/handler_pools_create_test.go b/internal/api/v2/handler_pools_create_test.go new file mode 100644 index 00000000..98c1ce52 --- /dev/null +++ b/internal/api/v2/handler_pools_create_test.go @@ -0,0 +1,77 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Pools Create", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + accID2 models.AccountID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + accID2 = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + }) + + Context("create pools", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + cpr createPoolRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(cpr createPoolRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("accountIDs missing", createPoolRequest{}), + Entry("accountIDs invalid", createPoolRequest{AccountIDs: []string{"invalid"}}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment create err") + m.EXPECT().PoolsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + cpr = createPoolRequest{ + Name: "name", + AccountIDs: []string{accID.String()}, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().PoolsCreate(gomock.Any(), gomock.Any()).Return(nil) + cpr = createPoolRequest{ + Name: "name", + AccountIDs: []string{accID.String(), accID2.String()}, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_delete.go b/internal/api/v2/handler_pools_delete.go new file mode 100644 index 00000000..2cec54e1 --- /dev/null +++ b/internal/api/v2/handler_pools_delete.go @@ -0,0 +1,35 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsDelete(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolsDelete") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PoolsDelete(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_pools_delete_test.go b/internal/api/v2/handler_pools_delete_test.go new file mode 100644 index 00000000..88c4a7c5 --- /dev/null +++ b/internal/api/v2/handler_pools_delete_test.go @@ -0,0 +1,55 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Pool Deletion", func() { + var ( + handlerFn http.HandlerFunc + poolID uuid.UUID + ) + BeforeEach(func() { + poolID = uuid.New() + }) + + Context("delete pool", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsDelete(m) + }) + + It("should return a bad request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation delete err") + m.EXPECT().PoolsDelete(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PoolsDelete(gomock.Any(), poolID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_get.go b/internal/api/v2/handler_pools_get.go new file mode 100644 index 00000000..f87e9bcc --- /dev/null +++ b/internal/api/v2/handler_pools_get.go @@ -0,0 +1,54 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolsGet") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + pool, err := backend.PoolsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := &poolResponse{ + ID: pool.ID.String(), + Name: pool.Name, + } + + accounts := make([]string, len(pool.PoolAccounts)) + for i := range pool.PoolAccounts { + accounts[i] = pool.PoolAccounts[i].AccountID.String() + } + data.Accounts = accounts + + err = json.NewEncoder(w).Encode(api.BaseResponse[poolResponse]{ + Data: data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_pools_get_test.go b/internal/api/v2/handler_pools_get_test.go new file mode 100644 index 00000000..ca062f33 --- /dev/null +++ b/internal/api/v2/handler_pools_get_test.go @@ -0,0 +1,63 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Get Pool", func() { + var ( + handlerFn http.HandlerFunc + poolID uuid.UUID + ) + BeforeEach(func() { + poolID = uuid.New() + }) + + Context("get pools", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsGet(m) + }) + + It("should return an invalid ID error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String()) + m.EXPECT().PoolsGet(gomock.Any(), poolID).Return( + &models.Pool{}, fmt.Errorf("pool get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String()) + m.EXPECT().PoolsGet(gomock.Any(), poolID).Return( + &models.Pool{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_list.go b/internal/api/v2/handler_pools_list.go new file mode 100644 index 00000000..f08e1264 --- /dev/null +++ b/internal/api/v2/handler_pools_list.go @@ -0,0 +1,70 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func poolsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPoolsQuery](r, func() (*storage.ListPoolsQuery, error) { + options, err := getPagination(span, r, storage.PoolQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPoolsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.PoolsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]*poolResponse, len(cursor.Data)) + for i := range cursor.Data { + data[i] = &poolResponse{ + ID: cursor.Data[i].ID.String(), + Name: cursor.Data[i].Name, + } + + accounts := make([]string, len(cursor.Data[i].PoolAccounts)) + for j := range cursor.Data[i].PoolAccounts { + accounts[j] = cursor.Data[i].PoolAccounts[j].AccountID.String() + } + + data[i].Accounts = accounts + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[*poolResponse]{ + Cursor: &bunpaginate.Cursor[*poolResponse]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_pools_list_test.go b/internal/api/v2/handler_pools_list_test.go new file mode 100644 index 00000000..d7fff366 --- /dev/null +++ b/internal/api/v2/handler_pools_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Pools List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list pools", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PoolsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Pool]{}, fmt.Errorf("pools list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PoolsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Pool]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_pools_remove_account.go b/internal/api/v2/handler_pools_remove_account.go new file mode 100644 index 00000000..647c1d53 --- /dev/null +++ b/internal/api/v2/handler_pools_remove_account.go @@ -0,0 +1,44 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsRemoveAccount(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_poolRemoveAccount") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + span.SetAttributes(attribute.String("accountID", accountID(r))) + accountID, err := models.AccountIDFromString(accountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PoolsRemoveAccount(ctx, id, accountID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_pools_remove_account_test.go b/internal/api/v2/handler_pools_remove_account_test.go new file mode 100644 index 00000000..48c4d8e7 --- /dev/null +++ b/internal/api/v2/handler_pools_remove_account_test.go @@ -0,0 +1,66 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 pools remove account", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + poolID uuid.UUID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + poolID = uuid.New() + }) + + Context("pool remove account", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsRemoveAccount(m) + }) + + It("should return a bad request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a bad request error when accountID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("pool remove account err") + m.EXPECT().PoolsRemoveAccount(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", accID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PoolsRemoveAccount(gomock.Any(), poolID, accID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", accID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_tasks_get.go b/internal/api/v2/handler_tasks_get.go new file mode 100644 index 00000000..ee80a85b --- /dev/null +++ b/internal/api/v2/handler_tasks_get.go @@ -0,0 +1,63 @@ +package v2 + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func tasksGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_tasksGet") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + span.SetAttributes(attribute.String("taskID", taskID(r))) + taskID := taskID(r) + + schedule, err := backend.SchedulesGet(ctx, taskID, connectorID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + raw, err := json.Marshal(schedule) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + + data := listTasksResponseElement{ + ID: schedule.ID, + ConnectorID: schedule.ConnectorID.String(), + CreatedAt: schedule.CreatedAt.Format(time.RFC3339), + UpdatedAt: schedule.CreatedAt.Format(time.RFC3339), + Descriptor: raw, + Status: "ACTIVE", + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[listTasksResponseElement]{ + Data: &data, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_tasks_get_test.go b/internal/api/v2/handler_tasks_get_test.go new file mode 100644 index 00000000..87ef7847 --- /dev/null +++ b/internal/api/v2/handler_tasks_get_test.go @@ -0,0 +1,65 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Get Task", func() { + var ( + handlerFn http.HandlerFunc + taskID models.TaskID + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + taskID = models.TaskID{Reference: "ref", ConnectorID: connID} + }) + + Context("get tasks", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = tasksGet(m) + }) + + It("should return an invalid ID error when connectorID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String(), "taskID", taskID.String()) + m.EXPECT().SchedulesGet(gomock.Any(), taskID.String(), connID).Return( + &models.Schedule{}, fmt.Errorf("task get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String(), "taskID", taskID.String()) + m.EXPECT().SchedulesGet(gomock.Any(), taskID.String(), connID).Return( + &models.Schedule{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_tasks_list.go b/internal/api/v2/handler_tasks_list.go new file mode 100644 index 00000000..08df9280 --- /dev/null +++ b/internal/api/v2/handler_tasks_list.go @@ -0,0 +1,89 @@ +package v2 + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "go.opentelemetry.io/otel/attribute" +) + +type listTasksResponseElement struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Descriptor json.RawMessage `json:"descriptor"` + Status string `json:"status"` + State json.RawMessage `json:"state"` + Error string `json:"error"` +} + +func tasksList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_tasksList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListSchedulesQuery](r, func() (*storage.ListSchedulesQuery, error) { + pageSize, err := bunpaginate.GetPageSize(r) + if err != nil { + return nil, err + } + span.SetAttributes(attribute.Int64("pageSize", int64(pageSize))) + + return pointer.For(storage.NewListSchedulesQuery(bunpaginate.NewPaginatedQueryOptions(storage.ScheduleQuery{}).WithPageSize(pageSize))), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.SchedulesList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]listTasksResponseElement, len(cursor.Data)) + for i := range cursor.Data { + raw, err := json.Marshal(&cursor.Data[i]) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + + data[i] = listTasksResponseElement{ + ID: cursor.Data[i].ID, + ConnectorID: cursor.Data[i].ConnectorID.String(), + CreatedAt: cursor.Data[i].CreatedAt.Format(time.RFC3339), + UpdatedAt: cursor.Data[i].CreatedAt.Format(time.RFC3339), + Descriptor: raw, + Status: "ACTIVE", + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[listTasksResponseElement]{ + Cursor: &bunpaginate.Cursor[listTasksResponseElement]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_tasks_list_test.go b/internal/api/v2/handler_tasks_list_test.go new file mode 100644 index 00000000..422d1e0b --- /dev/null +++ b/internal/api/v2/handler_tasks_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 tasks List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list tasks", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = tasksList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().SchedulesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Schedule]{}, fmt.Errorf("tasks list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().SchedulesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Schedule]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_test.go b/internal/api/v2/handler_test.go new file mode 100644 index 00000000..3fbca780 --- /dev/null +++ b/internal/api/v2/handler_test.go @@ -0,0 +1,73 @@ +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestV2Handlers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "API v2 Suite") +} + +func assertExpectedResponse(res *http.Response, expectedStatusCode int, expectedBodyString string) { + defer res.Body.Close() + Expect(res.StatusCode).To(Equal(expectedStatusCode)) + + data, err := ioutil.ReadAll(res.Body) + Expect(err).To(BeNil()) + Expect(data).To(ContainSubstring(expectedBodyString)) +} + +func prepareJSONRequest(method string, a any) *http.Request { + b, err := json.Marshal(a) + Expect(err).To(BeNil()) + body := bytes.NewReader(b) + return httptest.NewRequest(method, "/", body) +} + +func prepareJSONRequestWithQuery(method string, key string, val string, a any) *http.Request { + b, err := json.Marshal(a) + Expect(err).To(BeNil()) + body := bytes.NewReader(b) + return prepareQueryRequestWithBody(method, body, key, val) +} + +func prepareQueryRequest(method string, args ...string) *http.Request { + return prepareQueryRequestWithBody(method, nil, args...) +} + +func prepareQueryRequestWithBody(method string, body io.Reader, args ...string) *http.Request { + req := httptest.NewRequest(method, "/", body) + rctx := chi.NewRouteContext() + appendToRouteContext(rctx, args...) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func prepareQueryRequestWithPath(path string, args ...string) *http.Request { + req := httptest.NewRequest(http.MethodGet, path, nil) + rctx := chi.NewRouteContext() + appendToRouteContext(rctx, args...) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func appendToRouteContext(rctx *chi.Context, args ...string) { + if len(args)%2 != 0 { + log.Fatalf("arguments must be provided in key value pairs: %s", args) + } + for i := 0; i < len(args); i += 2 { + val := args[i+1] + rctx.URLParams.Add(args[i], val) + } +} diff --git a/internal/api/v2/handler_transfer_initiations_create.go b/internal/api/v2/handler_transfer_initiations_create.go new file mode 100644 index 00000000..646da1c8 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_create.go @@ -0,0 +1,170 @@ +package v2 + +import ( + "encoding/json" + "errors" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type CreateTransferInitiationRequest struct { + Reference string `json:"reference"` + ScheduledAt time.Time `json:"scheduledAt"` + Description string `json:"description"` + SourceAccountID string `json:"sourceAccountID"` + DestinationAccountID string `json:"destinationAccountID"` + ConnectorID string `json:"connectorID"` + Provider string `json:"provider"` + Type string `json:"type"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Validated bool `json:"validated"` + Metadata map[string]string `json:"metadata"` +} + +func (r *CreateTransferInitiationRequest) Validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.SourceAccountID != "" { + _, err := models.AccountIDFromString(r.SourceAccountID) + if err != nil { + return err + } + } + + if r.DestinationAccountID != "" { + _, err := models.AccountIDFromString(r.DestinationAccountID) + if err != nil { + return err + } + } + + _, err := models.PaymentInitiationTypeFromString(r.Type) + if err != nil { + return err + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Asset == "" { + return errors.New("asset is required") + } + + return nil +} + +func transferInitiationsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsCreate") + defer span.End() + + payload := CreateTransferInitiationRequest{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + setSpanAttributesFromRequest(span, payload) + + if err := payload.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectorID, err := models.ConnectorIDFromString(payload.ConnectorID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + pi := models.PaymentInitiation{ + ID: models.PaymentInitiationID{ + Reference: payload.Reference, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Reference: payload.Reference, + CreatedAt: time.Now(), + ScheduledAt: payload.ScheduledAt, + Description: payload.Description, + Type: models.MustPaymentInitiationTypeFromString(payload.Type), + Amount: payload.Amount, + Asset: payload.Asset, + Metadata: payload.Metadata, + } + + if payload.SourceAccountID != "" { + pi.SourceAccountID = pointer.For(models.MustAccountIDFromString(payload.SourceAccountID)) + } + + if payload.DestinationAccountID != "" { + pi.DestinationAccountID = pointer.For(models.MustAccountIDFromString(payload.DestinationAccountID)) + } + + _, err = backend.PaymentInitiationsCreate(ctx, pi, payload.Validated, true) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + resp := translatePaymentInitiationToResponse(&pi) + lastAdjustment, err := backend.PaymentInitiationAdjustmentsGetLast(ctx, pi.ID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + if lastAdjustment != nil { + resp.Status = lastAdjustment.Status.String() + resp.Error = func() string { + if lastAdjustment.Error == nil { + return "" + } + return lastAdjustment.Error.Error() + }() + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[transferInitiationResponse]{ + Data: &resp, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func setSpanAttributesFromRequest(span trace.Span, transfer CreateTransferInitiationRequest) { + span.SetAttributes( + attribute.String("reference", transfer.Reference), + attribute.String("scheduledAt", transfer.ScheduledAt.String()), + attribute.String("description", transfer.Description), + attribute.String("sourceAccountID", transfer.SourceAccountID), + attribute.String("destinationAccountID", transfer.DestinationAccountID), + attribute.String("connectorID", transfer.ConnectorID), + attribute.String("provider", transfer.Provider), + attribute.String("type", transfer.Type), + attribute.String("amount", transfer.Amount.String()), + attribute.String("asset", transfer.Asset), + attribute.String("validated", transfer.Asset), + ) +} diff --git a/internal/api/v2/handler_transfer_initiations_create_test.go b/internal/api/v2/handler_transfer_initiations_create_test.go new file mode 100644 index 00000000..92467e34 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_create_test.go @@ -0,0 +1,106 @@ +package v2 + +import ( + "errors" + "math/big" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payment Initiation Creation", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + source models.AccountID + dest models.AccountID + sourceID string + destID string + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + source = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + dest = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + sourceID = source.String() + destID = dest.String() + }) + + Context("create payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + picr CreateTransferInitiationRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(r CreateTransferInitiationRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &r)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", CreateTransferInitiationRequest{}), + Entry("type missing", CreateTransferInitiationRequest{Reference: "type", SourceAccountID: sourceID, DestinationAccountID: destID}), + Entry("amount missing", CreateTransferInitiationRequest{Reference: "amount", SourceAccountID: sourceID, DestinationAccountID: destID, Type: "TRANSFER"}), + Entry("asset missing", CreateTransferInitiationRequest{Reference: "asset", SourceAccountID: sourceID, DestinationAccountID: destID, Type: "TRANSFER", Amount: big.NewInt(1313)}), + Entry("connectorID missing", CreateTransferInitiationRequest{Reference: "connector", SourceAccountID: sourceID, DestinationAccountID: destID, Type: "TRANSFER", Amount: big.NewInt(1717), Asset: "USD"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation create err") + m.EXPECT().PaymentInitiationsCreate(gomock.Any(), gomock.Any(), false, true).Return( + models.Task{}, + expectedErr, + ) + picr = CreateTransferInitiationRequest{ + Reference: "ref-err", + ConnectorID: connID.String(), + SourceAccountID: sourceID, + DestinationAccountID: destID, + Type: "TRANSFER", + Amount: big.NewInt(144), + Asset: "EUR", + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &picr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsCreate(gomock.Any(), gomock.Any(), false, true).Return( + models.Task{}, + nil, + ) + m.EXPECT().PaymentInitiationAdjustmentsGetLast(gomock.Any(), gomock.Any()).Return( + &models.PaymentInitiationAdjustment{}, + nil, + ) + picr = CreateTransferInitiationRequest{ + Reference: "ref-ok", + ConnectorID: connID.String(), + SourceAccountID: sourceID, + DestinationAccountID: destID, + Type: "TRANSFER", + Amount: big.NewInt(2144), + Asset: "EUR", + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &picr)) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_transfer_initiations_delete.go b/internal/api/v2/handler_transfer_initiations_delete.go new file mode 100644 index 00000000..f8532a29 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_delete.go @@ -0,0 +1,35 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func transferInitiationsDelete(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsDelete") + defer span.End() + + span.SetAttributes(attribute.String("transferInitiationID", transferInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(transferInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PaymentInitiationsDelete(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_transfer_initiations_delete_test.go b/internal/api/v2/handler_transfer_initiations_delete_test.go new file mode 100644 index 00000000..30009d29 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_delete_test.go @@ -0,0 +1,57 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payment Initiation Deletion", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("delete payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsDelete(m) + }) + + It("should return a bad request error when transferInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "transferInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation delete err") + m.EXPECT().PaymentInitiationsDelete(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsDelete(gomock.Any(), paymentID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_transfer_initiations_get.go b/internal/api/v2/handler_transfer_initiations_get.go new file mode 100644 index 00000000..5deedadd --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_get.go @@ -0,0 +1,165 @@ +package v2 + +import ( + "encoding/json" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +type transferInitiationResponse struct { + ID string `json:"id"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + ScheduledAt time.Time `json:"scheduledAt"` + Description string `json:"description"` + SourceAccountID string `json:"sourceAccountID"` + DestinationAccountID string `json:"destinationAccountID"` + ConnectorID string `json:"connectorID"` + Provider string `json:"provider"` + Type string `json:"type"` + Amount *big.Int `json:"amount"` + InitialAmount *big.Int `json:"initialAmount"` + Asset string `json:"asset"` + Status string `json:"status"` + Error string `json:"error"` + Metadata map[string]string `json:"metadata"` +} + +type transferInitiationPaymentsResponse struct { + PaymentID string `json:"paymentID"` + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` +} + +type transferInitiationAdjustmentsResponse struct { + AdjustmentID string `json:"adjustmentID"` + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` + Error string `json:"error"` + Metadata map[string]string `json:"metadata"` +} + +type readTransferInitiationResponse struct { + transferInitiationResponse + RelatedPayments []transferInitiationPaymentsResponse `json:"relatedPayments"` + RelatedAdjustments []transferInitiationAdjustmentsResponse `json:"relatedAdjustments"` +} + +func transferInitiationsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsGet") + defer span.End() + + span.SetAttributes(attribute.String("transferInitiationID", transferInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(transferInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + transferInitiation, err := backend.PaymentInitiationsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + relatedPayments, err := backend.PaymentInitiationRelatedPaymentListAll(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + relatedAdjustments, err := backend.PaymentInitiationAdjustmentsListAll(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + t := translatePaymentInitiationToResponse(transferInitiation) + if len(relatedAdjustments) > 0 { + t.Status = relatedAdjustments[0].Status.String() + t.Error = func() string { + if relatedAdjustments[0].Error == nil { + return "" + } + return relatedAdjustments[0].Error.Error() + }() + } + + resp := &readTransferInitiationResponse{ + transferInitiationResponse: t, + RelatedPayments: translateRelatedPayments(relatedPayments), + RelatedAdjustments: translateAdjustments(relatedAdjustments), + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[readTransferInitiationResponse]{ + Data: resp, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} + +func translateAdjustments(from []models.PaymentInitiationAdjustment) []transferInitiationAdjustmentsResponse { + to := make([]transferInitiationAdjustmentsResponse, len(from)) + for i, adjustment := range from { + to[i] = transferInitiationAdjustmentsResponse{ + AdjustmentID: adjustment.ID.String(), + CreatedAt: adjustment.CreatedAt, + Status: adjustment.Status.String(), + Error: func() string { + if adjustment.Error == nil { + return "" + } + return adjustment.Error.Error() + }(), + Metadata: adjustment.Metadata, + } + } + return to +} + +func translateRelatedPayments(from []models.Payment) []transferInitiationPaymentsResponse { + to := make([]transferInitiationPaymentsResponse, len(from)) + for i, payment := range from { + to[i] = transferInitiationPaymentsResponse{ + PaymentID: payment.ID.String(), + CreatedAt: payment.CreatedAt, + Status: payment.Status.String(), + } + } + return to +} + +func translatePaymentInitiationToResponse(from *models.PaymentInitiation) transferInitiationResponse { + return transferInitiationResponse{ + ID: from.ID.String(), + Reference: from.Reference, + CreatedAt: from.CreatedAt, + ScheduledAt: from.ScheduledAt, + Description: from.Description, + SourceAccountID: from.SourceAccountID.String(), + DestinationAccountID: from.DestinationAccountID.String(), + ConnectorID: from.ConnectorID.String(), + Provider: from.ConnectorID.Provider, + Type: from.Type.String(), + Amount: from.Amount, + InitialAmount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + } +} diff --git a/internal/api/v2/handler_transfer_initiations_get_test.go b/internal/api/v2/handler_transfer_initiations_get_test.go new file mode 100644 index 00000000..1c4b16eb --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_get_test.go @@ -0,0 +1,103 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Transfer Initiation Get", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("get payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsGet(m) + }) + + It("should return a bad request error when transferInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "transferInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation get err") + m.EXPECT().PaymentInitiationsGet(gomock.Any(), gomock.Any()).Return( + &models.PaymentInitiation{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return an internal server error when backend returns error fetching payments", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation get payments err") + m.EXPECT().PaymentInitiationsGet(gomock.Any(), gomock.Any()).Return( + &models.PaymentInitiation{}, + nil, + ) + m.EXPECT().PaymentInitiationRelatedPaymentListAll(gomock.Any(), gomock.Any()).Return( + []models.Payment{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return an internal server error when backend returns error fetching payment adjustments", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation get adjustments err") + m.EXPECT().PaymentInitiationsGet(gomock.Any(), gomock.Any()).Return( + &models.PaymentInitiation{}, + nil, + ) + m.EXPECT().PaymentInitiationRelatedPaymentListAll(gomock.Any(), gomock.Any()).Return( + []models.Payment{}, + nil, + ) + m.EXPECT().PaymentInitiationAdjustmentsListAll(gomock.Any(), paymentID).Return( + []models.PaymentInitiationAdjustment{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsGet(gomock.Any(), paymentID).Return( + &models.PaymentInitiation{}, + nil, + ) + m.EXPECT().PaymentInitiationRelatedPaymentListAll(gomock.Any(), gomock.Any()).Return( + []models.Payment{}, + nil, + ) + m.EXPECT().PaymentInitiationAdjustmentsListAll(gomock.Any(), paymentID).Return( + []models.PaymentInitiationAdjustment{}, + nil, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v2/handler_transfer_initiations_list.go b/internal/api/v2/handler_transfer_initiations_list.go new file mode 100644 index 00000000..cbcdd48c --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_list.go @@ -0,0 +1,77 @@ +package v2 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func transferInitiationsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPaymentInitiationsQuery](r, func() (*storage.ListPaymentInitiationsQuery, error) { + options, err := getPagination(span, r, storage.PaymentInitiationQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPaymentInitiationsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.PaymentInitiationsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + data := make([]transferInitiationResponse, len(cursor.Data)) + for i := range cursor.Data { + data[i] = translatePaymentInitiationToResponse(&cursor.Data[i]) + + lastAdjustment, err := backend.PaymentInitiationAdjustmentsGetLast(ctx, cursor.Data[i].ID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + if lastAdjustment != nil { + data[i].Status = lastAdjustment.Status.String() + data[i].Error = func() string { + if lastAdjustment.Error == nil { + return "" + } + return lastAdjustment.Error.Error() + }() + } + } + + err = json.NewEncoder(w).Encode(api.BaseResponse[transferInitiationResponse]{ + Cursor: &bunpaginate.Cursor[transferInitiationResponse]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: data, + }, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v2/handler_transfer_initiations_list_test.go b/internal/api/v2/handler_transfer_initiations_list_test.go new file mode 100644 index 00000000..6eb44f9d --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_list_test.go @@ -0,0 +1,52 @@ +package v2 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 PaymentInitiations List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list paymentInitiations", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentInitiationsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.PaymentInitiation]{}, fmt.Errorf("paymentInitiations list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentInitiationsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.PaymentInitiation]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v2/handler_transfer_initiations_retry.go b/internal/api/v2/handler_transfer_initiations_retry.go new file mode 100644 index 00000000..08e35061 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_retry.go @@ -0,0 +1,35 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func transferInitiationsRetry(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsRetry") + defer span.End() + + span.SetAttributes(attribute.String("transferInitiationID", transferInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(transferInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + _, err = backend.PaymentInitiationsRetry(ctx, id, true) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_transfer_initiations_retry_test.go b/internal/api/v2/handler_transfer_initiations_retry_test.go new file mode 100644 index 00000000..2d1adbbb --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_retry_test.go @@ -0,0 +1,63 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payment Initiation Retry", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("retry payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsRetry(m) + }) + + It("should return a bad request error when transferInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "transferInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation retry err") + m.EXPECT().PaymentInitiationsRetry(gomock.Any(), gomock.Any(), true).Return( + models.Task{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsRetry(gomock.Any(), paymentID, true).Return( + models.Task{}, + nil, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_transfer_initiations_reverse.go b/internal/api/v2/handler_transfer_initiations_reverse.go new file mode 100644 index 00000000..9b91f4c7 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_reverse.go @@ -0,0 +1,101 @@ +package v2 + +import ( + "encoding/json" + "errors" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type reverseTransferInitiationRequest struct { + Reference string `json:"reference"` + Description string `json:"description"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` +} + +func (r *reverseTransferInitiationRequest) Validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Asset == "" { + return errors.New("asset is required") + } + + return nil +} + +func transferInitiationsReverse(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsReverse") + defer span.End() + + span.SetAttributes(attribute.String("transferInitiationID", transferInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(transferInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + payload := reverseTransferInitiationRequest{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + setReversalSpanAttributesFromRequest(span, payload) + + if err := payload.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + _, err = backend.PaymentInitiationReversalsCreate(ctx, models.PaymentInitiationReversal{ + ID: models.PaymentInitiationReversalID{ + Reference: payload.Reference, + ConnectorID: id.ConnectorID, + }, + ConnectorID: id.ConnectorID, + PaymentInitiationID: id, + Reference: payload.Reference, + CreatedAt: time.Now(), + Description: payload.Description, + Amount: payload.Amount, + Asset: payload.Asset, + Metadata: payload.Metadata, + }, true) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} + +func setReversalSpanAttributesFromRequest(span trace.Span, reversal reverseTransferInitiationRequest) { + span.SetAttributes( + attribute.String("reference", reversal.Reference), + attribute.String("description", reversal.Description), + attribute.String("asset", reversal.Asset), + attribute.String("amount", reversal.Amount.String()), + ) +} diff --git a/internal/api/v2/handler_transfer_initiations_reverse_test.go b/internal/api/v2/handler_transfer_initiations_reverse_test.go new file mode 100644 index 00000000..5f26afe6 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_reverse_test.go @@ -0,0 +1,88 @@ +package v2 + +import ( + "errors" + "math/big" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payment Initiation Reverse", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("retry payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsReverse(m) + }) + + It("should return a bad request error when transferInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "transferInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + handlerFn(w, prepareQueryRequest(http.MethodGet, "transferInitiationID", paymentID.String())) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(r reverseTransferInitiationRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &r)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", reverseTransferInitiationRequest{}), + Entry("amount missing", reverseTransferInitiationRequest{Reference: "amount"}), + Entry("asset missing", reverseTransferInitiationRequest{Reference: "asset", Amount: big.NewInt(1313)}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation reverse err") + m.EXPECT().PaymentInitiationReversalsCreate(gomock.Any(), gomock.Any(), true).Return( + models.Task{}, + expectedErr, + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &reverseTransferInitiationRequest{ + Reference: "ref", + Amount: big.NewInt(1313), + Asset: "USD", + })) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationReversalsCreate(gomock.Any(), gomock.Any(), true).Return( + models.Task{}, + nil, + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &reverseTransferInitiationRequest{ + Reference: "ref", + Amount: big.NewInt(1313), + Asset: "USD", + })) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/handler_transfer_initiations_update_status.go b/internal/api/v2/handler_transfer_initiations_update_status.go new file mode 100644 index 00000000..d2636966 --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_update_status.go @@ -0,0 +1,78 @@ +package v2 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +type deprecatedStatus string + +const ( + VALIDATED deprecatedStatus = "VALIDATED" + REJECTED deprecatedStatus = "REJECTED" +) + +type updateTransferInitiationStatusRequest struct { + Status string `json:"status"` +} + +func (r updateTransferInitiationStatusRequest) Validate() error { + if r.Status != string(VALIDATED) && r.Status != string(REJECTED) { + return errors.New("invalid status") + } + + return nil +} + +func transferInitiationsUpdateStatus(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_transferInitiationsUpdateStatus") + defer span.End() + + span.SetAttributes(attribute.String("transferInitiationID", transferInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(transferInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + payload := updateTransferInitiationStatusRequest{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + span.SetAttributes(attribute.String("status", payload.Status)) + + if err := payload.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + switch deprecatedStatus(payload.Status) { + case VALIDATED: + _, err = backend.PaymentInitiationsApprove(ctx, id, true) + case REJECTED: + err = backend.PaymentInitiationsReject(ctx, id) + default: + // Not possible since we already validated the status in the request + } + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v2/handler_transfer_initiations_update_status_test.go b/internal/api/v2/handler_transfer_initiations_update_status_test.go new file mode 100644 index 00000000..febbeddb --- /dev/null +++ b/internal/api/v2/handler_transfer_initiations_update_status_test.go @@ -0,0 +1,88 @@ +package v2 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Transfer Initiation Update Status", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("update payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + utsr updateTransferInitiationStatusRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = transferInitiationsUpdateStatus(m) + }) + + It("should return a bad request error when payment init id invalid", func(ctx SpecContext) { + req := prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", "invalid", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + DescribeTable("validation errors", + func(r updateTransferInitiationStatusRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &r)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("status missing", updateTransferInitiationStatusRequest{}), + Entry("status invalid", updateTransferInitiationStatusRequest{Status: "invalid"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation update err") + m.EXPECT().PaymentInitiationsApprove(gomock.Any(), gomock.Any(), true).Return( + models.Task{}, + expectedErr, + ) + utsr = updateTransferInitiationStatusRequest{ + Status: "VALIDATED", + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &utsr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should call approve backend when status is validated", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsApprove(gomock.Any(), gomock.Any(), true).Return( + models.Task{}, + nil, + ) + utsr = updateTransferInitiationStatusRequest{ + Status: "VALIDATED", + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &utsr)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + + It("should call reject backend when status is rejected", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsReject(gomock.Any(), gomock.Any()).Return(nil) + utsr = updateTransferInitiationStatusRequest{ + Status: "REJECTED", + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "transferInitiationID", paymentID.String(), &utsr)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v2/module.go b/internal/api/v2/module.go new file mode 100644 index 00000000..4356f8fc --- /dev/null +++ b/internal/api/v2/module.go @@ -0,0 +1,15 @@ +package v2 + +import ( + "github.com/formancehq/payments/internal/api" + "go.uber.org/fx" +) + +func NewModule() fx.Option { + return fx.Options( + fx.Supply(fx.Annotate(api.Version{ + Version: 2, + Builder: newRouter, + }, api.TagVersion())), + ) +} diff --git a/internal/api/v2/router.go b/internal/api/v2/router.go new file mode 100644 index 00000000..de2b079d --- /dev/null +++ b/internal/api/v2/router.go @@ -0,0 +1,150 @@ +package v2 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/auth" + "github.com/formancehq/go-libs/v2/service" + "github.com/formancehq/payments/internal/api/backend" + "github.com/go-chi/chi/v5" +) + +func newRouter(backend backend.Backend, a auth.Authenticator, debug bool) *chi.Mux { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(service.OTLPMiddleware("payments", debug)) + + // Public routes + r.Group(func(r chi.Router) { + r.Post("/connectors/webhooks/{connector}/connectorID", connectorsWebhooks(backend)) + }) + + // Authenticated routes + r.Group(func(r chi.Router) { + r.Use(auth.Middleware(a)) + + // Accounts + r.Route("/accounts", func(r chi.Router) { + r.Get("/", accountsList(backend)) + r.Post("/", accountsCreate(backend)) + + r.Route("/{accountID}", func(r chi.Router) { + r.Get("/", accountsGet(backend)) + r.Get("/balances", accountsBalances(backend)) + }) + }) + + // Bank Accounts + r.Route("/bank-accounts", func(r chi.Router) { + r.Post("/", bankAccountsCreate(backend)) + r.Get("/", bankAccountsList(backend)) + + r.Route("/{bankAccountID}", func(r chi.Router) { + r.Get("/", bankAccountsGet(backend)) + r.Patch("/metadata", bankAccountsUpdateMetadata(backend)) + r.Post("/forward", bankAccountsForwardToConnector(backend)) + }) + }) + + // Payments + r.Route("/payments", func(r chi.Router) { + r.Get("/", paymentsList(backend)) + r.Post("/", paymentsCreate(backend)) + + r.Route("/{paymentID}", func(r chi.Router) { + r.Get("/", paymentsGet(backend)) + r.Patch("/metadata", paymentsUpdateMetadata(backend)) + }) + }) + + // Pools + r.Route("/pools", func(r chi.Router) { + r.Post("/", poolsCreate(backend)) + r.Get("/", poolsList(backend)) + + r.Route("/{poolID}", func(r chi.Router) { + r.Get("/", poolsGet(backend)) + r.Delete("/", poolsDelete(backend)) + r.Get("/balances", poolsBalancesAt(backend)) + + r.Route("/accounts", func(r chi.Router) { + r.Post("/", poolsAddAccount(backend)) + r.Delete("/{accountID}", poolsRemoveAccount(backend)) + }) + }) + }) + + // Connectors + r.Route("/connectors", func(r chi.Router) { + r.Get("/", connectorsList(backend)) + r.Get("/configs", connectorsConfigs(backend)) + + r.Route("/{connector}", func(r chi.Router) { + r.Post("/", connectorsInstall(backend)) + connectorsRouter(backend, r) + }) + }) + + // Transfer Initiations + r.Route("/transfer-initiations", func(r chi.Router) { + r.Post("/", transferInitiationsCreate(backend)) + r.Get("/", transferInitiationsList(backend)) + + r.Route("/{transferInitiationID}", func(r chi.Router) { + r.Get("/", transferInitiationsGet(backend)) + r.Delete("/", transferInitiationsDelete(backend)) + + r.Post("/status", transferInitiationsUpdateStatus(backend)) + r.Post("/retry", transferInitiationsRetry(backend)) + r.Post("/reverse", transferInitiationsReverse(backend)) + }) + }) + }) + }) + + return r +} + +func connectorsRouter(backend backend.Backend, r chi.Router) { + r.Route("/{connectorID}", func(r chi.Router) { + r.Delete("/", connectorsUninstall(backend)) + r.Get("/config", connectorsConfig(backend)) + r.Post("/reset", connectorsReset(backend)) + r.Get("/tasks", tasksList(backend)) + r.Get("/tasks/{taskID}", tasksGet(backend)) + // TODO(polo): add update config handler + }) +} + +func connectorID(r *http.Request) string { + return chi.URLParam(r, "connectorID") +} + +func connectorProvider(r *http.Request) string { + return chi.URLParam(r, "connector") +} + +func accountID(r *http.Request) string { + return chi.URLParam(r, "accountID") +} + +func paymentID(r *http.Request) string { + return chi.URLParam(r, "paymentID") +} + +func poolID(r *http.Request) string { + return chi.URLParam(r, "poolID") +} + +func bankAccountID(r *http.Request) string { + return chi.URLParam(r, "bankAccountID") +} + +func taskID(r *http.Request) string { + return chi.URLParam(r, "taskID") +} + +func transferInitiationID(r *http.Request) string { + return chi.URLParam(r, "transferInitiationID") +} diff --git a/internal/api/v2/utils.go b/internal/api/v2/utils.go new file mode 100644 index 00000000..0fc8d5c6 --- /dev/null +++ b/internal/api/v2/utils.go @@ -0,0 +1,43 @@ +package v2 + +import ( + "io" + "net/http" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +func getQueryBuilder(span trace.Span, r *http.Request) (query.Builder, error) { + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if len(data) > 0 { + span.SetAttributes(attribute.String("query", string(data))) + return query.ParseJSON(string(data)) + } else { + // In order to be backward compatible + span.SetAttributes(attribute.String("query", r.URL.Query().Get("query"))) + return query.ParseJSON(r.URL.Query().Get("query")) + } +} + +func getPagination[T any](span trace.Span, r *http.Request, options T) (*bunpaginate.PaginatedQueryOptions[T], error) { + qb, err := getQueryBuilder(span, r) + if err != nil { + return nil, err + } + + pageSize, err := bunpaginate.GetPageSize(r) + if err != nil { + return nil, err + } + span.SetAttributes(attribute.Int64("pageSize", int64(pageSize))) + + return pointer.For(bunpaginate.NewPaginatedQueryOptions(options).WithQueryBuilder(qb).WithPageSize(pageSize)), nil +} diff --git a/internal/api/v3/errors.go b/internal/api/v3/errors.go new file mode 100644 index 00000000..4e384ed4 --- /dev/null +++ b/internal/api/v3/errors.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/services" + "github.com/formancehq/payments/internal/storage" +) + +const ( + ErrUniqueReference = "CONFLICT" + ErrNotFound = "NOT_FOUND" + ErrInvalidID = "INVALID_ID" + ErrMissingOrInvalidBody = "MISSING_OR_INVALID_BODY" + ErrValidation = "VALIDATION" +) + +func handleServiceErrors(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrDuplicateKeyValue): + api.BadRequest(w, ErrUniqueReference, err) + case errors.Is(err, storage.ErrNotFound): + api.NotFound(w, err) + case errors.Is(err, storage.ErrValidation): + api.BadRequest(w, ErrValidation, err) + case errors.Is(err, services.ErrValidation): + api.BadRequest(w, ErrValidation, err) + case errors.Is(err, services.ErrNotFound): + api.NotFound(w, err) + default: + api.InternalServerError(w, r, err) + } +} diff --git a/internal/api/v3/handler_accounts_balances.go b/internal/api/v3/handler_accounts_balances.go new file mode 100644 index 00000000..1de1010d --- /dev/null +++ b/internal/api/v3/handler_accounts_balances.go @@ -0,0 +1,104 @@ +package v3 + +import ( + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +func accountsBalances(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_accountsBalances") + defer span.End() + + balanceQuery, err := populateBalanceQueryFromRequest(span, r) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + query, err := bunpaginate.Extract(r, func() (*storage.ListBalancesQuery, error) { + options, err := getPagination(span, r, balanceQuery) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListBalancesQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.BalancesList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} + +func populateBalanceQueryFromRequest(span trace.Span, r *http.Request) (storage.BalanceQuery, error) { + var balanceQuery storage.BalanceQuery + + balanceQuery = balanceQuery.WithAsset(r.URL.Query().Get("asset")) + span.SetAttributes(attribute.String("asset", balanceQuery.Asset)) + + span.SetAttributes(attribute.String("accountID", accountID(r))) + accountID, err := models.AccountIDFromString(accountID(r)) + if err != nil { + return balanceQuery, err + } + balanceQuery = balanceQuery.WithAccountID(&accountID) + + var startTimeParsed, endTimeParsed time.Time + + from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") + if from != "" { + startTimeParsed, err = time.Parse(time.RFC3339Nano, from) + if err != nil { + return balanceQuery, err + } + } + if to != "" { + endTimeParsed, err = time.Parse(time.RFC3339Nano, to) + if err != nil { + return balanceQuery, err + } + } + + switch { + case startTimeParsed.IsZero() && endTimeParsed.IsZero(): + balanceQuery = balanceQuery. + WithTo(time.Now()) + case !startTimeParsed.IsZero() && endTimeParsed.IsZero(): + balanceQuery = balanceQuery. + WithFrom(startTimeParsed). + WithTo(time.Now()) + case startTimeParsed.IsZero() && !endTimeParsed.IsZero(): + balanceQuery = balanceQuery. + WithTo(endTimeParsed) + default: + balanceQuery = balanceQuery. + WithFrom(startTimeParsed). + WithTo(endTimeParsed) + } + + span.SetAttributes(attribute.String("from", balanceQuery.From.Format(time.RFC3339Nano))) + span.SetAttributes(attribute.String("to", balanceQuery.To.Format(time.RFC3339Nano))) + + return balanceQuery, nil +} diff --git a/internal/api/v3/handler_accounts_balances_test.go b/internal/api/v3/handler_accounts_balances_test.go new file mode 100644 index 00000000..3eeaa6e8 --- /dev/null +++ b/internal/api/v3/handler_accounts_balances_test.go @@ -0,0 +1,65 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Accounts Balances", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + }) + + Context("list balances", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsBalances(m) + }) + + It("should return a validation request error when account ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().BalancesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Balance]{}, fmt.Errorf("balances list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().BalancesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Balance]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_accounts_create.go b/internal/api/v3/handler_accounts_create.go new file mode 100644 index 00000000..c59b59be --- /dev/null +++ b/internal/api/v3/handler_accounts_create.go @@ -0,0 +1,129 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type CreateAccountRequest struct { + Reference string `json:"reference"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + DefaultAsset string `json:"defaultAsset"` + AccountName string `json:"accountName"` + Type string `json:"type"` + Metadata map[string]string `json:"metadata"` +} + +func (r *CreateAccountRequest) validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.ConnectorID == "" { + return errors.New("connectorID is required") + } + + if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { + return errors.New("createdAt is empty or in the future") + } + + if r.AccountName == "" { + return errors.New("accountName is required") + } + + if r.Type == "" { + return errors.New("type is required") + } + + _, err := models.ConnectorIDFromString(r.ConnectorID) + if err != nil { + return errors.New("connectorID is invalid") + } + + switch r.Type { + case string(models.ACCOUNT_TYPE_EXTERNAL): + case string(models.ACCOUNT_TYPE_INTERNAL): + default: + return errors.New("type is invalid") + } + + return nil +} + +func accountsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_accountsCreate") + defer span.End() + + var req CreateAccountRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromCreateAccountRequest(span, req) + + if err := req.validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectorID := models.MustConnectorIDFromString(req.ConnectorID) + raw, err := json.Marshal(req) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + + account := models.Account{ + ID: models.AccountID{ + Reference: req.Reference, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Reference: req.Reference, + CreatedAt: req.CreatedAt, + Type: models.AccountType(req.Type), + Name: &req.AccountName, + DefaultAsset: &req.DefaultAsset, + Metadata: req.Metadata, + Raw: raw, + } + + err = backend.AccountsCreate(ctx, account) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Created(w, account) + } +} + +func populateSpanFromCreateAccountRequest(span trace.Span, req CreateAccountRequest) { + span.SetAttributes(attribute.String("reference", req.Reference)) + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + span.SetAttributes(attribute.String("createdAt", req.CreatedAt.String())) + span.SetAttributes(attribute.String("defaultAsset", req.DefaultAsset)) + span.SetAttributes(attribute.String("accountName", req.AccountName)) + span.SetAttributes(attribute.String("type", req.Type)) + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v3/handler_accounts_create_test.go b/internal/api/v3/handler_accounts_create_test.go new file mode 100644 index 00000000..06559141 --- /dev/null +++ b/internal/api/v3/handler_accounts_create_test.go @@ -0,0 +1,92 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Accounts Create", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("create accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + cra CreateAccountRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(cra CreateAccountRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &cra)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", CreateAccountRequest{}), + Entry("connectorID missing", CreateAccountRequest{Reference: "reference"}), + Entry("createdAt missing", CreateAccountRequest{Reference: "reference", ConnectorID: "id"}), + Entry("accountName missing", CreateAccountRequest{Reference: "reference", ConnectorID: "id", CreatedAt: time.Now()}), + Entry("type missing", CreateAccountRequest{ + Reference: "reference", ConnectorID: "id", CreatedAt: time.Now(), AccountName: "accountName", + }), + Entry("connectorID invalid", CreateAccountRequest{ + Reference: "reference", ConnectorID: "id", CreatedAt: time.Now(), AccountName: "accountName", Type: "type", + }), + Entry("type invalid", CreateAccountRequest{ + Reference: "reference", ConnectorID: connID.String(), CreatedAt: time.Now(), AccountName: "accountName", Type: "type", + }), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("account create err") + m.EXPECT().AccountsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + cra = CreateAccountRequest{ + Reference: "reference", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + AccountName: "accountName", + Type: string(models.ACCOUNT_TYPE_EXTERNAL), + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cra)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status created on success", func(ctx SpecContext) { + m.EXPECT().AccountsCreate(gomock.Any(), gomock.Any()).Return(nil) + cra = CreateAccountRequest{ + Reference: "reference", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + AccountName: "accountName", + Type: string(models.ACCOUNT_TYPE_EXTERNAL), + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cra)) + assertExpectedResponse(w.Result(), http.StatusCreated, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_accounts_get.go b/internal/api/v3/handler_accounts_get.go new file mode 100644 index 00000000..b712b370 --- /dev/null +++ b/internal/api/v3/handler_accounts_get.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func accountsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_accountsGet") + defer span.End() + + span.SetAttributes(attribute.String("accountID", accountID(r))) + id, err := models.AccountIDFromString(accountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + account, err := backend.AccountsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, account) + } +} diff --git a/internal/api/v3/handler_accounts_get_test.go b/internal/api/v3/handler_accounts_get_test.go new file mode 100644 index 00000000..0494c637 --- /dev/null +++ b/internal/api/v3/handler_accounts_get_test.go @@ -0,0 +1,64 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Accounts", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + }) + + Context("get accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsGet(m) + }) + + It("should return an invalid ID error when account ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().AccountsGet(gomock.Any(), accID).Return( + &models.Account{}, fmt.Errorf("accounts get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "accountID", accID.String()) + m.EXPECT().AccountsGet(gomock.Any(), accID).Return( + &models.Account{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_accounts_list.go b/internal/api/v3/handler_accounts_list.go new file mode 100644 index 00000000..c100ec8f --- /dev/null +++ b/internal/api/v3/handler_accounts_list.go @@ -0,0 +1,41 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func accountsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_accountsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListAccountsQuery](r, func() (*storage.ListAccountsQuery, error) { + options, err := getPagination(span, r, storage.AccountQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListAccountsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + accounts, err := backend.AccountsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *accounts) + } +} diff --git a/internal/api/v3/handler_accounts_list_test.go b/internal/api/v3/handler_accounts_list_test.go new file mode 100644 index 00000000..d649aaec --- /dev/null +++ b/internal/api/v3/handler_accounts_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Accounts List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = accountsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().AccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Account]{}, fmt.Errorf("accounts list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().AccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Account]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_bank_accounts_create.go b/internal/api/v3/handler_bank_accounts_create.go new file mode 100644 index 00000000..fd4252bd --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_create.go @@ -0,0 +1,97 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type BankAccountsCreateRequest struct { + Name string `json:"name"` + + AccountNumber *string `json:"accountNumber"` + IBAN *string `json:"iban"` + SwiftBicCode *string `json:"swiftBicCode"` + Country *string `json:"country"` + + Metadata map[string]string `json:"metadata"` +} + +func (r *BankAccountsCreateRequest) Validate() error { + if r.AccountNumber == nil && r.IBAN == nil { + return errors.New("either accountNumber or iban must be provided") + } + + if r.Name == "" { + return errors.New("name must be provided") + } + + return nil +} + +func bankAccountsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_bankAccountsCreate") + defer span.End() + + var req BankAccountsCreateRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromBankAccountCreateRequest(span, req) + + if err := req.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + bankAccount := &models.BankAccount{ + ID: uuid.New(), + CreatedAt: time.Now().UTC(), + Name: req.Name, + AccountNumber: req.AccountNumber, + IBAN: req.IBAN, + SwiftBicCode: req.SwiftBicCode, + Country: req.Country, + Metadata: req.Metadata, + } + + err = backend.BankAccountsCreate(ctx, *bankAccount) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Created(w, bankAccount.ID.String()) + } +} + +func populateSpanFromBankAccountCreateRequest(span trace.Span, req BankAccountsCreateRequest) { + span.SetAttributes(attribute.String("name", req.Name)) + + // Do not record sensitive information + + if req.Country != nil { + span.SetAttributes(attribute.String("country", *req.Country)) + } + + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v3/handler_bank_accounts_create_test.go b/internal/api/v3/handler_bank_accounts_create_test.go new file mode 100644 index 00000000..5ab29c16 --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_create_test.go @@ -0,0 +1,77 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Bank Accounts Create", func() { + var ( + handlerFn http.HandlerFunc + accountNumber string + iban string + ) + BeforeEach(func() { + accountNumber = "1232434" + iban = "DE89370400440532013000" + }) + + Context("create bank accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + bac BankAccountsCreateRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(bac BankAccountsCreateRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &bac)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("account number missing", BankAccountsCreateRequest{}), + Entry("iban missing", BankAccountsCreateRequest{AccountNumber: &accountNumber}), + Entry("name missing", BankAccountsCreateRequest{AccountNumber: &accountNumber, IBAN: &iban}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("bank account create err") + m.EXPECT().BankAccountsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + bac = BankAccountsCreateRequest{ + Name: "reference", + IBAN: &iban, + AccountNumber: &accountNumber, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &bac)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status created on success", func(ctx SpecContext) { + m.EXPECT().BankAccountsCreate(gomock.Any(), gomock.Any()).Return(nil) + bac = BankAccountsCreateRequest{ + Name: "reference", + IBAN: &iban, + AccountNumber: &accountNumber, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &bac)) + assertExpectedResponse(w.Result(), http.StatusCreated, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_bank_accounts_forward_to_connector.go b/internal/api/v3/handler_bank_accounts_forward_to_connector.go new file mode 100644 index 00000000..b65f5921 --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_forward_to_connector.go @@ -0,0 +1,74 @@ +package v3 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +type BankAccountsForwardToConnectorRequest struct { + ConnectorID string `json:"connectorID"` +} + +func (f *BankAccountsForwardToConnectorRequest) Validate() error { + if f.ConnectorID == "" { + return errors.New("connectorID must be provided") + } + + return nil +} + +func bankAccountsForwardToConnector(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_bankAccountsForwardToConnector") + defer span.End() + + span.SetAttributes(attribute.String("bankAccountID", bankAccountID(r))) + id, err := uuid.Parse(bankAccountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var req BankAccountsForwardToConnectorRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + + err = req.Validate() + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + connectorID, err := models.ConnectorIDFromString(req.ConnectorID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + task, err := backend.BankAccountsForwardToConnector(ctx, id, connectorID, false) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Accepted(w, task) + } +} diff --git a/internal/api/v3/handler_bank_accounts_forward_to_connector_test.go b/internal/api/v3/handler_bank_accounts_forward_to_connector_test.go new file mode 100644 index 00000000..5fc1213e --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_forward_to_connector_test.go @@ -0,0 +1,72 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Bank Accounts ForwardToConnector", func() { + var ( + handlerFn http.HandlerFunc + bankAccountID uuid.UUID + connID models.ConnectorID + ) + BeforeEach(func() { + bankAccountID = uuid.New() + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("forward bank accounts to connector", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + freq BankAccountsForwardToConnectorRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsForwardToConnector(m) + }) + + DescribeTable("validation errors", + func(expected string, freq BankAccountsForwardToConnectorRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "bankAccountID", bankAccountID.String(), &freq)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, expected) + }, + Entry("connector ID missing", ErrMissingOrInvalidBody, BankAccountsForwardToConnectorRequest{}), + Entry("connector ID invalid", ErrValidation, BankAccountsForwardToConnectorRequest{ConnectorID: "blah"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + m.EXPECT().BankAccountsForwardToConnector(gomock.Any(), bankAccountID, connID, false).Return( + models.Task{}, + fmt.Errorf("bank account forward err"), + ) + freq = BankAccountsForwardToConnectorRequest{ + ConnectorID: connID.String(), + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "bankAccountID", bankAccountID.String(), &freq)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().BankAccountsForwardToConnector(gomock.Any(), bankAccountID, connID, false).Return( + models.Task{}, + nil, + ) + freq = BankAccountsForwardToConnectorRequest{ + ConnectorID: connID.String(), + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "bankAccountID", bankAccountID.String(), &freq)) + assertExpectedResponse(w.Result(), http.StatusAccepted, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_bank_accounts_get.go b/internal/api/v3/handler_bank_accounts_get.go new file mode 100644 index 00000000..e0f10211 --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_get.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func bankAccountsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_bankAccountsGet") + defer span.End() + + span.SetAttributes(attribute.String("bankAccountID", bankAccountID(r))) + id, err := uuid.Parse(bankAccountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + bankAccount, err := backend.BankAccountsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, bankAccount) + } +} diff --git a/internal/api/v3/handler_bank_accounts_get_test.go b/internal/api/v3/handler_bank_accounts_get_test.go new file mode 100644 index 00000000..8cba5b99 --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_get_test.go @@ -0,0 +1,63 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Bank Accounts", func() { + var ( + handlerFn http.HandlerFunc + accID uuid.UUID + ) + BeforeEach(func() { + accID = uuid.New() + }) + + Context("get bank accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsGet(m) + }) + + It("should return an invalid ID error when account ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "bankAccountID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "bankAccountID", accID.String()) + m.EXPECT().BankAccountsGet(gomock.Any(), accID).Return( + &models.BankAccount{}, fmt.Errorf("bank accounts get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "bankAccountID", accID.String()) + m.EXPECT().BankAccountsGet(gomock.Any(), accID).Return( + &models.BankAccount{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_bank_accounts_list.go b/internal/api/v3/handler_bank_accounts_list.go new file mode 100644 index 00000000..733c85ea --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_list.go @@ -0,0 +1,41 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func bankAccountsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_bankAccountsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListBankAccountsQuery](r, func() (*storage.ListBankAccountsQuery, error) { + options, err := getPagination(span, r, storage.BankAccountQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListBankAccountsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.BankAccountsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_bank_accounts_list_test.go b/internal/api/v3/handler_bank_accounts_list_test.go new file mode 100644 index 00000000..c096a669 --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Bank Accounts List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list bank accounts", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().BankAccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.BankAccount]{}, fmt.Errorf("bank accounts list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().BankAccountsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.BankAccount]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_bank_accounts_update_metadata.go b/internal/api/v3/handler_bank_accounts_update_metadata.go new file mode 100644 index 00000000..ecaae95d --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_update_metadata.go @@ -0,0 +1,74 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type BankAccountsUpdateMetadataRequest struct { + Metadata map[string]string `json:"metadata"` +} + +func (u *BankAccountsUpdateMetadataRequest) Validate() error { + if len(u.Metadata) == 0 { + return errors.New("metadata must be provided") + } + + return nil +} + +func bankAccountsUpdateMetadata(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_bankAccountsUpdateMetadata") + defer span.End() + + span.SetAttributes(attribute.String("bankAccountID", bankAccountID(r))) + id, err := uuid.Parse(bankAccountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var req BankAccountsUpdateMetadataRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromUpdateMetadataRequest(span, req.Metadata) + + err = req.Validate() + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + err = backend.BankAccountsUpdateMetadata(ctx, id, req.Metadata) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} + +func populateSpanFromUpdateMetadataRequest(span trace.Span, metadata map[string]string) { + for k, v := range metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v3/handler_bank_accounts_update_metadata_test.go b/internal/api/v3/handler_bank_accounts_update_metadata_test.go new file mode 100644 index 00000000..54ec2ed1 --- /dev/null +++ b/internal/api/v3/handler_bank_accounts_update_metadata_test.go @@ -0,0 +1,70 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Bank Accounts Update Metadata", func() { + var ( + handlerFn http.HandlerFunc + bankAccountID uuid.UUID + ) + BeforeEach(func() { + bankAccountID = uuid.New() + }) + + Context("update bank account metadata", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + bau BankAccountsUpdateMetadataRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = bankAccountsUpdateMetadata(m) + }) + + It("should return a bad request error when bank account is invalid", func(ctx SpecContext) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", "invalid", &bau)) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + DescribeTable("validation errors", + func(bau BankAccountsUpdateMetadataRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", bankAccountID.String(), &bau)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("metadata missing", BankAccountsUpdateMetadataRequest{}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("bank account update metadata err") + m.EXPECT().BankAccountsUpdateMetadata(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + bau = BankAccountsUpdateMetadataRequest{ + Metadata: map[string]string{"meta": "data"}, + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", bankAccountID.String(), &bau)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + metadata := map[string]string{"meta": "data"} + m.EXPECT().BankAccountsUpdateMetadata(gomock.Any(), gomock.Any(), metadata).Return(nil) + bau = BankAccountsUpdateMetadataRequest{ + Metadata: metadata, + } + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "bankAccountID", bankAccountID.String(), &bau)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_config.go b/internal/api/v3/handler_connectors_config.go new file mode 100644 index 00000000..98ffd7d8 --- /dev/null +++ b/internal/api/v3/handler_connectors_config.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsConfig(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_connectorsConfig") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + config, err := backend.ConnectorsConfig(ctx, connectorID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, config) + } +} diff --git a/internal/api/v3/handler_connectors_config_test.go b/internal/api/v3/handler_connectors_config_test.go new file mode 100644 index 00000000..b640bc20 --- /dev/null +++ b/internal/api/v3/handler_connectors_config_test.go @@ -0,0 +1,64 @@ +package v3 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connectors Config", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("get connectors config", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsConfig(m) + }) + + It("should return an invalid ID error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String()) + m.EXPECT().ConnectorsConfig(gomock.Any(), connID).Return( + json.RawMessage("{}"), fmt.Errorf("connector configs get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String()) + m.EXPECT().ConnectorsConfig(gomock.Any(), connID).Return( + json.RawMessage("{}"), nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_configs.go b/internal/api/v3/handler_connectors_configs.go new file mode 100644 index 00000000..f3b52b76 --- /dev/null +++ b/internal/api/v3/handler_connectors_configs.go @@ -0,0 +1,29 @@ +package v3 + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/otel" +) + +func connectorsConfigs(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, span := otel.Tracer().Start(r.Context(), "v3_connectorsConfigs") + defer span.End() + + configs := backend.ConnectorsConfigs() + + err := json.NewEncoder(w).Encode(api.BaseResponse[plugins.Configs]{ + Data: &configs, + }) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + } +} diff --git a/internal/api/v3/handler_connectors_configs_test.go b/internal/api/v3/handler_connectors_configs_test.go new file mode 100644 index 00000000..966cb52e --- /dev/null +++ b/internal/api/v3/handler_connectors_configs_test.go @@ -0,0 +1,38 @@ +package v3 + +import ( + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/connectors/plugins" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connectors Configs", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("get connectors configs", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsConfigs(m) + }) + + It("should return data object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().ConnectorsConfigs().Return(plugins.Configs{}) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_install.go b/internal/api/v3/handler_connectors_install.go new file mode 100644 index 00000000..f2905efa --- /dev/null +++ b/internal/api/v3/handler_connectors_install.go @@ -0,0 +1,39 @@ +package v3 + +import ( + "io" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsInstall(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_connectorsInstall") + defer span.End() + + config, err := io.ReadAll(r.Body) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + span.SetAttributes(attribute.String("config", string(config))) + span.SetAttributes(attribute.String("provider", connector(r))) + + provider := connector(r) + + connectorID, err := backend.ConnectorsInstall(ctx, provider, config) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Accepted(w, connectorID.String()) + } +} diff --git a/internal/api/v3/handler_connectors_install_test.go b/internal/api/v3/handler_connectors_install_test.go new file mode 100644 index 00000000..4de5f466 --- /dev/null +++ b/internal/api/v3/handler_connectors_install_test.go @@ -0,0 +1,56 @@ +package v3 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connector Install", func() { + var ( + handlerFn http.HandlerFunc + conn string + config json.RawMessage + ) + BeforeEach(func() { + conn = "psp" + config = json.RawMessage("{}") + }) + + Context("install connector", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsInstall(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + m.EXPECT().ConnectorsInstall(gomock.Any(), conn, config).Return( + models.ConnectorID{}, + fmt.Errorf("connector install err"), + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connector", conn, &config)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsInstall(gomock.Any(), conn, config).Return( + models.ConnectorID{}, + nil, + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connector", conn, &config)) + assertExpectedResponse(w.Result(), http.StatusAccepted, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_list.go b/internal/api/v3/handler_connectors_list.go new file mode 100644 index 00000000..d39254b9 --- /dev/null +++ b/internal/api/v3/handler_connectors_list.go @@ -0,0 +1,41 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func connectorsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_connectorsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListConnectorsQuery](r, func() (*storage.ListConnectorsQuery, error) { + options, err := getPagination(span, r, storage.ConnectorQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListConnectorsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectors, err := backend.ConnectorsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *connectors) + } +} diff --git a/internal/api/v3/handler_connectors_list_test.go b/internal/api/v3/handler_connectors_list_test.go new file mode 100644 index 00000000..85a59612 --- /dev/null +++ b/internal/api/v3/handler_connectors_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connectors List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list connectors", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Connector]{}, fmt.Errorf("connectors list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Connector]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_reset.go b/internal/api/v3/handler_connectors_reset.go new file mode 100644 index 00000000..de81dd67 --- /dev/null +++ b/internal/api/v3/handler_connectors_reset.go @@ -0,0 +1,34 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsReset(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_connectorsReset") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + if err := backend.ConnectorsReset(ctx, connectorID); err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_connectors_reset_test.go b/internal/api/v3/handler_connectors_reset_test.go new file mode 100644 index 00000000..c0ba180f --- /dev/null +++ b/internal/api/v3/handler_connectors_reset_test.go @@ -0,0 +1,56 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connectors reset", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("reset connectors", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsReset(m) + }) + + It("should return a bad request error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("connectors reset err") + m.EXPECT().ConnectorsReset(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsReset(gomock.Any(), connID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_uninstall.go b/internal/api/v3/handler_connectors_uninstall.go new file mode 100644 index 00000000..336f2bb0 --- /dev/null +++ b/internal/api/v3/handler_connectors_uninstall.go @@ -0,0 +1,34 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsUninstall(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_connectorsUninstall") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + if err := backend.ConnectorsUninstall(ctx, connectorID); err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + + api.Accepted(w, connectorID.String()) + } +} diff --git a/internal/api/v3/handler_connectors_uninstall_test.go b/internal/api/v3/handler_connectors_uninstall_test.go new file mode 100644 index 00000000..4d300598 --- /dev/null +++ b/internal/api/v3/handler_connectors_uninstall_test.go @@ -0,0 +1,56 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connectors uninstall", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("uninstall connectors", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsUninstall(m) + }) + + It("should return a bad request error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("connectors uninstall err") + m.EXPECT().ConnectorsUninstall(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsUninstall(gomock.Any(), connID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "connectorID", connID.String())) + assertExpectedResponse(w.Result(), http.StatusAccepted, connID.String()) + }) + }) +}) diff --git a/internal/api/v3/handler_connectors_webhooks.go b/internal/api/v3/handler_connectors_webhooks.go new file mode 100644 index 00000000..d6112596 --- /dev/null +++ b/internal/api/v3/handler_connectors_webhooks.go @@ -0,0 +1,64 @@ +package v3 + +import ( + "io" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func connectorsWebhooks(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_connectorsWebhooks") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil && err != io.EOF { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + headers := r.Header + queryValues := r.URL.Query() + path := r.URL.Path + username, password, ok := r.BasicAuth() + + webhook := models.Webhook{ + ID: uuid.New().String(), + ConnectorID: connectorID, + QueryValues: queryValues, + Headers: headers, + Body: body, + } + + if ok { + webhook.BasicAuth = &models.BasicAuth{ + Username: username, + Password: password, + } + } + + err = backend.ConnectorsHandleWebhooks(ctx, path, webhook) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RawOk(w, nil) + } +} diff --git a/internal/api/v3/handler_connectors_webhooks_test.go b/internal/api/v3/handler_connectors_webhooks_test.go new file mode 100644 index 00000000..cbc1114c --- /dev/null +++ b/internal/api/v3/handler_connectors_webhooks_test.go @@ -0,0 +1,58 @@ +package v3 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Connector Webhooks", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + config json.RawMessage + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + config = json.RawMessage("{}") + }) + + Context("webhooks connector", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = connectorsWebhooks(m) + }) + + It("should return a bad request error when connector ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + m.EXPECT().ConnectorsHandleWebhooks(gomock.Any(), "/", gomock.Any()).Return(fmt.Errorf("connector webhooks err")) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connectorID", connID.String(), &config)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().ConnectorsHandleWebhooks(gomock.Any(), "/", gomock.Any()).Return(nil) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "connectorID", connID.String(), &config)) + assertExpectedResponse(w.Result(), http.StatusOK, "") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiation_adjustments_list.go b/internal/api/v3/handler_payment_initiation_adjustments_list.go new file mode 100644 index 00000000..f04ebdac --- /dev/null +++ b/internal/api/v3/handler_payment_initiation_adjustments_list.go @@ -0,0 +1,51 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationAdjustmentsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationAdjustmentsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPaymentInitiationAdjustmentsQuery](r, func() (*storage.ListPaymentInitiationAdjustmentsQuery, error) { + options, err := getPagination(span, r, storage.PaymentInitiationAdjustmentsQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPaymentInitiationAdjustmentsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + cursor, err := backend.PaymentInitiationAdjustmentsList(ctx, id, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_payment_initiation_adjustments_list_test.go b/internal/api/v3/handler_payment_initiation_adjustments_list_test.go new file mode 100644 index 00000000..2136d82d --- /dev/null +++ b/internal/api/v3/handler_payment_initiation_adjustments_list_test.go @@ -0,0 +1,65 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Adjustments List", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("list payment initiation adjustments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationAdjustmentsList(m) + }) + + It("should return a validation request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String()) + m.EXPECT().PaymentInitiationAdjustmentsList(gomock.Any(), paymentID, gomock.Any()).Return( + &bunpaginate.Cursor[models.PaymentInitiationAdjustment]{}, fmt.Errorf("payment initiation adjustments list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String()) + m.EXPECT().PaymentInitiationAdjustmentsList(gomock.Any(), paymentID, gomock.Any()).Return( + &bunpaginate.Cursor[models.PaymentInitiationAdjustment]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiation_payments_list.go b/internal/api/v3/handler_payment_initiation_payments_list.go new file mode 100644 index 00000000..95104b4e --- /dev/null +++ b/internal/api/v3/handler_payment_initiation_payments_list.go @@ -0,0 +1,51 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationPaymentsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationPaymentsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPaymentInitiationRelatedPaymentsQuery](r, func() (*storage.ListPaymentInitiationRelatedPaymentsQuery, error) { + options, err := getPagination(span, r, storage.PaymentInitiationRelatedPaymentsQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPaymentInitiationRelatedPaymentsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + cursor, err := backend.PaymentInitiationRelatedPaymentsList(ctx, id, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_payment_initiation_payments_list_test.go b/internal/api/v3/handler_payment_initiation_payments_list_test.go new file mode 100644 index 00000000..6932fe47 --- /dev/null +++ b/internal/api/v3/handler_payment_initiation_payments_list_test.go @@ -0,0 +1,65 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Payments List", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("list payment initiation list payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationPaymentsList(m) + }) + + It("should return a validation request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String()) + m.EXPECT().PaymentInitiationRelatedPaymentsList(gomock.Any(), paymentID, gomock.Any()).Return( + &bunpaginate.Cursor[models.Payment]{}, fmt.Errorf("payment initiation payments list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String()) + m.EXPECT().PaymentInitiationRelatedPaymentsList(gomock.Any(), paymentID, gomock.Any()).Return( + &bunpaginate.Cursor[models.Payment]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_approve.go b/internal/api/v3/handler_payment_initiations_approve.go new file mode 100644 index 00000000..8b23ea08 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_approve.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationsApprove(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsApprove") + defer span.End() + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + task, err := backend.PaymentInitiationsApprove(ctx, id, false) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Accepted(w, task) + } +} diff --git a/internal/api/v3/handler_payment_initiations_approve_test.go b/internal/api/v3/handler_payment_initiations_approve_test.go new file mode 100644 index 00000000..009a9ed3 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_approve_test.go @@ -0,0 +1,63 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Approval", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("approve payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsApprove(m) + }) + + It("should return a bad request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation approve err") + m.EXPECT().PaymentInitiationsApprove(gomock.Any(), gomock.Any(), false).Return( + models.Task{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsApprove(gomock.Any(), paymentID, false).Return( + models.Task{}, + nil, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusAccepted, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_create.go b/internal/api/v3/handler_payment_initiations_create.go new file mode 100644 index 00000000..af928cbf --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_create.go @@ -0,0 +1,159 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type PaymentInitiationsCreateRequest struct { + Reference string `json:"reference"` + ScheduledAt time.Time `json:"scheduledAt"` + ConnectorID string `json:"connectorID"` + Description string `json:"description"` + Type string `json:"type"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + + SourceAccountID *string `json:"sourceAccountID"` + DestinationAccountID *string `json:"destinationAccountID"` + + Metadata map[string]string `json:"metadata"` +} + +func (r *PaymentInitiationsCreateRequest) Validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.SourceAccountID != nil { + _, err := models.AccountIDFromString(*r.SourceAccountID) + if err != nil { + return err + } + } + + if r.DestinationAccountID != nil { + _, err := models.AccountIDFromString(*r.DestinationAccountID) + if err != nil { + return err + } + } + + _, err := models.PaymentInitiationTypeFromString(r.Type) + if err != nil { + return err + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Asset == "" { + return errors.New("asset is required") + } + + return nil +} + +type PaymentInitiationsCreateResponse struct { + PaymentInitiationID string `json:"paymentInitiationID"` + TaskID string `json:"taskID"` +} + +func paymentInitiationsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsCreate") + defer span.End() + + payload := PaymentInitiationsCreateRequest{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromPaymentInitiationCreateRequest(span, payload) + + if err := payload.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectorID, err := models.ConnectorIDFromString(payload.ConnectorID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + noValidation := r.URL.Query().Get("noValidation") == "true" + + pi := models.PaymentInitiation{ + ID: models.PaymentInitiationID{ + Reference: payload.Reference, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Reference: payload.Reference, + CreatedAt: time.Now(), + ScheduledAt: payload.ScheduledAt, + Description: payload.Description, + Type: models.MustPaymentInitiationTypeFromString(payload.Type), + Amount: payload.Amount, + Asset: payload.Asset, + Metadata: payload.Metadata, + } + + if payload.SourceAccountID != nil { + pi.SourceAccountID = pointer.For(models.MustAccountIDFromString(*payload.SourceAccountID)) + } + + if payload.DestinationAccountID != nil { + pi.DestinationAccountID = pointer.For(models.MustAccountIDFromString(*payload.DestinationAccountID)) + } + + task, err := backend.PaymentInitiationsCreate(ctx, pi, noValidation, false) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Accepted(w, PaymentInitiationsCreateResponse{ + PaymentInitiationID: pi.ID.String(), + TaskID: task.ID.String(), + }) + } +} + +func populateSpanFromPaymentInitiationCreateRequest(span trace.Span, req PaymentInitiationsCreateRequest) { + span.SetAttributes(attribute.String("reference", req.Reference)) + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + span.SetAttributes(attribute.String("scheduledAt", req.ScheduledAt.String())) + span.SetAttributes(attribute.String("description", req.Description)) + span.SetAttributes(attribute.String("type", req.Type)) + span.SetAttributes(attribute.String("amount", req.Amount.String())) + span.SetAttributes(attribute.String("asset", req.Asset)) + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } + if req.SourceAccountID != nil { + span.SetAttributes(attribute.String("sourceAccountID", *req.SourceAccountID)) + } + if req.DestinationAccountID != nil { + span.SetAttributes(attribute.String("destinationAccountID", *req.DestinationAccountID)) + } +} diff --git a/internal/api/v3/handler_payment_initiations_create_test.go b/internal/api/v3/handler_payment_initiations_create_test.go new file mode 100644 index 00000000..2e3f73d6 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_create_test.go @@ -0,0 +1,102 @@ +package v3 + +import ( + "errors" + "math/big" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Creation", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + source models.AccountID + dest models.AccountID + sourceID string + destID string + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + source = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + dest = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + sourceID = source.String() + destID = dest.String() + }) + + Context("create payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + picr PaymentInitiationsCreateRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(r PaymentInitiationsCreateRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &r)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", PaymentInitiationsCreateRequest{}), + Entry("type missing", PaymentInitiationsCreateRequest{Reference: "type", SourceAccountID: &sourceID, DestinationAccountID: &destID}), + Entry("amount missing", PaymentInitiationsCreateRequest{Reference: "amount", SourceAccountID: &sourceID, DestinationAccountID: &destID, Type: "TRANSFER"}), + Entry("asset missing", PaymentInitiationsCreateRequest{Reference: "asset", SourceAccountID: &sourceID, DestinationAccountID: &destID, Type: "TRANSFER", Amount: big.NewInt(1313)}), + Entry("connectorID missing", PaymentInitiationsCreateRequest{Reference: "connector", SourceAccountID: &sourceID, DestinationAccountID: &destID, Type: "TRANSFER", Amount: big.NewInt(1717), Asset: "USD"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation create err") + m.EXPECT().PaymentInitiationsCreate(gomock.Any(), gomock.Any(), false, false).Return( + models.Task{}, + expectedErr, + ) + picr = PaymentInitiationsCreateRequest{ + Reference: "ref-err", + ConnectorID: connID.String(), + SourceAccountID: &sourceID, + DestinationAccountID: &destID, + Type: "TRANSFER", + Amount: big.NewInt(144), + Asset: "EUR", + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &picr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsCreate(gomock.Any(), gomock.Any(), false, false).Return( + models.Task{}, + nil, + ) + picr = PaymentInitiationsCreateRequest{ + Reference: "ref-ok", + ConnectorID: connID.String(), + SourceAccountID: &sourceID, + DestinationAccountID: &destID, + Type: "TRANSFER", + Amount: big.NewInt(2144), + Asset: "EUR", + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &picr)) + assertExpectedResponse(w.Result(), http.StatusAccepted, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_delete.go b/internal/api/v3/handler_payment_initiations_delete.go new file mode 100644 index 00000000..19f29bfe --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_delete.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationsDelete(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsDelete") + defer span.End() + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PaymentInitiationsDelete(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_payment_initiations_delete_test.go b/internal/api/v3/handler_payment_initiations_delete_test.go new file mode 100644 index 00000000..135c6c2b --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_delete_test.go @@ -0,0 +1,57 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Deletion", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("delete payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsDelete(m) + }) + + It("should return a bad request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation delete err") + m.EXPECT().PaymentInitiationsDelete(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsDelete(gomock.Any(), paymentID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_get.go b/internal/api/v3/handler_payment_initiations_get.go new file mode 100644 index 00000000..f867068b --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_get.go @@ -0,0 +1,48 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsGet") + defer span.End() + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + paymentInitiation, err := backend.PaymentInitiationsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + lastAdjustment, err := backend.PaymentInitiationAdjustmentsGetLast(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + res := models.PaymentInitiationExpanded{ + PaymentInitiation: *paymentInitiation, + Status: lastAdjustment.Status, + Error: lastAdjustment.Error, + } + + api.Ok(w, res) + } +} diff --git a/internal/api/v3/handler_payment_initiations_get_test.go b/internal/api/v3/handler_payment_initiations_get_test.go new file mode 100644 index 00000000..016b59bc --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_get_test.go @@ -0,0 +1,81 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Get", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("get payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsGet(m) + }) + + It("should return a bad request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation get err") + m.EXPECT().PaymentInitiationsGet(gomock.Any(), gomock.Any()).Return( + &models.PaymentInitiation{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return an internal server error when backend returns error finding payment adjustment", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation get adjustment err") + m.EXPECT().PaymentInitiationsGet(gomock.Any(), gomock.Any()).Return( + &models.PaymentInitiation{}, + nil, + ) + m.EXPECT().PaymentInitiationAdjustmentsGetLast(gomock.Any(), paymentID).Return( + &models.PaymentInitiationAdjustment{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status ok on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsGet(gomock.Any(), paymentID).Return( + &models.PaymentInitiation{}, + nil, + ) + m.EXPECT().PaymentInitiationAdjustmentsGetLast(gomock.Any(), paymentID).Return( + &models.PaymentInitiationAdjustment{}, + nil, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_list.go b/internal/api/v3/handler_payment_initiations_list.go new file mode 100644 index 00000000..96a695b5 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_list.go @@ -0,0 +1,64 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func paymentInitiationsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPaymentInitiationsQuery](r, func() (*storage.ListPaymentInitiationsQuery, error) { + options, err := getPagination(span, r, storage.PaymentInitiationQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPaymentInitiationsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.PaymentInitiationsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + pis := make([]models.PaymentInitiationExpanded, 0, len(cursor.Data)) + for _, pi := range cursor.Data { + lastAdjustment, err := backend.PaymentInitiationAdjustmentsGetLast(ctx, pi.ID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + pis = append(pis, models.PaymentInitiationExpanded{ + PaymentInitiation: pi, + Status: lastAdjustment.Status, + Error: lastAdjustment.Error, + }) + } + + api.RenderCursor(w, bunpaginate.Cursor[models.PaymentInitiationExpanded]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pis, + }) + } +} diff --git a/internal/api/v3/handler_payment_initiations_list_test.go b/internal/api/v3/handler_payment_initiations_list_test.go new file mode 100644 index 00000000..b59082b7 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 PaymentInitiations List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list paymentInitiations", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentInitiationsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.PaymentInitiation]{}, fmt.Errorf("paymentInitiations list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentInitiationsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.PaymentInitiation]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_reject.go b/internal/api/v3/handler_payment_initiations_reject.go new file mode 100644 index 00000000..70306f30 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_reject.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationsReject(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsReject") + defer span.End() + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PaymentInitiationsReject(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_payment_initiations_reject_test.go b/internal/api/v3/handler_payment_initiations_reject_test.go new file mode 100644 index 00000000..c594fcf6 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_reject_test.go @@ -0,0 +1,57 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Rejection", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("reject payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsReject(m) + }) + + It("should return a bad request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation reject err") + m.EXPECT().PaymentInitiationsReject(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsReject(gomock.Any(), paymentID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_retry.go b/internal/api/v3/handler_payment_initiations_retry.go new file mode 100644 index 00000000..11d203c3 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_retry.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentInitiationsRetry(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsRetry") + defer span.End() + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + task, err := backend.PaymentInitiationsRetry(ctx, id, false) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Accepted(w, task) + } +} diff --git a/internal/api/v3/handler_payment_initiations_retry_test.go b/internal/api/v3/handler_payment_initiations_retry_test.go new file mode 100644 index 00000000..dd269040 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_retry_test.go @@ -0,0 +1,63 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payment Initiation Retry", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("retry payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsRetry(m) + }) + + It("should return a bad request error when paymentInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation retry err") + m.EXPECT().PaymentInitiationsRetry(gomock.Any(), gomock.Any(), false).Return( + models.Task{}, + expectedErr, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status accepted on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationsRetry(gomock.Any(), paymentID, false).Return( + models.Task{}, + nil, + ) + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + assertExpectedResponse(w.Result(), http.StatusAccepted, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_payment_initiations_reverse.go b/internal/api/v3/handler_payment_initiations_reverse.go new file mode 100644 index 00000000..50d921ca --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_reverse.go @@ -0,0 +1,118 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type PaymentInitiationsReverseRequest struct { + Reference string `json:"reference"` + Description string `json:"description"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` +} + +func (r *PaymentInitiationsReverseRequest) Validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Asset == "" { + return errors.New("asset is required") + } + + return nil +} + +type PaymentInitiationsReverseResponse struct { + PaymentInitiationReversalID string `json:"paymentInitiationReversalID"` + TaskID string `json:"taskID"` +} + +func paymentInitiationsReverse(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentInitiationsReverse") + defer span.End() + + span.SetAttributes(attribute.String("paymentInitiationID", paymentInitiationID(r))) + id, err := models.PaymentInitiationIDFromString(paymentInitiationID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + payload := PaymentInitiationsReverseRequest{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromPaymentInitiationsReverseRequest(span, payload) + + if err := payload.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + reversalID := models.PaymentInitiationReversalID{ + Reference: payload.Reference, + ConnectorID: id.ConnectorID, + } + task, err := backend.PaymentInitiationReversalsCreate(ctx, models.PaymentInitiationReversal{ + ID: reversalID, + ConnectorID: id.ConnectorID, + PaymentInitiationID: id, + Reference: payload.Reference, + CreatedAt: time.Now(), + Description: payload.Description, + Amount: payload.Amount, + Asset: payload.Asset, + Metadata: payload.Metadata, + }, false) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Accepted(w, PaymentInitiationsReverseResponse{ + PaymentInitiationReversalID: reversalID.String(), + TaskID: task.ID.String(), + }) + } +} + +func populateSpanFromPaymentInitiationsReverseRequest(span trace.Span, r PaymentInitiationsReverseRequest) { + span.SetAttributes( + attribute.String("reference", r.Reference), + attribute.String("description", r.Description), + attribute.String("asset", r.Asset), + ) + + if r.Amount != nil { + span.SetAttributes(attribute.String("amount", r.Amount.String())) + } + + for k, v := range r.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } +} diff --git a/internal/api/v3/handler_payment_initiations_reverse_test.go b/internal/api/v3/handler_payment_initiations_reverse_test.go new file mode 100644 index 00000000..10944511 --- /dev/null +++ b/internal/api/v3/handler_payment_initiations_reverse_test.go @@ -0,0 +1,90 @@ +package v3 + +import ( + "errors" + "math/big" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v2 Payment Initiation Reverse", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentInitiationID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + paymentID = models.PaymentInitiationID{Reference: "ref", ConnectorID: connID} + }) + + Context("retry payment initiation", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentInitiationsReverse(m) + + _ = paymentID + }) + + It("should return a bad request error when transferInitiationID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodPost, "paymentInitiationID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + handlerFn(w, prepareQueryRequest(http.MethodGet, "paymentInitiationID", paymentID.String())) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(r PaymentInitiationsReverseRequest) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "paymentInitiationID", paymentID.String(), &r)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", PaymentInitiationsReverseRequest{}), + Entry("amount missing", PaymentInitiationsReverseRequest{Reference: "amount"}), + Entry("asset missing", PaymentInitiationsReverseRequest{Reference: "asset", Amount: big.NewInt(1313)}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation reverse err") + m.EXPECT().PaymentInitiationReversalsCreate(gomock.Any(), gomock.Any(), false).Return( + models.Task{}, + expectedErr, + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "paymentInitiationID", paymentID.String(), &PaymentInitiationsReverseRequest{ + Reference: "ref", + Amount: big.NewInt(1313), + Asset: "USD", + })) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PaymentInitiationReversalsCreate(gomock.Any(), gomock.Any(), false).Return( + models.Task{}, + nil, + ) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPost, "paymentInitiationID", paymentID.String(), &PaymentInitiationsReverseRequest{ + Reference: "ref", + Amount: big.NewInt(1313), + Asset: "USD", + })) + assertExpectedResponse(w.Result(), http.StatusAccepted, "") + }) + }) +}) diff --git a/internal/api/v3/handler_payments_create.go b/internal/api/v3/handler_payments_create.go new file mode 100644 index 00000000..793121b8 --- /dev/null +++ b/internal/api/v3/handler_payments_create.go @@ -0,0 +1,259 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type CreatePaymentRequest struct { + Reference string `json:"reference"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + Type string `json:"type"` + InitialAmount *big.Int `json:"initialAmount"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Scheme string `json:"scheme"` + SourceAccountID *string `json:"sourceAccountID"` + DestinationAccountID *string `json:"destinationAccountID"` + Metadata map[string]string `json:"metadata"` + Adjustments []CreatePaymentsAdjustmentsRequest `json:"adjustments"` +} + +type CreatePaymentsAdjustmentsRequest struct { + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` + Amount *big.Int `json:"amount"` + Asset *string `json:"asset"` + Metadata map[string]string `json:"metadata"` +} + +func (r *CreatePaymentRequest) validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.ConnectorID == "" { + return errors.New("connectorID is required") + } + + if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { + return errors.New("createdAt is empty or in the future") + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Type == "" { + return errors.New("type is required") + } + + if _, err := models.PaymentTypeFromString(r.Type); err != nil { + return fmt.Errorf("invalid type: %w", err) + } + + if r.Scheme == "" { + return errors.New("scheme is required") + } + + if _, err := models.PaymentSchemeFromString(r.Scheme); err != nil { + return fmt.Errorf("invalid scheme: %w", err) + } + + if r.Asset == "" { + return errors.New("asset is required") + } + + if r.SourceAccountID != nil { + _, err := models.AccountIDFromString(*r.SourceAccountID) + if err != nil { + return fmt.Errorf("invalid sourceAccountID: %w", err) + } + } + + if r.DestinationAccountID != nil { + _, err := models.AccountIDFromString(*r.DestinationAccountID) + if err != nil { + return fmt.Errorf("invalid destinationAccountID: %w", err) + } + } + + if len(r.Adjustments) == 0 { + return errors.New("adjustments is required") + } + + for i, adj := range r.Adjustments { + if err := adj.validate(); err != nil { + return fmt.Errorf("adjustment %d: %w", i, err) + } + } + + return nil +} + +func (r *CreatePaymentsAdjustmentsRequest) validate() error { + if r.Reference == "" { + return errors.New("reference is required") + } + + if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) { + return errors.New("createdAt is empty or in the future") + } + + if r.Amount == nil { + return errors.New("amount is required") + } + + if r.Asset == nil { + return errors.New("asset is required") + } + + if r.Status == "" { + return errors.New("status is required") + } + + if _, err := models.PaymentStatusFromString(r.Status); err != nil { + return fmt.Errorf("invalid status: %w", err) + } + + return nil +} + +func paymentsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v2_paymentsCreate") + defer span.End() + + var req CreatePaymentRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromPaymentCreateRequest(span, req) + + if err := req.validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + connectorID := models.MustConnectorIDFromString(req.ConnectorID) + paymentType := models.MustPaymentTypeFromString(req.Type) + pid := models.PaymentID{ + PaymentReference: models.PaymentReference{ + Reference: req.Reference, + Type: paymentType, + }, + ConnectorID: connectorID, + } + + payment := models.Payment{ + ID: pid, + ConnectorID: connectorID, + Reference: req.Reference, + CreatedAt: req.CreatedAt.UTC(), + Type: paymentType, + InitialAmount: req.InitialAmount, + Amount: req.Amount, + Asset: req.Asset, + Scheme: models.MustPaymentSchemeFromString(req.Scheme), + SourceAccountID: func() *models.AccountID { + if req.SourceAccountID == nil { + return nil + } + return pointer.For(models.MustAccountIDFromString(*req.SourceAccountID)) + }(), + DestinationAccountID: func() *models.AccountID { + if req.DestinationAccountID == nil { + return nil + } + return pointer.For(models.MustAccountIDFromString(*req.DestinationAccountID)) + }(), + Metadata: req.Metadata, + } + + for _, adj := range req.Adjustments { + raw, err := json.Marshal(adj) + if err != nil { + otel.RecordError(span, err) + api.InternalServerError(w, r, err) + return + } + status := models.MustPaymentStatusFromString(adj.Status) + + payment.Adjustments = append(payment.Adjustments, models.PaymentAdjustment{ + ID: models.PaymentAdjustmentID{ + PaymentID: pid, + Reference: adj.Reference, + CreatedAt: adj.CreatedAt.UTC(), + Status: status, + }, + PaymentID: pid, + Reference: adj.Reference, + CreatedAt: adj.CreatedAt, + Status: status, + Amount: adj.Amount, + Asset: adj.Asset, + Metadata: adj.Metadata, + Raw: raw, + }) + } + + err = backend.PaymentsCreate(ctx, payment) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Created(w, payment) + } +} + +func populateSpanFromPaymentCreateRequest(span trace.Span, req CreatePaymentRequest) { + span.SetAttributes(attribute.String("reference", req.Reference)) + span.SetAttributes(attribute.String("connectorID", req.ConnectorID)) + span.SetAttributes(attribute.String("createdAt", req.CreatedAt.String())) + span.SetAttributes(attribute.String("type", req.Type)) + span.SetAttributes(attribute.String("initialAmount", req.InitialAmount.String())) + span.SetAttributes(attribute.String("amount", req.Amount.String())) + span.SetAttributes(attribute.String("asset", req.Asset)) + span.SetAttributes(attribute.String("scheme", req.Scheme)) + if req.SourceAccountID != nil { + span.SetAttributes(attribute.String("sourceAccountID", *req.SourceAccountID)) + } + if req.DestinationAccountID != nil { + span.SetAttributes(attribute.String("destinationAccountID", *req.DestinationAccountID)) + } + for k, v := range req.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("metadata[%s]", k), v)) + } + + for i, adj := range req.Adjustments { + span.SetAttributes(attribute.String(fmt.Sprintf("adjustments[%d].reference", i), adj.Reference)) + span.SetAttributes(attribute.String(fmt.Sprintf("adjustments[%d].createdAt", i), adj.CreatedAt.String())) + span.SetAttributes(attribute.String(fmt.Sprintf("adjustments[%d].status", i), adj.Status)) + span.SetAttributes(attribute.String(fmt.Sprintf("adjustments[%d].amount", i), adj.Amount.String())) + span.SetAttributes(attribute.String(fmt.Sprintf("adjustments[%d].asset", i), *adj.Asset)) + for k, v := range adj.Metadata { + span.SetAttributes(attribute.String(fmt.Sprintf("adjustments[%d].metadata[%s]", i, k), v)) + } + } +} diff --git a/internal/api/v3/handler_payments_create_test.go b/internal/api/v3/handler_payments_create_test.go new file mode 100644 index 00000000..cc2a6127 --- /dev/null +++ b/internal/api/v3/handler_payments_create_test.go @@ -0,0 +1,108 @@ +package v3 + +import ( + "errors" + "math/big" + "net/http" + "net/http/httptest" + "time" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payments Create", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("create payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + cpr CreatePaymentRequest + adj []CreatePaymentsAdjustmentsRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsCreate(m) + + asset := "JPY" + adj = []CreatePaymentsAdjustmentsRequest{ + { + Reference: "ref_adjustment", + CreatedAt: time.Now(), + Amount: big.NewInt(55), + Asset: &asset, + Status: models.PAYMENT_STATUS_PENDING.String(), + }, + } + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(cpr CreatePaymentRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("reference missing", CreatePaymentRequest{}), + Entry("connectorID missing", CreatePaymentRequest{Reference: "ref"}), + Entry("createdAt missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id"}), + Entry("amount missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now()}), + Entry("payment type missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467)}), + Entry("payment type invalid", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "invalid"}), + Entry("scheme missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT"}), + Entry("scheme invalid", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT", Scheme: "invalid"}), + Entry("asset missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT", Scheme: "CARD_VISA"}), + Entry("adjustments missing", CreatePaymentRequest{Reference: "ref", ConnectorID: "id", CreatedAt: time.Now(), Amount: big.NewInt(4467), Type: "PAYOUT", Scheme: "CARD_VISA", Asset: "CAD"}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment create err") + m.EXPECT().PaymentsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + cpr = CreatePaymentRequest{ + Reference: "reference-err", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + Amount: big.NewInt(3500), + Asset: "JPY", + Type: models.PAYMENT_TYPE_PAYIN.String(), + Scheme: models.PAYMENT_SCHEME_CARD_AMEX.String(), + Adjustments: adj, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status created on success", func(ctx SpecContext) { + m.EXPECT().PaymentsCreate(gomock.Any(), gomock.Any()).Return(nil) + cpr = CreatePaymentRequest{ + Reference: "reference-ok", + ConnectorID: connID.String(), + CreatedAt: time.Now(), + Amount: big.NewInt(3500), + Asset: "JPY", + Type: models.PAYMENT_TYPE_PAYIN.String(), + Scheme: models.PAYMENT_SCHEME_CARD_AMEX.String(), + Adjustments: adj, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusCreated, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_payments_get.go b/internal/api/v3/handler_payments_get.go new file mode 100644 index 00000000..db8e894f --- /dev/null +++ b/internal/api/v3/handler_payments_get.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func paymentsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentsGet") + defer span.End() + + span.SetAttributes(attribute.String("paymentID", paymentID(r))) + id, err := models.PaymentIDFromString(paymentID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + payment, err := backend.PaymentsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, payment) + } +} diff --git a/internal/api/v3/handler_payments_get_test.go b/internal/api/v3/handler_payments_get_test.go new file mode 100644 index 00000000..94912a9c --- /dev/null +++ b/internal/api/v3/handler_payments_get_test.go @@ -0,0 +1,65 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payments", func() { + var ( + handlerFn http.HandlerFunc + payID models.PaymentID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + payRef := models.PaymentReference{Reference: "ref", Type: models.PAYMENT_TYPE_TRANSFER} + payID = models.PaymentID{PaymentReference: payRef, ConnectorID: connID} + }) + + Context("get payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsGet(m) + }) + + It("should return an invalid ID error when payment ID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentID", payID.String()) + m.EXPECT().PaymentsGet(gomock.Any(), payID).Return( + &models.Payment{}, fmt.Errorf("payments get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "paymentID", payID.String()) + m.EXPECT().PaymentsGet(gomock.Any(), payID).Return( + &models.Payment{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_payments_list.go b/internal/api/v3/handler_payments_list.go new file mode 100644 index 00000000..2b84e492 --- /dev/null +++ b/internal/api/v3/handler_payments_list.go @@ -0,0 +1,41 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func paymentsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPaymentsQuery](r, func() (*storage.ListPaymentsQuery, error) { + options, err := getPagination(span, r, storage.PaymentQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPaymentsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.PaymentsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_payments_list_test.go b/internal/api/v3/handler_payments_list_test.go new file mode 100644 index 00000000..8e5bf28f --- /dev/null +++ b/internal/api/v3/handler_payments_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payments List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list payments", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Payment]{}, fmt.Errorf("payments list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PaymentsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Payment]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_payments_update_metadata.go b/internal/api/v3/handler_payments_update_metadata.go new file mode 100644 index 00000000..3092b3cf --- /dev/null +++ b/internal/api/v3/handler_payments_update_metadata.go @@ -0,0 +1,60 @@ +package v3 + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func validatePaymentsMetadata(metadata map[string]string) error { + if len(metadata) == 0 { + return errors.New("metadata must be provided") + } + return nil +} + +func paymentsUpdateMetadata(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_paymentsUpdateMetadata") + defer span.End() + + span.SetAttributes(attribute.String("paymentID", paymentID(r))) + id, err := models.PaymentIDFromString(paymentID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + var metadata map[string]string + err = json.NewDecoder(r.Body).Decode(&metadata) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + populateSpanFromUpdateMetadataRequest(span, metadata) + + if err := validatePaymentsMetadata(metadata); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + err = backend.PaymentsUpdateMetadata(ctx, id, metadata) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_payments_update_metadata_test.go b/internal/api/v3/handler_payments_update_metadata_test.go new file mode 100644 index 00000000..6f24bc74 --- /dev/null +++ b/internal/api/v3/handler_payments_update_metadata_test.go @@ -0,0 +1,68 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Payments Update Metadata", func() { + var ( + handlerFn http.HandlerFunc + paymentID models.PaymentID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + payRef := models.PaymentReference{Reference: "ref", Type: models.PAYMENT_TYPE_TRANSFER} + paymentID = models.PaymentID{PaymentReference: payRef, ConnectorID: connID} + }) + + Context("update payment metadata", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = paymentsUpdateMetadata(m) + }) + + It("should return a bad request error when paymentID is invalid", func(ctx SpecContext) { + payload := map[string]string{} + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", "invalid", &payload)) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + DescribeTable("validation errors", + func(payload map[string]string) { + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", paymentID.String(), &payload)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("metadata missing", nil), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment update metadata err") + m.EXPECT().PaymentsUpdateMetadata(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + metadata := map[string]string{"meta": "data"} + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", paymentID.String(), &metadata)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + metadata := map[string]string{"meta": "data"} + m.EXPECT().PaymentsUpdateMetadata(gomock.Any(), gomock.Any(), metadata).Return(nil) + handlerFn(w, prepareJSONRequestWithQuery(http.MethodPatch, "paymentID", paymentID.String(), &metadata)) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_add_account.go b/internal/api/v3/handler_pools_add_account.go new file mode 100644 index 00000000..255a3ee5 --- /dev/null +++ b/internal/api/v3/handler_pools_add_account.go @@ -0,0 +1,44 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsAddAccount(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsAddAccount") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + span.SetAttributes(attribute.String("accountID", accountID(r))) + accountID, err := models.AccountIDFromString(accountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PoolsAddAccount(ctx, id, accountID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_pools_add_account_test.go b/internal/api/v3/handler_pools_add_account_test.go new file mode 100644 index 00000000..33ae39bb --- /dev/null +++ b/internal/api/v3/handler_pools_add_account_test.go @@ -0,0 +1,66 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 pools add account", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + poolID uuid.UUID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + poolID = uuid.New() + }) + + Context("pool add account", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsAddAccount(m) + }) + + It("should return a bad request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a bad request error when accountID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("pool add account err") + m.EXPECT().PoolsAddAccount(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", accID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PoolsAddAccount(gomock.Any(), poolID, accID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", accID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_balances_at.go b/internal/api/v3/handler_pools_balances_at.go new file mode 100644 index 00000000..dd0eff6b --- /dev/null +++ b/internal/api/v3/handler_pools_balances_at.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "github.com/pkg/errors" + "go.opentelemetry.io/otel/attribute" +) + +func poolsBalancesAt(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsBalancesAt") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + atTime := r.URL.Query().Get("at") + span.SetAttributes(attribute.String("atTime", atTime)) + if atTime == "" { + otel.RecordError(span, errors.New("missing atTime")) + api.BadRequest(w, ErrValidation, errors.New("missing atTime")) + return + } + + at, err := time.Parse(time.RFC3339, atTime) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, errors.Wrap(err, "invalid atTime")) + return + } + + balances, err := backend.PoolsBalancesAt(ctx, id, at) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, balances) + } +} diff --git a/internal/api/v3/handler_pools_balances_at_test.go b/internal/api/v3/handler_pools_balances_at_test.go new file mode 100644 index 00000000..6cb67318 --- /dev/null +++ b/internal/api/v3/handler_pools_balances_at_test.go @@ -0,0 +1,77 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Pools Balances At", func() { + var ( + handlerFn http.HandlerFunc + poolID uuid.UUID + now time.Time + ) + BeforeEach(func() { + poolID = uuid.New() + now = time.Now().UTC().Truncate(time.Second) + }) + + Context("pools balances at", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsBalancesAt(m) + }) + + It("should return a validation request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequestWithPath("/", "poolID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a validation request error when at param is missing", func(ctx SpecContext) { + req := prepareQueryRequestWithPath("/", "poolID", poolID.String()) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + path := fmt.Sprintf("/?at=%s", now.Format(time.RFC3339)) + req := prepareQueryRequestWithPath(path, "poolID", poolID.String()) + m.EXPECT().PoolsBalancesAt(gomock.Any(), gomock.Any(), gomock.Any()).Return( + []models.AggregatedBalance{}, + fmt.Errorf("balances list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a data object", func(ctx SpecContext) { + path := fmt.Sprintf("/?at=%s", now.Format(time.RFC3339)) + req := prepareQueryRequestWithPath(path, "poolID", poolID.String()) + m.EXPECT().PoolsBalancesAt(gomock.Any(), poolID, now).Return( + []models.AggregatedBalance{}, + nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_create.go b/internal/api/v3/handler_pools_create.go new file mode 100644 index 00000000..f232cba5 --- /dev/null +++ b/internal/api/v3/handler_pools_create.go @@ -0,0 +1,90 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type createPoolRequest struct { + Name string `json:"name"` + AccountIDs []string `json:"accountIDs"` +} + +func (r *createPoolRequest) Validate() error { + if len(r.AccountIDs) == 0 { + return errors.New("one or more account id required") + } + return nil +} + +func poolsCreate(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsCreate") + defer span.End() + + var createPoolRequest createPoolRequest + err := json.NewDecoder(r.Body).Decode(&createPoolRequest) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrMissingOrInvalidBody, err) + return + } + + populateSpanFromCreatePoolRequest(span, createPoolRequest) + + if err := createPoolRequest.Validate(); err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + pool := models.Pool{ + ID: uuid.New(), + Name: createPoolRequest.Name, + CreatedAt: time.Now().UTC(), + } + + accounts := make([]models.PoolAccounts, len(createPoolRequest.AccountIDs)) + for i, accountID := range createPoolRequest.AccountIDs { + aID, err := models.AccountIDFromString(accountID) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + accounts[i] = models.PoolAccounts{ + PoolID: pool.ID, + AccountID: aID, + } + } + pool.PoolAccounts = accounts + + err = backend.PoolsCreate(ctx, pool) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Created(w, pool.ID.String()) + } +} + +func populateSpanFromCreatePoolRequest(span trace.Span, req createPoolRequest) { + span.SetAttributes(attribute.String("name", req.Name)) + for i, acc := range req.AccountIDs { + span.SetAttributes(attribute.String(fmt.Sprintf("accountIDs[%d]", i), acc)) + } +} diff --git a/internal/api/v3/handler_pools_create_test.go b/internal/api/v3/handler_pools_create_test.go new file mode 100644 index 00000000..6cb40623 --- /dev/null +++ b/internal/api/v3/handler_pools_create_test.go @@ -0,0 +1,77 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Pools Create", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + accID2 models.AccountID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + accID2 = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + }) + + Context("create pools", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + cpr createPoolRequest + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsCreate(m) + }) + + It("should return a bad request error when body is missing", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrMissingOrInvalidBody) + }) + + DescribeTable("validation errors", + func(cpr createPoolRequest) { + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrValidation) + }, + Entry("accountIDs missing", createPoolRequest{}), + Entry("accountIDs invalid", createPoolRequest{AccountIDs: []string{"invalid"}}), + ) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment create err") + m.EXPECT().PoolsCreate(gomock.Any(), gomock.Any()).Return(expectedErr) + cpr = createPoolRequest{ + Name: "name", + AccountIDs: []string{accID.String()}, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status created on success", func(ctx SpecContext) { + m.EXPECT().PoolsCreate(gomock.Any(), gomock.Any()).Return(nil) + cpr = createPoolRequest{ + Name: "name", + AccountIDs: []string{accID.String(), accID2.String()}, + } + handlerFn(w, prepareJSONRequest(http.MethodPost, &cpr)) + assertExpectedResponse(w.Result(), http.StatusCreated, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_delete.go b/internal/api/v3/handler_pools_delete.go new file mode 100644 index 00000000..e8b81867 --- /dev/null +++ b/internal/api/v3/handler_pools_delete.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsDelete(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsDelete") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PoolsDelete(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_pools_delete_test.go b/internal/api/v3/handler_pools_delete_test.go new file mode 100644 index 00000000..0247bfcb --- /dev/null +++ b/internal/api/v3/handler_pools_delete_test.go @@ -0,0 +1,55 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Pool Deletion", func() { + var ( + handlerFn http.HandlerFunc + poolID uuid.UUID + ) + BeforeEach(func() { + poolID = uuid.New() + }) + + Context("delete pool", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsDelete(m) + }) + + It("should return a bad request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("payment initiation delete err") + m.EXPECT().PoolsDelete(gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PoolsDelete(gomock.Any(), poolID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_get.go b/internal/api/v3/handler_pools_get.go new file mode 100644 index 00000000..ab15f19f --- /dev/null +++ b/internal/api/v3/handler_pools_get.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsGet") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + pool, err := backend.PoolsGet(ctx, id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, pool) + } +} diff --git a/internal/api/v3/handler_pools_get_test.go b/internal/api/v3/handler_pools_get_test.go new file mode 100644 index 00000000..2b2fd601 --- /dev/null +++ b/internal/api/v3/handler_pools_get_test.go @@ -0,0 +1,63 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Get Pool", func() { + var ( + handlerFn http.HandlerFunc + poolID uuid.UUID + ) + BeforeEach(func() { + poolID = uuid.New() + }) + + Context("get pools", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsGet(m) + }) + + It("should return an invalid ID error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String()) + m.EXPECT().PoolsGet(gomock.Any(), poolID).Return( + &models.Pool{}, fmt.Errorf("pool get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String()) + m.EXPECT().PoolsGet(gomock.Any(), poolID).Return( + &models.Pool{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_list.go b/internal/api/v3/handler_pools_list.go new file mode 100644 index 00000000..f132dbcf --- /dev/null +++ b/internal/api/v3/handler_pools_list.go @@ -0,0 +1,41 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func poolsList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListPoolsQuery](r, func() (*storage.ListPoolsQuery, error) { + options, err := getPagination(span, r, storage.PoolQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListPoolsQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.PoolsList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_pools_list_test.go b/internal/api/v3/handler_pools_list_test.go new file mode 100644 index 00000000..40eda250 --- /dev/null +++ b/internal/api/v3/handler_pools_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Pools List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list pools", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PoolsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Pool]{}, fmt.Errorf("pools list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().PoolsList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Pool]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_pools_remove_account.go b/internal/api/v3/handler_pools_remove_account.go new file mode 100644 index 00000000..fd9eaa4f --- /dev/null +++ b/internal/api/v3/handler_pools_remove_account.go @@ -0,0 +1,44 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" +) + +func poolsRemoveAccount(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_poolsRemoveAccount") + defer span.End() + + span.SetAttributes(attribute.String("poolID", poolID(r))) + id, err := uuid.Parse(poolID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + span.SetAttributes(attribute.String("accountID", accountID(r))) + accountID, err := models.AccountIDFromString(accountID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + err = backend.PoolsRemoveAccount(ctx, id, accountID) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.NoContent(w) + } +} diff --git a/internal/api/v3/handler_pools_remove_account_test.go b/internal/api/v3/handler_pools_remove_account_test.go new file mode 100644 index 00000000..49102581 --- /dev/null +++ b/internal/api/v3/handler_pools_remove_account_test.go @@ -0,0 +1,66 @@ +package v3 + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 pools remove account", func() { + var ( + handlerFn http.HandlerFunc + accID models.AccountID + poolID uuid.UUID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + accID = models.AccountID{Reference: uuid.New().String(), ConnectorID: connID} + poolID = uuid.New() + }) + + Context("pool remove account", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = poolsRemoveAccount(m) + }) + + It("should return a bad request error when poolID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return a bad request error when accountID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", "invalid") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + expectedErr := errors.New("pool remove account err") + m.EXPECT().PoolsRemoveAccount(gomock.Any(), gomock.Any(), gomock.Any()).Return(expectedErr) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", accID.String())) + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return status no content on success", func(ctx SpecContext) { + m.EXPECT().PoolsRemoveAccount(gomock.Any(), poolID, accID).Return(nil) + handlerFn(w, prepareQueryRequest(http.MethodGet, "poolID", poolID.String(), "accountID", accID.String())) + assertExpectedResponse(w.Result(), http.StatusNoContent, "") + }) + }) +}) diff --git a/internal/api/v3/handler_schedules_list.go b/internal/api/v3/handler_schedules_list.go new file mode 100644 index 00000000..80f7ff31 --- /dev/null +++ b/internal/api/v3/handler_schedules_list.go @@ -0,0 +1,41 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" +) + +func schedulesList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_schedulesList") + defer span.End() + + query, err := bunpaginate.Extract[storage.ListSchedulesQuery](r, func() (*storage.ListSchedulesQuery, error) { + options, err := getPagination(span, r, storage.ScheduleQuery{}) + if err != nil { + return nil, err + } + return pointer.For(storage.NewListSchedulesQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.SchedulesList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_schedules_list_test.go b/internal/api/v3/handler_schedules_list_test.go new file mode 100644 index 00000000..f0d07d81 --- /dev/null +++ b/internal/api/v3/handler_schedules_list_test.go @@ -0,0 +1,52 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Schedules List", func() { + var ( + handlerFn http.HandlerFunc + ) + + Context("list schedules", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = schedulesList(m) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().SchedulesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Schedule]{}, fmt.Errorf("schedules list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + m.EXPECT().SchedulesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Schedule]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/handler_tasks_get.go b/internal/api/v3/handler_tasks_get.go new file mode 100644 index 00000000..f223575f --- /dev/null +++ b/internal/api/v3/handler_tasks_get.go @@ -0,0 +1,35 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +func tasksGet(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_tasksGet") + defer span.End() + + span.SetAttributes(attribute.String("taskID", taskID(r))) + id, err := models.TaskIDFromString(taskID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + task, err := backend.TaskGet(ctx, *id) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.Ok(w, task) + } +} diff --git a/internal/api/v3/handler_tasks_get_test.go b/internal/api/v3/handler_tasks_get_test.go new file mode 100644 index 00000000..2dca91b6 --- /dev/null +++ b/internal/api/v3/handler_tasks_get_test.go @@ -0,0 +1,64 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 Get Task", func() { + var ( + handlerFn http.HandlerFunc + taskID models.TaskID + ) + BeforeEach(func() { + connID := models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + taskID = models.TaskID{Reference: "ref", ConnectorID: connID} + }) + + Context("get tasks", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = tasksGet(m) + }) + + It("should return an invalid ID error when taskID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "taskID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "taskID", taskID.String()) + m.EXPECT().TaskGet(gomock.Any(), taskID).Return( + &models.Task{}, fmt.Errorf("task get error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return data object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "taskID", taskID.String()) + m.EXPECT().TaskGet(gomock.Any(), taskID).Return( + &models.Task{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "data") + }) + }) +}) diff --git a/internal/api/v3/handler_test.go b/internal/api/v3/handler_test.go new file mode 100644 index 00000000..047bc9eb --- /dev/null +++ b/internal/api/v3/handler_test.go @@ -0,0 +1,73 @@ +package v3 + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestV3Handlers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "API v3 Suite") +} + +func assertExpectedResponse(res *http.Response, expectedStatusCode int, expectedBodyString string) { + defer res.Body.Close() + Expect(res.StatusCode).To(Equal(expectedStatusCode)) + + data, err := ioutil.ReadAll(res.Body) + Expect(err).To(BeNil()) + Expect(data).To(ContainSubstring(expectedBodyString)) +} + +func prepareJSONRequest(method string, a any) *http.Request { + b, err := json.Marshal(a) + Expect(err).To(BeNil()) + body := bytes.NewReader(b) + return httptest.NewRequest(method, "/", body) +} + +func prepareJSONRequestWithQuery(method string, key string, val string, a any) *http.Request { + b, err := json.Marshal(a) + Expect(err).To(BeNil()) + body := bytes.NewReader(b) + return prepareQueryRequestWithBody(method, body, key, val) +} + +func prepareQueryRequest(method string, args ...string) *http.Request { + return prepareQueryRequestWithBody(method, nil, args...) +} + +func prepareQueryRequestWithBody(method string, body io.Reader, args ...string) *http.Request { + req := httptest.NewRequest(method, "/", body) + rctx := chi.NewRouteContext() + appendToRouteContext(rctx, args...) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func prepareQueryRequestWithPath(path string, args ...string) *http.Request { + req := httptest.NewRequest(http.MethodGet, path, nil) + rctx := chi.NewRouteContext() + appendToRouteContext(rctx, args...) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func appendToRouteContext(rctx *chi.Context, args ...string) { + if len(args)%2 != 0 { + log.Fatalf("arguments must be provided in key value pairs: %s", args) + } + for i := 0; i < len(args); i += 2 { + val := args[i+1] + rctx.URLParams.Add(args[i], val) + } +} diff --git a/internal/api/v3/handler_workflows_instances_list.go b/internal/api/v3/handler_workflows_instances_list.go new file mode 100644 index 00000000..3d1b275f --- /dev/null +++ b/internal/api/v3/handler_workflows_instances_list.go @@ -0,0 +1,65 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "go.opentelemetry.io/otel/attribute" +) + +func workflowsInstancesList(backend backend.Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer().Start(r.Context(), "v3_workflowsInstancesList") + defer span.End() + + span.SetAttributes(attribute.String("connectorID", connectorID(r))) + connectorID, err := models.ConnectorIDFromString(connectorID(r)) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrInvalidID, err) + return + } + + span.SetAttributes(attribute.String("scheduleID", scheduleID(r))) + scheduleID := scheduleID(r) + + query, err := bunpaginate.Extract[storage.ListInstancesQuery](r, func() (*storage.ListInstancesQuery, error) { + pageSize, err := bunpaginate.GetPageSize(r) + if err != nil { + return nil, err + } + span.SetAttributes(attribute.Int64("pageSize", int64(pageSize))) + + options := pointer.For(bunpaginate.NewPaginatedQueryOptions(storage.InstanceQuery{}).WithPageSize(pageSize)) + options = pointer.For(options.WithQueryBuilder( + query.And( + query.Match("connector_id", connectorID), + query.Match("schedule_id", scheduleID), + ), + )) + + return pointer.For(storage.NewListInstancesQuery(*options)), nil + }) + if err != nil { + otel.RecordError(span, err) + api.BadRequest(w, ErrValidation, err) + return + } + + cursor, err := backend.WorkflowsInstancesList(ctx, *query) + if err != nil { + otel.RecordError(span, err) + handleServiceErrors(w, r, err) + return + } + + api.RenderCursor(w, *cursor) + } +} diff --git a/internal/api/v3/handler_workflows_instances_list_test.go b/internal/api/v3/handler_workflows_instances_list_test.go new file mode 100644 index 00000000..905d02f3 --- /dev/null +++ b/internal/api/v3/handler_workflows_instances_list_test.go @@ -0,0 +1,64 @@ +package v3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/api/backend" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "go.uber.org/mock/gomock" +) + +var _ = Describe("API v3 WorkflowInstances List", func() { + var ( + handlerFn http.HandlerFunc + connID models.ConnectorID + ) + BeforeEach(func() { + connID = models.ConnectorID{Reference: uuid.New(), Provider: "psp"} + }) + + Context("list workflowsInstances", func() { + var ( + w *httptest.ResponseRecorder + m *backend.MockBackend + ) + BeforeEach(func() { + w = httptest.NewRecorder() + ctrl := gomock.NewController(GinkgoT()) + m = backend.NewMockBackend(ctrl) + handlerFn = workflowsInstancesList(m) + }) + + It("should return an invalid ID error when connectorID is invalid", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", "invalidvalue") + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusBadRequest, ErrInvalidID) + }) + + It("should return an internal server error when backend returns error", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String()) + m.EXPECT().WorkflowsInstancesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Instance]{}, fmt.Errorf("workflowsInstances list error"), + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusInternalServerError, "INTERNAL") + }) + + It("should return a cursor object", func(ctx SpecContext) { + req := prepareQueryRequest(http.MethodGet, "connectorID", connID.String()) + m.EXPECT().WorkflowsInstancesList(gomock.Any(), gomock.Any()).Return( + &bunpaginate.Cursor[models.Instance]{}, nil, + ) + handlerFn(w, req) + + assertExpectedResponse(w.Result(), http.StatusOK, "cursor") + }) + }) +}) diff --git a/internal/api/v3/module.go b/internal/api/v3/module.go new file mode 100644 index 00000000..83a095e3 --- /dev/null +++ b/internal/api/v3/module.go @@ -0,0 +1,15 @@ +package v3 + +import ( + "github.com/formancehq/payments/internal/api" + "go.uber.org/fx" +) + +func NewModule() fx.Option { + return fx.Options( + fx.Supply(fx.Annotate(api.Version{ + Version: 3, + Builder: newRouter, + }, api.TagVersion())), + ) +} diff --git a/internal/api/v3/router.go b/internal/api/v3/router.go new file mode 100644 index 00000000..f87dd6c6 --- /dev/null +++ b/internal/api/v3/router.go @@ -0,0 +1,163 @@ +package v3 + +import ( + "net/http" + + "github.com/formancehq/go-libs/v2/auth" + "github.com/formancehq/go-libs/v2/service" + "github.com/formancehq/payments/internal/api/backend" + "github.com/go-chi/chi/v5" +) + +func newRouter(backend backend.Backend, a auth.Authenticator, debug bool) *chi.Mux { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(service.OTLPMiddleware("payments", debug)) + + // Public routes + r.Group(func(r chi.Router) { + r.Handle("/connectors/webhooks/{connectorID}/*", connectorsWebhooks(backend)) + }) + + // Authenticated routes + r.Group(func(r chi.Router) { + r.Use(auth.Middleware(a)) + + // Accounts + r.Route("/accounts", func(r chi.Router) { + r.Get("/", accountsList(backend)) + r.Post("/", accountsCreate(backend)) + + r.Route("/{accountID}", func(r chi.Router) { + r.Get("/", accountsGet(backend)) + r.Get("/balances", accountsBalances(backend)) + }) + }) + + // Bank Accounts + r.Route("/bank-accounts", func(r chi.Router) { + r.Post("/", bankAccountsCreate(backend)) + r.Get("/", bankAccountsList(backend)) + + r.Route("/{bankAccountID}", func(r chi.Router) { + r.Get("/", bankAccountsGet(backend)) + r.Patch("/metadata", bankAccountsUpdateMetadata(backend)) + r.Post("/forward", bankAccountsForwardToConnector(backend)) + }) + }) + + // Payments + r.Route("/payments", func(r chi.Router) { + r.Get("/", paymentsList(backend)) + r.Post("/", paymentsCreate(backend)) + + r.Route("/{paymentID}", func(r chi.Router) { + r.Get("/", paymentsGet(backend)) + r.Patch("/metadata", paymentsUpdateMetadata(backend)) + }) + }) + + // Pools + r.Route("/pools", func(r chi.Router) { + r.Post("/", poolsCreate(backend)) + r.Get("/", poolsList(backend)) + + r.Route("/{poolID}", func(r chi.Router) { + r.Get("/", poolsGet(backend)) + r.Delete("/", poolsDelete(backend)) + r.Get("/balances", poolsBalancesAt(backend)) + + r.Route("/accounts/{accountID}", func(r chi.Router) { + r.Post("/", poolsAddAccount(backend)) + r.Delete("/", poolsRemoveAccount(backend)) + }) + }) + }) + + // Connectors + r.Route("/connectors", func(r chi.Router) { + r.Get("/", connectorsList(backend)) + r.Post("/install/{connector}", connectorsInstall(backend)) + + r.Get("/configs", connectorsConfigs(backend)) + + r.Route("/{connectorID}", func(r chi.Router) { + r.Delete("/", connectorsUninstall(backend)) + r.Get("/config", connectorsConfig(backend)) + r.Post("/reset", connectorsReset(backend)) + + r.Get("/schedules", schedulesList(backend)) + r.Route("/schedules/{scheduleID}", func(r chi.Router) { + r.Get("/instances", workflowsInstancesList(backend)) + }) + // TODO(polo): add update config handler + }) + }) + + // Tasks + r.Route("/tasks", func(r chi.Router) { + r.Route("/{taskID}", func(r chi.Router) { + r.Get("/", tasksGet(backend)) + }) + }) + + // Payment Initiations + r.Route("/payment-initiations", func(r chi.Router) { + r.Post("/", paymentInitiationsCreate(backend)) + r.Get("/", paymentInitiationsList(backend)) + + r.Route("/{paymentInitiationID}", func(r chi.Router) { + r.Delete("/", paymentInitiationsDelete(backend)) + r.Get("/", paymentInitiationsGet(backend)) + r.Post("/retry", paymentInitiationsRetry(backend)) + r.Post("/approve", paymentInitiationsApprove(backend)) + r.Post("/reject", paymentInitiationsReject(backend)) + r.Post("/reverse", paymentInitiationsReverse(backend)) + + r.Get("/adjustments", paymentInitiationAdjustmentsList(backend)) + r.Get("/payments", paymentInitiationPaymentsList(backend)) + }) + + }) + }) + }) + + return r +} + +func connector(r *http.Request) string { + return chi.URLParam(r, "connector") +} + +func connectorID(r *http.Request) string { + return chi.URLParam(r, "connectorID") +} + +func accountID(r *http.Request) string { + return chi.URLParam(r, "accountID") +} + +func paymentID(r *http.Request) string { + return chi.URLParam(r, "paymentID") +} + +func poolID(r *http.Request) string { + return chi.URLParam(r, "poolID") +} + +func bankAccountID(r *http.Request) string { + return chi.URLParam(r, "bankAccountID") +} + +func scheduleID(r *http.Request) string { + return chi.URLParam(r, "scheduleID") +} + +func taskID(r *http.Request) string { + return chi.URLParam(r, "taskID") +} + +func paymentInitiationID(r *http.Request) string { + return chi.URLParam(r, "paymentInitiationID") +} diff --git a/internal/api/v3/utils.go b/internal/api/v3/utils.go new file mode 100644 index 00000000..a7e481ab --- /dev/null +++ b/internal/api/v3/utils.go @@ -0,0 +1,43 @@ +package v3 + +import ( + "io" + "net/http" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +func getQueryBuilder(span trace.Span, r *http.Request) (query.Builder, error) { + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if len(data) > 0 { + span.SetAttributes(attribute.String("query", string(data))) + return query.ParseJSON(string(data)) + } else { + // In order to be backward compatible + span.SetAttributes(attribute.String("query", r.URL.Query().Get("query"))) + return query.ParseJSON(r.URL.Query().Get("query")) + } +} + +func getPagination[T any](span trace.Span, r *http.Request, options T) (*bunpaginate.PaginatedQueryOptions[T], error) { + qb, err := getQueryBuilder(span, r) + if err != nil { + return nil, err + } + + pageSize, err := bunpaginate.GetPageSize(r) + if err != nil { + return nil, err + } + span.SetAttributes(attribute.Int64("page_size", int64(pageSize))) + + return pointer.For(bunpaginate.NewPaginatedQueryOptions(options).WithQueryBuilder(qb).WithPageSize(pageSize)), nil +} diff --git a/internal/connectors/engine/activities/activity.go b/internal/connectors/engine/activities/activity.go new file mode 100644 index 00000000..5045b102 --- /dev/null +++ b/internal/connectors/engine/activities/activity.go @@ -0,0 +1,333 @@ +package activities + +import ( + "errors" + + temporalworker "github.com/formancehq/go-libs/v2/temporal" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type Activities struct { + storage storage.Storage + events *events.Events + temporalClient client.Client + + plugins plugins.Plugins +} + +func (a Activities) DefinitionSet() temporalworker.DefinitionSet { + return temporalworker.NewDefinitionSet(). + Append(temporalworker.Definition{ + Name: "PluginInstallConnector", + Func: a.PluginInstallConnector, + }). + Append(temporalworker.Definition{ + Name: "PluginUninstallConnector", + Func: a.PluginUninstallConnector, + }). + Append(temporalworker.Definition{ + Name: "PluginFetchNextAccounts", + Func: a.PluginFetchNextAccounts, + }). + Append(temporalworker.Definition{ + Name: "PluginFetchNextBalances", + Func: a.PluginFetchNextBalances, + }). + Append(temporalworker.Definition{ + Name: "PluginFetchNextExternalAccounts", + Func: a.PluginFetchNextExternalAccounts, + }). + Append(temporalworker.Definition{ + Name: "PluginFetchNextPayments", + Func: a.PluginFetchNextPayments, + }). + Append(temporalworker.Definition{ + Name: "PluginFetchNextOthers", + Func: a.PluginFetchNextOthers, + }). + Append(temporalworker.Definition{ + Name: "PluginCreateBankAccount", + Func: a.PluginCreateBankAccount, + }). + Append(temporalworker.Definition{ + Name: "PluginCreateTransfert", + Func: a.PluginCreateTransfer, + }). + Append(temporalworker.Definition{ + Name: "PluginReverseTransfer", + Func: a.PluginReverseTransfer, + }). + Append(temporalworker.Definition{ + Name: "PluginPollTransferStatus", + Func: a.PluginPollTransferStatus, + }). + Append(temporalworker.Definition{ + Name: "PluginCreatePayout", + Func: a.PluginCreatePayout, + }). + Append(temporalworker.Definition{ + Name: "PluginReversePayout", + Func: a.PluginReversePayout, + }). + Append(temporalworker.Definition{ + Name: "PluginPollPayoutStatus", + Func: a.PluginPollPayoutStatus, + }). + Append(temporalworker.Definition{ + Name: "PluginCreateWebhooks", + Func: a.PluginCreateWebhooks, + }). + Append(temporalworker.Definition{ + Name: "PluginTranslateWebhook", + Func: a.PluginTranslateWebhook, + }). + Append(temporalworker.Definition{ + Name: "StorageAccountsStore", + Func: a.StorageAccountsStore, + }). + Append(temporalworker.Definition{ + Name: "StorageAccountsGet", + Func: a.StorageAccountsGet, + }). + Append(temporalworker.Definition{ + Name: "StorageAccountsDelete", + Func: a.StorageAccountsDelete, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentsStore", + Func: a.StoragePaymentsStore, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentsDelete", + Func: a.StoragePaymentsDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageStatesGet", + Func: a.StorageStatesGet, + }). + Append(temporalworker.Definition{ + Name: "StorageStatesStore", + Func: a.StorageStatesStore, + }). + Append(temporalworker.Definition{ + Name: "StorageStatesDelete", + Func: a.StorageStatesDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageConnectorTasksTreeStore", + Func: a.StorageConnectorTasksTreeStore, + }). + Append(temporalworker.Definition{ + Name: "StorageConnectorTasksTreeDelete", + Func: a.StorageConnectorTasksTreeDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageConnectorsStore", + Func: a.StorageConnectorsStore, + }). + Append(temporalworker.Definition{ + Name: "StorageConnectorsDelete", + Func: a.StorageConnectorsDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageSchedulesStore", + Func: a.StorageSchedulesStore, + }). + Append(temporalworker.Definition{ + Name: "StorageSchedulesList", + Func: a.StorageSchedulesList, + }). + Append(temporalworker.Definition{ + Name: "StoreSchedulesDelete", + Func: a.StorageSchedulesDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageSchedulesDeleteFromConnectorID", + Func: a.StorageSchedulesDeleteFromConnectorID, + }). + Append(temporalworker.Definition{ + Name: "StorageInstancesStore", + Func: a.StorageInstancesStore, + }). + Append(temporalworker.Definition{ + Name: "StorageInstancesUpdate", + Func: a.StorageInstancesUpdate, + }). + Append(temporalworker.Definition{ + Name: "StorageInstancesDelete", + Func: a.StorageInstancesDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageBankAccountsDeleteRelatedAccounts", + Func: a.StorageBankAccountsDeleteRelatedAccounts, + }). + Append(temporalworker.Definition{ + Name: "StorageBankAccountsAddRelatedAccount", + Func: a.StorageBankAccountsAddRelatedAccount, + }). + Append(temporalworker.Definition{ + Name: "StorageBankAccountsGet", + Func: a.StorageBankAccountsGet, + }). + Append(temporalworker.Definition{ + Name: "StorageBalancesDelete", + Func: a.StorageBalancesDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageBalancesStore", + Func: a.StorageBalancesStore, + }). + Append(temporalworker.Definition{ + Name: "StorageWebhooksConfigsStore", + Func: a.StorageWebhooksConfigsStore, + }). + Append(temporalworker.Definition{ + Name: "StorageWebhooksConfigsGet", + Func: a.StorageWebhooksConfigsGet, + }). + Append(temporalworker.Definition{ + Name: "StorageWebhooksConfigsDelete", + Func: a.StorageWebhooksConfigsDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageWebhooksStore", + Func: a.StorageWebhooksStore, + }). + Append(temporalworker.Definition{ + Name: "StorageWebhooksDelete", + Func: a.StorageWebhooksDelete, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationGet", + Func: a.StoragePaymentInitiationsGet, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationsRelatedPaymentsStore", + Func: a.StoragePaymentInitiationsRelatedPaymentsStore, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationsAdjustmentsStore", + Func: a.StoragePaymentInitiationsAdjustmentsStore, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationAdjustmentsList", + Func: a.StoragePaymentInitiationAdjustmentsList, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationsAdjusmentsIfPredicateStore", + Func: a.StoragePaymentInitiationsAdjusmentsIfPredicateStore, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationIDsListFromPaymentID", + Func: a.StoragePaymentInitiationIDsListFromPaymentID, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationsDelete", + Func: a.StoragePaymentInitiationsDelete, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationReversalsGet", + Func: a.StoragePaymentInitiationReversalsGet, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationReversalsDelete", + Func: a.StoragePaymentInitiationReversalsDelete, + }). + Append(temporalworker.Definition{ + Name: "StoragePaymentInitiationReversalsAdjustmentsStore", + Func: a.StoragePaymentInitiationReversalsAdjustmentsStore, + }). + Append(temporalworker.Definition{ + Name: "StorageEventsSentStore", + Func: a.StorageEventsSentStore, + }). + Append(temporalworker.Definition{ + Name: "StorageEventsSentDelete", + Func: a.StorageEventsSentDelete, + }). + Append(temporalworker.Definition{ + Name: "StorageEventsSentExists", + Func: a.StorageEventsSentExists, + }). + Append(temporalworker.Definition{ + Name: "StorageTasksStore", + Func: a.StorageTasksStore, + }). + Append(temporalworker.Definition{ + Name: "StorageTasksDelete", + Func: a.StorageTasksDeleteFromConnectorID, + }). + Append(temporalworker.Definition{ + Name: "StoragePoolsRemoveAccountsFromConnectorID", + Func: a.StoragePoolsRemoveAccountsFromConnectorID, + }). + Append(temporalworker.Definition{ + Name: "EventsSendAccount", + Func: a.EventsSendAccount, + }). + Append(temporalworker.Definition{ + Name: "EventsSendBalance", + Func: a.EventsSendBalance, + }). + Append(temporalworker.Definition{ + Name: "EventsSendBankAccount", + Func: a.EventsSendBankAccount, + }). + Append(temporalworker.Definition{ + Name: "EventsSendConnectorReset", + Func: a.EventsSendConnectorReset, + }). + Append(temporalworker.Definition{ + Name: "EventsSendPayment", + Func: a.EventsSendPayment, + }). + Append(temporalworker.Definition{ + Name: "EventsSendPoolCreation", + Func: a.EventsSendPoolCreation, + }). + Append(temporalworker.Definition{ + Name: "EventsSendPoolDeletion", + Func: a.EventsSendPoolDeletion, + }). + Append(temporalworker.Definition{ + Name: "TemporalScheduleCreate", + Func: a.TemporalScheduleCreate, + }). + Append(temporalworker.Definition{ + Name: "TemporalDeleteSchedule", + Func: a.TemporalScheduleDelete, + }). + Append(temporalworker.Definition{ + Name: "TemporalWorkflowTerminate", + Func: a.TemporalWorkflowTerminate, + }). + Append(temporalworker.Definition{ + Name: "TemporalWorkflowExecutionsList", + Func: a.TemporalWorkflowExecutionsList, + }) +} + +func New(temporalClient client.Client, storage storage.Storage, events *events.Events, plugins plugins.Plugins) Activities { + return Activities{ + temporalClient: temporalClient, + storage: storage, + plugins: plugins, + events: events, + } +} + +func executeActivity(ctx workflow.Context, activity any, ret any, args ...any) error { + if err := workflow.ExecuteActivity(ctx, activity, args...).Get(ctx, ret); err != nil { + var timeoutError *temporal.TimeoutError + if errors.As(err, &timeoutError) { + return errors.New(timeoutError.Message()) + } + return err + } + return nil +} diff --git a/internal/connectors/engine/activities/errors.go b/internal/connectors/engine/activities/errors.go new file mode 100644 index 00000000..aa6802c4 --- /dev/null +++ b/internal/connectors/engine/activities/errors.go @@ -0,0 +1,51 @@ +package activities + +import ( + "errors" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/temporal" +) + +const ( + ErrTypeStorage = "STORAGE" + ErrTypeDefault = "DEFAULT" + ErrTypeInvalidArgument = "INVALID_ARGUMENT" + ErrTypeUnimplemented = "UNIMPLEMENTED" +) + +func temporalPluginError(err error) error { + switch { + // Do not retry the following errors + case errors.Is(err, plugins.ErrNotImplemented): + return temporal.NewNonRetryableApplicationError(err.Error(), ErrTypeUnimplemented, err) + case errors.Is(err, plugins.ErrInvalidClientRequest): + return temporal.NewNonRetryableApplicationError(err.Error(), ErrTypeInvalidArgument, err) + case errors.Is(err, plugins.ErrCurrencyNotSupported): + return temporal.NewNonRetryableApplicationError(err.Error(), ErrTypeInvalidArgument, err) + + // Retry the following errors + case errors.Is(err, plugins.ErrNotYetInstalled): + // We want to retry in case of not installed + return temporal.NewApplicationErrorWithCause(err.Error(), ErrTypeDefault, err) + default: + return temporal.NewApplicationErrorWithCause(err.Error(), ErrTypeDefault, err) + } +} + +func temporalStorageError(err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, storage.ErrNotFound), + errors.Is(err, storage.ErrDuplicateKeyValue), + errors.Is(err, storage.ErrValidation): + // Do not retry these errors + return temporal.NewNonRetryableApplicationError(err.Error(), ErrTypeStorage, err) + default: + return temporal.NewApplicationErrorWithCause(err.Error(), ErrTypeStorage, err) + } +} diff --git a/internal/connectors/engine/activities/events_send_account.go b/internal/connectors/engine/activities/events_send_account.go new file mode 100644 index 00000000..51aac079 --- /dev/null +++ b/internal/connectors/engine/activities/events_send_account.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) EventsSendAccount(ctx context.Context, account models.Account) error { + return a.events.Publish(ctx, a.events.NewEventSavedAccounts(account)) +} + +var EventsSendAccountActivity = Activities{}.EventsSendAccount + +func EventsSendAccount(ctx workflow.Context, account models.Account) error { + return executeActivity(ctx, EventsSendAccountActivity, nil, account) +} diff --git a/internal/connectors/engine/activities/events_send_balance.go b/internal/connectors/engine/activities/events_send_balance.go new file mode 100644 index 00000000..d41aad2a --- /dev/null +++ b/internal/connectors/engine/activities/events_send_balance.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) EventsSendBalance(ctx context.Context, balance models.Balance) error { + return a.events.Publish(ctx, a.events.NewEventSavedBalances(balance)) +} + +var EventsSendBalanceActivity = Activities{}.EventsSendBalance + +func EventsSendBalance(ctx workflow.Context, balance models.Balance) error { + return executeActivity(ctx, EventsSendBalanceActivity, nil, balance) +} diff --git a/internal/connectors/engine/activities/events_send_bank_account.go b/internal/connectors/engine/activities/events_send_bank_account.go new file mode 100644 index 00000000..b3117691 --- /dev/null +++ b/internal/connectors/engine/activities/events_send_bank_account.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) EventsSendBankAccount(ctx context.Context, bankAccount models.BankAccount) error { + return a.events.Publish(ctx, a.events.NewEventSavedBankAccounts(bankAccount)) +} + +var EventsSendBankAccountActivity = Activities{}.EventsSendBankAccount + +func EventsSendBankAccount(ctx workflow.Context, bankAccount models.BankAccount) error { + return executeActivity(ctx, EventsSendBankAccountActivity, nil, bankAccount) +} diff --git a/internal/connectors/engine/activities/events_send_connector_reset.go b/internal/connectors/engine/activities/events_send_connector_reset.go new file mode 100644 index 00000000..446bcd23 --- /dev/null +++ b/internal/connectors/engine/activities/events_send_connector_reset.go @@ -0,0 +1,19 @@ +package activities + +import ( + "context" + "time" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) EventsSendConnectorReset(ctx context.Context, connectorID models.ConnectorID, at time.Time) error { + return a.events.Publish(ctx, a.events.NewEventResetConnector(connectorID, at)) +} + +var EventsSendConnectorResetActivity = Activities{}.EventsSendConnectorReset + +func EventsSendConnectorReset(ctx workflow.Context, connectorID models.ConnectorID, at time.Time) error { + return executeActivity(ctx, EventsSendConnectorResetActivity, nil, connectorID, at) +} diff --git a/internal/connectors/engine/activities/events_send_payment.go b/internal/connectors/engine/activities/events_send_payment.go new file mode 100644 index 00000000..80ab61d1 --- /dev/null +++ b/internal/connectors/engine/activities/events_send_payment.go @@ -0,0 +1,26 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type EventsSendPaymentRequest struct { + Payment models.Payment + Adjustment models.PaymentAdjustment +} + +func (a Activities) EventsSendPayment(ctx context.Context, req EventsSendPaymentRequest) error { + return a.events.Publish(ctx, a.events.NewEventSavedPayments(req.Payment, req.Adjustment)) +} + +var EventsSendPaymentActivity = Activities{}.EventsSendPayment + +func EventsSendPayment(ctx workflow.Context, payment models.Payment, adjustment models.PaymentAdjustment) error { + return executeActivity(ctx, EventsSendPaymentActivity, nil, EventsSendPaymentRequest{ + Payment: payment, + Adjustment: adjustment, + }) +} diff --git a/internal/connectors/engine/activities/events_send_pool_creation.go b/internal/connectors/engine/activities/events_send_pool_creation.go new file mode 100644 index 00000000..990244f7 --- /dev/null +++ b/internal/connectors/engine/activities/events_send_pool_creation.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) EventsSendPoolCreation(ctx context.Context, pool models.Pool) error { + return a.events.Publish(ctx, a.events.NewEventSavedPool(pool)) +} + +var EventsSendPoolCreationActivity = Activities{}.EventsSendPoolCreation + +func EventsSendPoolCreation(ctx workflow.Context, pool models.Pool) error { + return executeActivity(ctx, EventsSendPoolCreationActivity, nil, pool) +} diff --git a/internal/connectors/engine/activities/events_send_pool_deletion.go b/internal/connectors/engine/activities/events_send_pool_deletion.go new file mode 100644 index 00000000..62f784a3 --- /dev/null +++ b/internal/connectors/engine/activities/events_send_pool_deletion.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/google/uuid" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) EventsSendPoolDeletion(ctx context.Context, id uuid.UUID) error { + return a.events.Publish(ctx, a.events.NewEventDeletePool(id)) +} + +var EventsSendPoolDeletionActivity = Activities{}.EventsSendPoolDeletion + +func EventsSendPoolDeletion(ctx workflow.Context, id uuid.UUID) error { + return executeActivity(ctx, EventsSendPoolDeletionActivity, nil, id) +} diff --git a/internal/connectors/engine/activities/plugin_create_bank_account.go b/internal/connectors/engine/activities/plugin_create_bank_account.go new file mode 100644 index 00000000..124432e7 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_bank_account.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type CreateBankAccountRequest struct { + ConnectorID models.ConnectorID + Req models.CreateBankAccountRequest +} + +func (a Activities) PluginCreateBankAccount(ctx context.Context, request CreateBankAccountRequest) (*models.CreateBankAccountResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.CreateBankAccount(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginCreateBankAccountActivity = Activities{}.PluginCreateBankAccount + +func PluginCreateBankAccount(ctx workflow.Context, connectorID models.ConnectorID, request models.CreateBankAccountRequest) (*models.CreateBankAccountResponse, error) { + ret := models.CreateBankAccountResponse{} + if err := executeActivity(ctx, PluginCreateBankAccountActivity, &ret, CreateBankAccountRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_create_bank_account_test.go b/internal/connectors/engine/activities/plugin_create_bank_account_test.go new file mode 100644 index 00000000..d94ce54b --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_bank_account_test.go @@ -0,0 +1,89 @@ +package activities_test + +import ( + "fmt" + "testing" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Activities Suite") +} + +var _ = Describe("Plugin Create Bank Account", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.CreateBankAccountResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{Reference: "ref"}, + } + }) + + Context("plugin create bank account", func() { + var ( + plugin *models.MockPlugin + req activities.CreateBankAccountRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.CreateBankAccountRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateBankAccount(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginCreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res.RelatedAccount.Reference).To(Equal(sampleResponse.RelatedAccount.Reference)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateBankAccount(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginCreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateBankAccount(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginCreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_create_payout.go b/internal/connectors/engine/activities/plugin_create_payout.go new file mode 100644 index 00000000..dc83ded6 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_payout.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type CreatePayoutRequest struct { + ConnectorID models.ConnectorID + Req models.CreatePayoutRequest +} + +func (a Activities) PluginCreatePayout(ctx context.Context, request CreatePayoutRequest) (*models.CreatePayoutResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.CreatePayout(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginCreatePayoutActivity = Activities{}.PluginCreatePayout + +func PluginCreatePayout(ctx workflow.Context, connectorID models.ConnectorID, request models.CreatePayoutRequest) (*models.CreatePayoutResponse, error) { + ret := models.CreatePayoutResponse{} + if err := executeActivity(ctx, PluginCreatePayoutActivity, &ret, CreatePayoutRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_create_payout_test.go b/internal/connectors/engine/activities/plugin_create_payout_test.go new file mode 100644 index 00000000..4aa8fb4a --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_payout_test.go @@ -0,0 +1,83 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Create Payout", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.CreatePayoutResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.CreatePayoutResponse{ + Payment: &models.PSPPayment{Reference: "ref"}, + } + }) + + Context("plugin create payout", func() { + var ( + plugin *models.MockPlugin + req activities.CreatePayoutRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.CreatePayoutRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreatePayout(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginCreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payment.Reference).To(Equal(sampleResponse.Payment.Reference)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreatePayout(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginCreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreatePayout(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginCreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_create_transfer.go b/internal/connectors/engine/activities/plugin_create_transfer.go new file mode 100644 index 00000000..43e76bc4 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_transfer.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type CreateTransferRequest struct { + ConnectorID models.ConnectorID + Req models.CreateTransferRequest +} + +func (a Activities) PluginCreateTransfer(ctx context.Context, request CreateTransferRequest) (*models.CreateTransferResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.CreateTransfer(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginCreateTransferActivity = Activities{}.PluginCreateTransfer + +func PluginCreateTransfer(ctx workflow.Context, connectorID models.ConnectorID, request models.CreateTransferRequest) (*models.CreateTransferResponse, error) { + ret := models.CreateTransferResponse{} + if err := executeActivity(ctx, PluginCreateTransferActivity, &ret, CreateTransferRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_create_transfer_test.go b/internal/connectors/engine/activities/plugin_create_transfer_test.go new file mode 100644 index 00000000..de7d82d8 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_transfer_test.go @@ -0,0 +1,83 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Create Transfer", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.CreateTransferResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.CreateTransferResponse{ + Payment: &models.PSPPayment{Reference: "ref"}, + } + }) + + Context("plugin create transfer", func() { + var ( + plugin *models.MockPlugin + req activities.CreateTransferRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.CreateTransferRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateTransfer(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginCreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payment.Reference).To(Equal(sampleResponse.Payment.Reference)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateTransfer(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginCreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateTransfer(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginCreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_create_webhooks.go b/internal/connectors/engine/activities/plugin_create_webhooks.go new file mode 100644 index 00000000..48b43977 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_webhooks.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type CreateWebhooksRequest struct { + ConnectorID models.ConnectorID + Req models.CreateWebhooksRequest +} + +func (a Activities) PluginCreateWebhooks(ctx context.Context, request CreateWebhooksRequest) (*models.CreateWebhooksResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.CreateWebhooks(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginCreateWebhooksActivity = Activities{}.PluginCreateWebhooks + +func PluginCreateWebhooks(ctx workflow.Context, connectorID models.ConnectorID, request models.CreateWebhooksRequest) (*models.CreateWebhooksResponse, error) { + ret := models.CreateWebhooksResponse{} + if err := executeActivity(ctx, PluginCreateWebhooksActivity, &ret, CreateWebhooksRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_create_webhooks_test.go b/internal/connectors/engine/activities/plugin_create_webhooks_test.go new file mode 100644 index 00000000..dddc1a1b --- /dev/null +++ b/internal/connectors/engine/activities/plugin_create_webhooks_test.go @@ -0,0 +1,81 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Create Webhooks", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.CreateWebhooksResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.CreateWebhooksResponse{Others: make([]models.PSPOther, 0)} + }) + + Context("plugin create webhook", func() { + var ( + plugin *models.MockPlugin + req activities.CreateWebhooksRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.CreateWebhooksRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateWebhooks(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginCreateWebhooks(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Others).To(Equal(sampleResponse.Others)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateWebhooks(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginCreateWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().CreateWebhooks(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginCreateWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_fetch_next_accounts.go b/internal/connectors/engine/activities/plugin_fetch_next_accounts.go new file mode 100644 index 00000000..79bf0996 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_accounts.go @@ -0,0 +1,45 @@ +package activities + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type FetchNextAccountsRequest struct { + ConnectorID models.ConnectorID + Req models.FetchNextAccountsRequest +} + +func (a Activities) PluginFetchNextAccounts(ctx context.Context, request FetchNextAccountsRequest) (*models.FetchNextAccountsResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.FetchNextAccounts(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginFetchNextAccountsActivity = Activities{}.PluginFetchNextAccounts + +func PluginFetchNextAccounts(ctx workflow.Context, connectorID models.ConnectorID, fromPayload, state json.RawMessage, pageSize int) (*models.FetchNextAccountsResponse, error) { + ret := models.FetchNextAccountsResponse{} + if err := executeActivity(ctx, PluginFetchNextAccountsActivity, &ret, FetchNextAccountsRequest{ + ConnectorID: connectorID, + Req: models.FetchNextAccountsRequest{ + FromPayload: fromPayload, + State: state, + PageSize: pageSize, + }, + }, + ); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_fetch_next_accounts_test.go b/internal/connectors/engine/activities/plugin_fetch_next_accounts_test.go new file mode 100644 index 00000000..4437ec44 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_accounts_test.go @@ -0,0 +1,81 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Fetch Next Accounts", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.FetchNextAccountsResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.FetchNextAccountsResponse{HasMore: true} + }) + + Context("plugin fetch next accounts", func() { + var ( + plugin *models.MockPlugin + req activities.FetchNextAccountsRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.FetchNextAccountsRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextAccounts(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginFetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(Equal(sampleResponse.HasMore)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextAccounts(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginFetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextAccounts(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginFetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_fetch_next_balances.go b/internal/connectors/engine/activities/plugin_fetch_next_balances.go new file mode 100644 index 00000000..82ed380f --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_balances.go @@ -0,0 +1,45 @@ +package activities + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type FetchNextBalancesRequest struct { + ConnectorID models.ConnectorID + Req models.FetchNextBalancesRequest +} + +func (a Activities) PluginFetchNextBalances(ctx context.Context, request FetchNextBalancesRequest) (*models.FetchNextBalancesResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.FetchNextBalances(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginFetchNextBalancesActivity = Activities{}.PluginFetchNextBalances + +func PluginFetchNextBalances(ctx workflow.Context, connectorID models.ConnectorID, fromPayload, state json.RawMessage, pageSize int) (*models.FetchNextBalancesResponse, error) { + ret := models.FetchNextBalancesResponse{} + if err := executeActivity(ctx, PluginFetchNextBalancesActivity, &ret, FetchNextBalancesRequest{ + ConnectorID: connectorID, + Req: models.FetchNextBalancesRequest{ + FromPayload: fromPayload, + State: state, + PageSize: pageSize, + }, + }, + ); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_fetch_next_balances_test.go b/internal/connectors/engine/activities/plugin_fetch_next_balances_test.go new file mode 100644 index 00000000..bb6bc848 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_balances_test.go @@ -0,0 +1,81 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Fetch Next Balances", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.FetchNextBalancesResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.FetchNextBalancesResponse{HasMore: true} + }) + + Context("plugin fetch next balances", func() { + var ( + plugin *models.MockPlugin + req activities.FetchNextBalancesRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.FetchNextBalancesRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextBalances(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginFetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(Equal(sampleResponse.HasMore)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextBalances(ctx, req.Req).Return(sampleResponse, fmt.Errorf("err")) + _, err := act.PluginFetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextBalances(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginFetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_fetch_next_external_accounts.go b/internal/connectors/engine/activities/plugin_fetch_next_external_accounts.go new file mode 100644 index 00000000..aeccc31b --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_external_accounts.go @@ -0,0 +1,45 @@ +package activities + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type FetchNextExternalAccountsRequest struct { + ConnectorID models.ConnectorID + Req models.FetchNextExternalAccountsRequest +} + +func (a Activities) PluginFetchNextExternalAccounts(ctx context.Context, request FetchNextExternalAccountsRequest) (*models.FetchNextExternalAccountsResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.FetchNextExternalAccounts(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginFetchNextExternalAccountsActivity = Activities{}.PluginFetchNextExternalAccounts + +func PluginFetchNextExternalAccounts(ctx workflow.Context, connectorID models.ConnectorID, fromPayload, state json.RawMessage, pageSize int) (*models.FetchNextExternalAccountsResponse, error) { + ret := models.FetchNextExternalAccountsResponse{} + if err := executeActivity(ctx, PluginFetchNextExternalAccountsActivity, &ret, FetchNextExternalAccountsRequest{ + ConnectorID: connectorID, + Req: models.FetchNextExternalAccountsRequest{ + FromPayload: fromPayload, + State: state, + PageSize: pageSize, + }, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_fetch_next_external_accounts_test.go b/internal/connectors/engine/activities/plugin_fetch_next_external_accounts_test.go new file mode 100644 index 00000000..fcdbc6c3 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_external_accounts_test.go @@ -0,0 +1,81 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Fetch Next ExternalAccounts", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.FetchNextExternalAccountsResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.FetchNextExternalAccountsResponse{HasMore: true} + }) + + Context("plugin fetch next external accounts", func() { + var ( + plugin *models.MockPlugin + req activities.FetchNextExternalAccountsRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.FetchNextExternalAccountsRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextExternalAccounts(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginFetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(Equal(sampleResponse.HasMore)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextExternalAccounts(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginFetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextExternalAccounts(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginFetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_fetch_next_others.go b/internal/connectors/engine/activities/plugin_fetch_next_others.go new file mode 100644 index 00000000..7293cf17 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_others.go @@ -0,0 +1,46 @@ +package activities + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type FetchNextOthersRequest struct { + ConnectorID models.ConnectorID + Req models.FetchNextOthersRequest +} + +func (a Activities) PluginFetchNextOthers(ctx context.Context, request FetchNextOthersRequest) (*models.FetchNextOthersResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.FetchNextOthers(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginFetchNextOthersActivity = Activities{}.PluginFetchNextOthers + +func PluginFetchNextOthers(ctx workflow.Context, connectorID models.ConnectorID, name string, fromPayload, state json.RawMessage, pageSize int) (*models.FetchNextOthersResponse, error) { + ret := models.FetchNextOthersResponse{} + if err := executeActivity(ctx, PluginFetchNextOthersActivity, &ret, FetchNextOthersRequest{ + ConnectorID: connectorID, + Req: models.FetchNextOthersRequest{ + Name: name, + FromPayload: fromPayload, + State: state, + PageSize: pageSize, + }, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_fetch_next_others_test.go b/internal/connectors/engine/activities/plugin_fetch_next_others_test.go new file mode 100644 index 00000000..52de33b5 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_others_test.go @@ -0,0 +1,81 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Fetch Next Others", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.FetchNextOthersResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.FetchNextOthersResponse{HasMore: true} + }) + + Context("plugin fetch next others", func() { + var ( + plugin *models.MockPlugin + req activities.FetchNextOthersRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.FetchNextOthersRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextOthers(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginFetchNextOthers(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(Equal(sampleResponse.HasMore)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextOthers(ctx, req.Req).Return(sampleResponse, fmt.Errorf("abort")) + _, err := act.PluginFetchNextOthers(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextOthers(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginFetchNextOthers(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_fetch_next_payments.go b/internal/connectors/engine/activities/plugin_fetch_next_payments.go new file mode 100644 index 00000000..fd9cb9da --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_payments.go @@ -0,0 +1,45 @@ +package activities + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type FetchNextPaymentsRequest struct { + ConnectorID models.ConnectorID + Req models.FetchNextPaymentsRequest +} + +func (a Activities) PluginFetchNextPayments(ctx context.Context, request FetchNextPaymentsRequest) (*models.FetchNextPaymentsResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.FetchNextPayments(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginFetchNextPaymentsActivity = Activities{}.PluginFetchNextPayments + +func PluginFetchNextPayments(ctx workflow.Context, connectorID models.ConnectorID, fromPayload, state json.RawMessage, pageSize int) (*models.FetchNextPaymentsResponse, error) { + ret := models.FetchNextPaymentsResponse{} + if err := executeActivity(ctx, PluginFetchNextPaymentsActivity, &ret, FetchNextPaymentsRequest{ + ConnectorID: connectorID, + Req: models.FetchNextPaymentsRequest{ + FromPayload: fromPayload, + State: state, + PageSize: pageSize, + }, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_fetch_next_payments_test.go b/internal/connectors/engine/activities/plugin_fetch_next_payments_test.go new file mode 100644 index 00000000..6280b164 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_fetch_next_payments_test.go @@ -0,0 +1,81 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Fetch Next Payments", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.FetchNextPaymentsResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.FetchNextPaymentsResponse{HasMore: true} + }) + + Context("plugin fetch next payments", func() { + var ( + plugin *models.MockPlugin + req activities.FetchNextPaymentsRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.FetchNextPaymentsRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextPayments(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginFetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(Equal(sampleResponse.HasMore)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextPayments(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginFetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().FetchNextPayments(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginFetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_install_connector.go b/internal/connectors/engine/activities/plugin_install_connector.go new file mode 100644 index 00000000..2f3cf15a --- /dev/null +++ b/internal/connectors/engine/activities/plugin_install_connector.go @@ -0,0 +1,40 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type InstallConnectorRequest struct { + ConnectorID models.ConnectorID + Req models.InstallRequest +} + +func (a Activities) PluginInstallConnector(ctx context.Context, request InstallConnectorRequest) (*models.InstallResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.Install(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginInstallConnectorActivity = Activities{}.PluginInstallConnector + +func PluginInstallConnector(ctx workflow.Context, connectorID models.ConnectorID) (*models.InstallResponse, error) { + ret := models.InstallResponse{} + if err := executeActivity(ctx, PluginInstallConnectorActivity, &ret, InstallConnectorRequest{ + ConnectorID: connectorID, + Req: models.InstallRequest{}, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_install_connector_test.go b/internal/connectors/engine/activities/plugin_install_connector_test.go new file mode 100644 index 00000000..6245672e --- /dev/null +++ b/internal/connectors/engine/activities/plugin_install_connector_test.go @@ -0,0 +1,80 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Install Connector", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.InstallResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.InstallResponse{} + }) + + Context("plugin install connector", func() { + var ( + plugin *models.MockPlugin + req activities.InstallConnectorRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.InstallConnectorRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().Install(ctx, req.Req).Return(sampleResponse, nil) + _, err := act.PluginInstallConnector(ctx, req) + Expect(err).To(BeNil()) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().Install(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginInstallConnector(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().Install(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginInstallConnector(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_poll_payout_status.go b/internal/connectors/engine/activities/plugin_poll_payout_status.go new file mode 100644 index 00000000..f97af729 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_poll_payout_status.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type PollPayoutStatusRequest struct { + ConnectorID models.ConnectorID + Req models.PollPayoutStatusRequest +} + +func (a Activities) PluginPollPayoutStatus(ctx context.Context, request PollPayoutStatusRequest) (*models.PollPayoutStatusResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.PollPayoutStatus(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginPollPayoutStatusActivity = Activities{}.PluginPollPayoutStatus + +func PluginPollPayoutStatus(ctx workflow.Context, connectorID models.ConnectorID, request models.PollPayoutStatusRequest) (*models.PollPayoutStatusResponse, error) { + ret := models.PollPayoutStatusResponse{} + if err := executeActivity(ctx, PluginPollPayoutStatusActivity, &ret, PollPayoutStatusRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_poll_payout_status_test.go b/internal/connectors/engine/activities/plugin_poll_payout_status_test.go new file mode 100644 index 00000000..edf318e2 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_poll_payout_status_test.go @@ -0,0 +1,84 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Poll Payout Status", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.PollPayoutStatusResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.PollPayoutStatusResponse{ + Payment: &models.PSPPayment{Reference: "ref"}, + } + }) + + Context("plugin poll payout status", func() { + var ( + plugin *models.MockPlugin + req activities.PollPayoutStatusRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.PollPayoutStatusRequest{ + ConnectorID: models.ConnectorID{Provider: "some_provider"}, + Req: models.PollPayoutStatusRequest{ + PayoutID: "test", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().PollPayoutStatus(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginPollPayoutStatus(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payment.Reference).To(Equal(sampleResponse.Payment.Reference)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().PollPayoutStatus(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginPollPayoutStatus(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().PollPayoutStatus(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginPollPayoutStatus(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_poll_transfer_status.go b/internal/connectors/engine/activities/plugin_poll_transfer_status.go new file mode 100644 index 00000000..4a49faf2 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_poll_transfer_status.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type PollTransferStatusRequest struct { + ConnectorID models.ConnectorID + Req models.PollTransferStatusRequest +} + +func (a Activities) PluginPollTransferStatus(ctx context.Context, request PollTransferStatusRequest) (*models.PollTransferStatusResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.PollTransferStatus(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginPollTransferStatusActivity = Activities{}.PluginPollTransferStatus + +func PluginPollTransferStatus(ctx workflow.Context, connectorID models.ConnectorID, request models.PollTransferStatusRequest) (*models.PollTransferStatusResponse, error) { + ret := models.PollTransferStatusResponse{} + if err := executeActivity(ctx, PluginPollTransferStatusActivity, &ret, PollTransferStatusRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_poll_transfer_status_test.go b/internal/connectors/engine/activities/plugin_poll_transfer_status_test.go new file mode 100644 index 00000000..1c18461f --- /dev/null +++ b/internal/connectors/engine/activities/plugin_poll_transfer_status_test.go @@ -0,0 +1,84 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Poll Transfer Status", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.PollTransferStatusResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.PollTransferStatusResponse{ + Payment: &models.PSPPayment{Reference: "ref"}, + } + }) + + Context("plugin poll transfer staus", func() { + var ( + plugin *models.MockPlugin + req activities.PollTransferStatusRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.PollTransferStatusRequest{ + ConnectorID: models.ConnectorID{Provider: "some_provider"}, + Req: models.PollTransferStatusRequest{ + TransferID: "test", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().PollTransferStatus(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginPollTransferStatus(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payment.Reference).To(Equal(sampleResponse.Payment.Reference)) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().PollTransferStatus(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginPollTransferStatus(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().PollTransferStatus(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginPollTransferStatus(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_reverse_payout.go b/internal/connectors/engine/activities/plugin_reverse_payout.go new file mode 100644 index 00000000..10cb45e3 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_reverse_payout.go @@ -0,0 +1,40 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type ReversePayoutRequest struct { + ConnectorID models.ConnectorID + Req models.ReversePayoutRequest +} + +func (a Activities) PluginReversePayout(ctx context.Context, request ReversePayoutRequest) (*models.ReversePayoutResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.ReversePayout(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginReversePayoutActivity = Activities{}.PluginReversePayout + +func PluginReversePayout(ctx workflow.Context, connectorID models.ConnectorID, request models.ReversePayoutRequest) (*models.ReversePayoutResponse, error) { + ret := models.ReversePayoutResponse{} + if err := executeActivity(ctx, PluginReversePayoutActivity, &ret, ReversePayoutRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_reverse_payout_test.go b/internal/connectors/engine/activities/plugin_reverse_payout_test.go new file mode 100644 index 00000000..84f360a2 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_reverse_payout_test.go @@ -0,0 +1,77 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Reverse Payout", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.ReversePayoutResponse + ) + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.ReversePayoutResponse{ + Payment: models.PSPPayment{Reference: "ref"}, + } + }) + Context("plugin reverse transfer", func() { + var ( + plugin *models.MockPlugin + req activities.ReversePayoutRequest + ) + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.ReversePayoutRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().ReversePayout(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginReversePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payment.Reference).To(Equal(sampleResponse.Payment.Reference)) + }) + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().ReversePayout(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginReversePayout(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().ReversePayout(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginReversePayout(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_reverse_transfer.go b/internal/connectors/engine/activities/plugin_reverse_transfer.go new file mode 100644 index 00000000..c6f6ac18 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_reverse_transfer.go @@ -0,0 +1,40 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type ReverseTransferRequest struct { + ConnectorID models.ConnectorID + Req models.ReverseTransferRequest +} + +func (a Activities) PluginReverseTransfer(ctx context.Context, request ReverseTransferRequest) (*models.ReverseTransferResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.ReverseTransfer(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginReverseTransferActivity = Activities{}.PluginReverseTransfer + +func PluginReverseTransfer(ctx workflow.Context, connectorID models.ConnectorID, request models.ReverseTransferRequest) (*models.ReverseTransferResponse, error) { + ret := models.ReverseTransferResponse{} + if err := executeActivity(ctx, PluginReverseTransferActivity, &ret, ReverseTransferRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_reverse_transfer_test.go b/internal/connectors/engine/activities/plugin_reverse_transfer_test.go new file mode 100644 index 00000000..998eb3f9 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_reverse_transfer_test.go @@ -0,0 +1,77 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Reverse Transfer", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.ReverseTransferResponse + ) + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.ReverseTransferResponse{ + Payment: models.PSPPayment{Reference: "ref"}, + } + }) + Context("plugin reverse transfer", func() { + var ( + plugin *models.MockPlugin + req activities.ReverseTransferRequest + ) + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.ReverseTransferRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().ReverseTransfer(ctx, req.Req).Return(sampleResponse, nil) + res, err := act.PluginReverseTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payment.Reference).To(Equal(sampleResponse.Payment.Reference)) + }) + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().ReverseTransfer(ctx, req.Req).Return(sampleResponse, fmt.Errorf("some string")) + _, err := act.PluginReverseTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().ReverseTransfer(ctx, req.Req).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrNotImplemented)) + _, err := act.PluginReverseTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeUnimplemented)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/plugin_translate_webhook.go b/internal/connectors/engine/activities/plugin_translate_webhook.go new file mode 100644 index 00000000..01b07e56 --- /dev/null +++ b/internal/connectors/engine/activities/plugin_translate_webhook.go @@ -0,0 +1,39 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type TranslateWebhookRequest struct { + ConnectorID models.ConnectorID + Req models.TranslateWebhookRequest +} + +func (a Activities) PluginTranslateWebhook(ctx context.Context, request TranslateWebhookRequest) (*models.TranslateWebhookResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + if err != nil { + return nil, temporalPluginError(err) + } + + resp, err := plugin.TranslateWebhook(ctx, request.Req) + if err != nil { + return nil, temporalPluginError(err) + } + return &resp, nil +} + +var PluginTranslateWebhookActivity = Activities{}.PluginTranslateWebhook + +func PluginTranslateWebhook(ctx workflow.Context, connectorID models.ConnectorID, request models.TranslateWebhookRequest) (*models.TranslateWebhookResponse, error) { + ret := models.TranslateWebhookResponse{} + if err := executeActivity(ctx, PluginTranslateWebhookActivity, &ret, TranslateWebhookRequest{ + ConnectorID: connectorID, + Req: request, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_uninstall_connector.go b/internal/connectors/engine/activities/plugin_uninstall_connector.go new file mode 100644 index 00000000..2d27cc6f --- /dev/null +++ b/internal/connectors/engine/activities/plugin_uninstall_connector.go @@ -0,0 +1,44 @@ +package activities + +import ( + "context" + "errors" + + "github.com/formancehq/payments/internal/connectors/engine/plugins" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type UninstallConnectorRequest struct { + ConnectorID models.ConnectorID +} + +func (a Activities) PluginUninstallConnector(ctx context.Context, request UninstallConnectorRequest) (*models.UninstallResponse, error) { + plugin, err := a.plugins.Get(request.ConnectorID) + switch { + case errors.Is(err, plugins.ErrNotFound): + // When the plugin is not found, we consider it as uninstalled. + return &models.UninstallResponse{}, nil + case err != nil: + return nil, err + } + + resp, err := plugin.Uninstall(ctx, models.UninstallRequest{}) + if err != nil { + return nil, temporalPluginError(err) + } + + return &resp, nil +} + +var PluginUninstallConnectorActivity = Activities{}.PluginUninstallConnector + +func PluginUninstallConnector(ctx workflow.Context, connectorID models.ConnectorID) (*models.UninstallResponse, error) { + ret := models.UninstallResponse{} + if err := executeActivity(ctx, PluginUninstallConnectorActivity, &ret, UninstallConnectorRequest{ + ConnectorID: connectorID, + }); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/plugin_uninstall_connector_test.go b/internal/connectors/engine/activities/plugin_uninstall_connector_test.go new file mode 100644 index 00000000..ae3d9b2a --- /dev/null +++ b/internal/connectors/engine/activities/plugin_uninstall_connector_test.go @@ -0,0 +1,80 @@ +package activities_test + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + pluginsError "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.temporal.io/sdk/temporal" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Plugin Uninstall", func() { + var ( + act activities.Activities + p *plugins.MockPlugins + s *storage.MockStorage + evts *events.Events + sampleResponse models.UninstallResponse + ) + + BeforeEach(func() { + evts = &events.Events{} + sampleResponse = models.UninstallResponse{} + }) + + Context("plugin uninstall connector", func() { + var ( + plugin *models.MockPlugin + req activities.UninstallConnectorRequest + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + p = plugins.NewMockPlugins(ctrl) + s = storage.NewMockStorage(ctrl) + plugin = models.NewMockPlugin(ctrl) + act = activities.New(nil, s, evts, p) + req = activities.UninstallConnectorRequest{ + ConnectorID: models.ConnectorID{ + Provider: "some_provider", + }, + } + }) + + It("calls underlying plugin", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().Uninstall(ctx, models.UninstallRequest{}).Return(sampleResponse, nil) + _, err := act.PluginUninstallConnector(ctx, req) + Expect(err).To(BeNil()) + }) + + It("returns a retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().Uninstall(ctx, models.UninstallRequest{}).Return(sampleResponse, fmt.Errorf("no uninstall")) + _, err := act.PluginUninstallConnector(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeFalse()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeDefault)) + }) + + It("returns a non-retryable temporal error", func(ctx SpecContext) { + p.EXPECT().Get(req.ConnectorID).Return(plugin, nil) + plugin.EXPECT().Uninstall(ctx, models.UninstallRequest{}).Return(sampleResponse, fmt.Errorf("invalid: %w", pluginsError.ErrInvalidClientRequest)) + _, err := act.PluginUninstallConnector(ctx, req) + Expect(err).ToNot(BeNil()) + temporalErr, ok := err.(*temporal.ApplicationError) + Expect(ok).To(BeTrue()) + Expect(temporalErr.NonRetryable()).To(BeTrue()) + Expect(temporalErr.Type()).To(Equal(activities.ErrTypeInvalidArgument)) + }) + }) +}) diff --git a/internal/connectors/engine/activities/storage_accounts_delete.go b/internal/connectors/engine/activities/storage_accounts_delete.go new file mode 100644 index 00000000..328abe47 --- /dev/null +++ b/internal/connectors/engine/activities/storage_accounts_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageAccountsDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.AccountsDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageAccountsDeleteActivity = Activities{}.StorageAccountsDelete + +func StorageAccountsDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageAccountsDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_accounts_get.go b/internal/connectors/engine/activities/storage_accounts_get.go new file mode 100644 index 00000000..baf211c1 --- /dev/null +++ b/internal/connectors/engine/activities/storage_accounts_get.go @@ -0,0 +1,26 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageAccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) { + account, err := a.storage.AccountsGet(ctx, id) + if err != nil { + return nil, temporalStorageError(err) + } + return account, nil +} + +var StorageAccountsGetActivity = Activities{}.StorageAccountsGet + +func StorageAccountsGet(ctx workflow.Context, id models.AccountID) (*models.Account, error) { + ret := models.Account{} + if err := executeActivity(ctx, StorageAccountsGetActivity, &ret, id); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/storage_accounts_store.go b/internal/connectors/engine/activities/storage_accounts_store.go new file mode 100644 index 00000000..13715f18 --- /dev/null +++ b/internal/connectors/engine/activities/storage_accounts_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageAccountsStore(ctx context.Context, accounts []models.Account) error { + return temporalStorageError(a.storage.AccountsUpsert(ctx, accounts)) +} + +var StorageAccountsStoreActivity = Activities{}.StorageAccountsStore + +func StorageAccountsStore(ctx workflow.Context, accounts []models.Account) error { + return executeActivity(ctx, StorageAccountsStoreActivity, nil, accounts) +} diff --git a/internal/connectors/engine/activities/storage_balances_delete.go b/internal/connectors/engine/activities/storage_balances_delete.go new file mode 100644 index 00000000..e8691d88 --- /dev/null +++ b/internal/connectors/engine/activities/storage_balances_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageBalancesDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.BalancesDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageBalancesDeleteActivity = Activities{}.StorageBalancesDelete + +func StorageBalancesDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageBalancesDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_balances_store.go b/internal/connectors/engine/activities/storage_balances_store.go new file mode 100644 index 00000000..55a3706a --- /dev/null +++ b/internal/connectors/engine/activities/storage_balances_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageBalancesStore(ctx context.Context, balances []models.Balance) error { + return temporalStorageError(a.storage.BalancesUpsert(ctx, balances)) +} + +var StorageBalancesStoreActivity = Activities{}.StorageBalancesStore + +func StorageBalancesStore(ctx workflow.Context, balances []models.Balance) error { + return executeActivity(ctx, StorageBalancesStoreActivity, nil, balances) +} diff --git a/internal/connectors/engine/activities/storage_bank_accounts_add_related_account.go b/internal/connectors/engine/activities/storage_bank_accounts_add_related_account.go new file mode 100644 index 00000000..30161e7c --- /dev/null +++ b/internal/connectors/engine/activities/storage_bank_accounts_add_related_account.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageBankAccountsAddRelatedAccount(ctx context.Context, relatedAccount models.BankAccountRelatedAccount) error { + return temporalStorageError(a.storage.BankAccountsAddRelatedAccount(ctx, relatedAccount)) +} + +var StorageBankAccountsAddRelatedAccountActivity = Activities{}.StorageBankAccountsAddRelatedAccount + +func StorageBankAccountsAddRelatedAccount(ctx workflow.Context, relatedAccount models.BankAccountRelatedAccount) error { + return executeActivity(ctx, StorageBankAccountsAddRelatedAccountActivity, nil, relatedAccount) +} diff --git a/internal/connectors/engine/activities/storage_bank_accounts_delete_related_accounts.go b/internal/connectors/engine/activities/storage_bank_accounts_delete_related_accounts.go new file mode 100644 index 00000000..3769e22c --- /dev/null +++ b/internal/connectors/engine/activities/storage_bank_accounts_delete_related_accounts.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageBankAccountsDeleteRelatedAccounts(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.BankAccountsDeleteRelatedAccountFromConnectorID(ctx, connectorID)) +} + +var StorageBankAccountsDeleteRelatedAccountsActivity = Activities{}.StorageBankAccountsDeleteRelatedAccounts + +func StorageBankAccountsDeleteRelatedAccounts(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageBankAccountsDeleteRelatedAccountsActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_bank_accounts_get.go b/internal/connectors/engine/activities/storage_bank_accounts_get.go new file mode 100644 index 00000000..4b206ae3 --- /dev/null +++ b/internal/connectors/engine/activities/storage_bank_accounts_get.go @@ -0,0 +1,25 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageBankAccountsGet(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { + ba, err := a.storage.BankAccountsGet(ctx, id, expand) + if err != nil { + return nil, temporalStorageError(err) + } + return ba, nil +} + +var StorageBankAccountsGetActivity = Activities{}.StorageBankAccountsGet + +func StorageBankAccountsGet(ctx workflow.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { + var result models.BankAccount + err := executeActivity(ctx, StorageBankAccountsGetActivity, &result, id, expand) + return &result, err +} diff --git a/internal/connectors/engine/activities/storage_connector_tasks_tree_delete.go b/internal/connectors/engine/activities/storage_connector_tasks_tree_delete.go new file mode 100644 index 00000000..b3826077 --- /dev/null +++ b/internal/connectors/engine/activities/storage_connector_tasks_tree_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageConnectorTasksTreeDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.ConnectorTasksTreeDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageConnectorTasksTreeDeleteActivity = Activities{}.StorageConnectorTasksTreeDelete + +func StorageConnectortTasksTreeDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageConnectorTasksTreeDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_connector_tasks_tree_store.go b/internal/connectors/engine/activities/storage_connector_tasks_tree_store.go new file mode 100644 index 00000000..a4246a4d --- /dev/null +++ b/internal/connectors/engine/activities/storage_connector_tasks_tree_store.go @@ -0,0 +1,26 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type TasksTreeStoreRequest struct { + ConnectorID models.ConnectorID + Workflow models.ConnectorTasksTree +} + +func (a Activities) StorageConnectorTasksTreeStore(ctx context.Context, request TasksTreeStoreRequest) error { + return temporalStorageError(a.storage.ConnectorTasksTreeUpsert(ctx, request.ConnectorID, request.Workflow)) +} + +var StorageConnectorTasksTreeStoreActivity = Activities{}.StorageConnectorTasksTreeStore + +func StorageConnectorTasksTreeStore(ctx workflow.Context, connectorID models.ConnectorID, workflow models.ConnectorTasksTree) error { + return executeActivity(ctx, StorageConnectorTasksTreeStoreActivity, nil, TasksTreeStoreRequest{ + ConnectorID: connectorID, + Workflow: workflow, + }) +} diff --git a/internal/connectors/engine/activities/storage_connectors_delete.go b/internal/connectors/engine/activities/storage_connectors_delete.go new file mode 100644 index 00000000..a553e5c7 --- /dev/null +++ b/internal/connectors/engine/activities/storage_connectors_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageConnectorsDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.ConnectorsUninstall(ctx, connectorID)) +} + +var StorageConnectorsDeleteActivity = Activities{}.StorageConnectorsDelete + +func StorageConnectorsDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageConnectorsDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_connectors_store.go b/internal/connectors/engine/activities/storage_connectors_store.go new file mode 100644 index 00000000..51b1f27e --- /dev/null +++ b/internal/connectors/engine/activities/storage_connectors_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageConnectorsStore(ctx context.Context, connector models.Connector) error { + return temporalStorageError(a.storage.ConnectorsInstall(ctx, connector)) +} + +var StorageConnectorsStoreActivity = Activities{}.StorageConnectorsStore + +func StorageConnectorsStore(ctx workflow.Context, connector models.Connector) error { + return executeActivity(ctx, StorageConnectorsStoreActivity, nil, connector) +} diff --git a/internal/connectors/engine/activities/storage_events_sent_delete.go b/internal/connectors/engine/activities/storage_events_sent_delete.go new file mode 100644 index 00000000..6af923dd --- /dev/null +++ b/internal/connectors/engine/activities/storage_events_sent_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageEventsSentDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.EventsSentDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageEventsSentDeleteActivity = Activities{}.StorageEventsSentDelete + +func StorageEventsSentDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageEventsSentDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_events_sent_exists.go b/internal/connectors/engine/activities/storage_events_sent_exists.go new file mode 100644 index 00000000..cd0119df --- /dev/null +++ b/internal/connectors/engine/activities/storage_events_sent_exists.go @@ -0,0 +1,27 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageEventsSentExists(ctx context.Context, id models.EventID) (bool, error) { + isExisting, err := a.storage.EventsSentExists(ctx, id) + if err != nil { + return false, temporalStorageError(err) + } + return isExisting, nil +} + +var StorageEventsSentGetActivity = Activities{}.StorageEventsSentExists + +func StorageEventsSentExists(ctx workflow.Context, ik string, connectorID *models.ConnectorID) (bool, error) { + var result bool + err := executeActivity(ctx, StorageEventsSentGetActivity, &result, models.EventID{ + EventIdempotencyKey: ik, + ConnectorID: connectorID, + }) + return result, err +} diff --git a/internal/connectors/engine/activities/storage_events_sent_store.go b/internal/connectors/engine/activities/storage_events_sent_store.go new file mode 100644 index 00000000..1d1d333c --- /dev/null +++ b/internal/connectors/engine/activities/storage_events_sent_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageEventsSentStore(ctx context.Context, eventSent models.EventSent) error { + return temporalStorageError(a.storage.EventsSentUpsert(ctx, eventSent)) +} + +var StorageEventsSentStoreActivity = Activities{}.StorageEventsSentStore + +func StorageEventsSentStore(ctx workflow.Context, eventSent models.EventSent) error { + return executeActivity(ctx, StorageEventsSentStoreActivity, nil, eventSent) +} diff --git a/internal/connectors/engine/activities/storage_instances_delete.go b/internal/connectors/engine/activities/storage_instances_delete.go new file mode 100644 index 00000000..1c9bb138 --- /dev/null +++ b/internal/connectors/engine/activities/storage_instances_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageInstancesDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.InstancesDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageInstancesDeleteActivity = Activities{}.StorageInstancesDelete + +func StorageInstancesDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageInstancesDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_instances_store.go b/internal/connectors/engine/activities/storage_instances_store.go new file mode 100644 index 00000000..1abccdb1 --- /dev/null +++ b/internal/connectors/engine/activities/storage_instances_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageInstancesStore(ctx context.Context, instance models.Instance) error { + return temporalStorageError(a.storage.InstancesUpsert(ctx, instance)) +} + +var StorageInstancesStoreActivity = Activities{}.StorageInstancesStore + +func StorageInstancesStore(ctx workflow.Context, instance models.Instance) error { + return executeActivity(ctx, StorageInstancesStoreActivity, nil, instance) +} diff --git a/internal/connectors/engine/activities/storage_instances_update.go b/internal/connectors/engine/activities/storage_instances_update.go new file mode 100644 index 00000000..6cbc771b --- /dev/null +++ b/internal/connectors/engine/activities/storage_instances_update.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageInstancesUpdate(ctx context.Context, instance models.Instance) error { + return temporalStorageError(a.storage.InstancesUpdate(ctx, instance)) +} + +var StorageInstancesUpdateActivity = Activities{}.StorageInstancesUpdate + +func StorageInstancesUpdate(ctx workflow.Context, instance models.Instance) error { + return executeActivity(ctx, StorageInstancesUpdateActivity, nil, instance) +} diff --git a/internal/connectors/engine/activities/storage_payment_initiation_adjustments_list.go b/internal/connectors/engine/activities/storage_payment_initiation_adjustments_list.go new file mode 100644 index 00000000..428cbdfb --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiation_adjustments_list.go @@ -0,0 +1,28 @@ +package activities + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationAdjustmentsList(ctx context.Context, piID models.PaymentInitiationID, query storage.ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) { + cursor, err := a.storage.PaymentInitiationAdjustmentsList(ctx, piID, query) + if err != nil { + return nil, temporalStorageError(err) + } + return cursor, nil +} + +var StoragePaymentInitiationAdjustmentsListActivity = Activities{}.StoragePaymentInitiationAdjustmentsList + +func StoragePaymentInitiationAdjustmentsList(ctx workflow.Context, piID models.PaymentInitiationID, query storage.ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) { + ret := bunpaginate.Cursor[models.PaymentInitiationAdjustment]{} + if err := executeActivity(ctx, StoragePaymentInitiationAdjustmentsListActivity, &ret, piID, query); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/storage_payment_initiation_reversals_adjustments_store.go b/internal/connectors/engine/activities/storage_payment_initiation_reversals_adjustments_store.go new file mode 100644 index 00000000..b2f62d2e --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiation_reversals_adjustments_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationReversalsAdjustmentsStore(ctx context.Context, adj models.PaymentInitiationReversalAdjustment) error { + return temporalStorageError(a.storage.PaymentInitiationReversalAdjustmentsUpsert(ctx, adj)) +} + +var StoragePaymentInitiationReversalsAdjustmentsStoreActivity = Activities{}.StoragePaymentInitiationReversalsAdjustmentsStore + +func StoragePaymentInitiationReversalsAdjustmentsStore(ctx workflow.Context, adj models.PaymentInitiationReversalAdjustment) error { + return executeActivity(ctx, StoragePaymentInitiationReversalsAdjustmentsStoreActivity, nil, adj) +} diff --git a/internal/connectors/engine/activities/storage_payment_initiation_reversals_delete.go b/internal/connectors/engine/activities/storage_payment_initiation_reversals_delete.go new file mode 100644 index 00000000..eb735a8a --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiation_reversals_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationReversalsDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.PaymentInitiationReversalsDeleteFromConnectorID(ctx, connectorID)) +} + +var StoragePaymentInitiationReversalsDeleteActivity = Activities{}.StoragePaymentInitiationReversalsDelete + +func StoragePaymentInitiationReversalsDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StoragePaymentInitiationReversalsDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_payment_initiation_reversals_get.go b/internal/connectors/engine/activities/storage_payment_initiation_reversals_get.go new file mode 100644 index 00000000..d33d109e --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiation_reversals_get.go @@ -0,0 +1,24 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationReversalsGet(ctx context.Context, id models.PaymentInitiationReversalID) (*models.PaymentInitiationReversal, error) { + pi, err := a.storage.PaymentInitiationReversalsGet(ctx, id) + if err != nil { + return nil, temporalStorageError(err) + } + return pi, nil +} + +var StoragePaymentInitiationReversalsGetActivity = Activities{}.StoragePaymentInitiationReversalsGet + +func StoragePaymentInitiationReversalsGet(ctx workflow.Context, id models.PaymentInitiationReversalID) (*models.PaymentInitiationReversal, error) { + var result models.PaymentInitiationReversal + err := executeActivity(ctx, StoragePaymentInitiationReversalsGetActivity, &result, id) + return &result, err +} diff --git a/internal/connectors/engine/activities/storage_payment_initiations_adjusments_status_predicate_store.go b/internal/connectors/engine/activities/storage_payment_initiations_adjusments_status_predicate_store.go new file mode 100644 index 00000000..3a0bcee1 --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiations_adjusments_status_predicate_store.go @@ -0,0 +1,28 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationsAdjusmentsIfPredicateStore(ctx context.Context, adj models.PaymentInitiationAdjustment, unAcceptablePreviousStatus []models.PaymentInitiationAdjustmentStatus) (bool, error) { + inserted, err := a.storage.PaymentInitiationAdjustmentsUpsertIfPredicate(ctx, adj, func(pia models.PaymentInitiationAdjustment) bool { + for _, status := range unAcceptablePreviousStatus { + if pia.Status == status { + return false + } + } + return true + }) + return inserted, temporalStorageError(err) +} + +var StoragePaymentInitiationsAdjusmentsIfStatusEqualStoreActivity = Activities{}.StoragePaymentInitiationsAdjusmentsIfPredicateStore + +func StoragePaymentInitiationsAdjusmentsIfPredicateStore(ctx workflow.Context, adj models.PaymentInitiationAdjustment, unAcceptablePreviousStatus []models.PaymentInitiationAdjustmentStatus) (bool, error) { + var result bool + err := executeActivity(ctx, StoragePaymentInitiationsAdjusmentsIfStatusEqualStoreActivity, &result, adj, unAcceptablePreviousStatus) + return result, err +} diff --git a/internal/connectors/engine/activities/storage_payment_initiations_adjusments_store.go b/internal/connectors/engine/activities/storage_payment_initiations_adjusments_store.go new file mode 100644 index 00000000..315f0672 --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiations_adjusments_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationsAdjustmentsStore(ctx context.Context, adj models.PaymentInitiationAdjustment) error { + return temporalStorageError(a.storage.PaymentInitiationAdjustmentsUpsert(ctx, adj)) +} + +var StoragePaymentInitiationsAdjustmentsStoreActivity = Activities{}.StoragePaymentInitiationsAdjustmentsStore + +func StoragePaymentInitiationsAdjustmentsStore(ctx workflow.Context, adj models.PaymentInitiationAdjustment) error { + return executeActivity(ctx, StoragePaymentInitiationsAdjustmentsStoreActivity, nil, adj) +} diff --git a/internal/connectors/engine/activities/storage_payment_initiations_delete.go b/internal/connectors/engine/activities/storage_payment_initiations_delete.go new file mode 100644 index 00000000..1809a6bc --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiations_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationsDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.PaymentInitiationsDeleteFromConnectorID(ctx, connectorID)) +} + +var StoragePaymentInitiationsDeleteActivity = Activities{}.StoragePaymentInitiationsDelete + +func StoragePaymentInitiationsDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StoragePaymentInitiationsDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_payment_initiations_get.go b/internal/connectors/engine/activities/storage_payment_initiations_get.go new file mode 100644 index 00000000..7fb921b5 --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiations_get.go @@ -0,0 +1,24 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationsGet(ctx context.Context, id models.PaymentInitiationID) (*models.PaymentInitiation, error) { + pi, err := a.storage.PaymentInitiationsGet(ctx, id) + if err != nil { + return nil, temporalStorageError(err) + } + return pi, nil +} + +var StoragePaymentInitiationsGetActivity = Activities{}.StoragePaymentInitiationsGet + +func StoragePaymentInitiationsGet(ctx workflow.Context, id models.PaymentInitiationID) (*models.PaymentInitiation, error) { + var result models.PaymentInitiation + err := executeActivity(ctx, StoragePaymentInitiationsGetActivity, &result, id) + return &result, err +} diff --git a/internal/connectors/engine/activities/storage_payment_initiations_ids_list_from_payment_id.go b/internal/connectors/engine/activities/storage_payment_initiations_ids_list_from_payment_id.go new file mode 100644 index 00000000..58364406 --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiations_ids_list_from_payment_id.go @@ -0,0 +1,26 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentInitiationIDsListFromPaymentID(ctx context.Context, paymentID models.PaymentID) ([]models.PaymentInitiationID, error) { + cursor, err := a.storage.PaymentInitiationIDsListFromPaymentID(ctx, paymentID) + if err != nil { + return nil, temporalStorageError(err) + } + return cursor, nil +} + +var StoragePaymentInitiationIDsListFromPaymentIDActivity = Activities{}.StoragePaymentInitiationIDsListFromPaymentID + +func StoragePaymentInitiationIDsListFromPaymentID(ctx workflow.Context, paymentID models.PaymentID) ([]models.PaymentInitiationID, error) { + ret := []models.PaymentInitiationID{} + if err := executeActivity(ctx, StoragePaymentInitiationIDsListFromPaymentIDActivity, &ret, paymentID); err != nil { + return nil, err + } + return ret, nil +} diff --git a/internal/connectors/engine/activities/storage_payment_initiations_related_payments_store.go b/internal/connectors/engine/activities/storage_payment_initiations_related_payments_store.go new file mode 100644 index 00000000..a1979098 --- /dev/null +++ b/internal/connectors/engine/activities/storage_payment_initiations_related_payments_store.go @@ -0,0 +1,29 @@ +package activities + +import ( + "context" + "time" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type RelatedPayment struct { + PiID models.PaymentInitiationID + PID models.PaymentID + CreatedAt time.Time +} + +func (a Activities) StoragePaymentInitiationsRelatedPaymentsStore(ctx context.Context, relatedPayment RelatedPayment) error { + return temporalStorageError(a.storage.PaymentInitiationRelatedPaymentsUpsert(ctx, relatedPayment.PiID, relatedPayment.PID, relatedPayment.CreatedAt)) +} + +var StoragePaymentInitiationsRelatedPaymentsStoreActivity = Activities{}.StoragePaymentInitiationsRelatedPaymentsStore + +func StoragePaymentInitiationsRelatedPaymentsStore(ctx workflow.Context, piID models.PaymentInitiationID, pID models.PaymentID, createdAt time.Time) error { + return executeActivity(ctx, StoragePaymentInitiationsRelatedPaymentsStoreActivity, nil, RelatedPayment{ + PiID: piID, + PID: pID, + CreatedAt: createdAt, + }) +} diff --git a/internal/connectors/engine/activities/storage_payments_delete.go b/internal/connectors/engine/activities/storage_payments_delete.go new file mode 100644 index 00000000..65fdfac4 --- /dev/null +++ b/internal/connectors/engine/activities/storage_payments_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentsDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.PaymentsDeleteFromConnectorID(ctx, connectorID)) +} + +var StoragePaymentsDeleteActivity = Activities{}.StoragePaymentsDelete + +func StoragePaymentsDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StoragePaymentsDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_payments_store.go b/internal/connectors/engine/activities/storage_payments_store.go new file mode 100644 index 00000000..71c5d20f --- /dev/null +++ b/internal/connectors/engine/activities/storage_payments_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePaymentsStore(ctx context.Context, payments []models.Payment) error { + return temporalStorageError(a.storage.PaymentsUpsert(ctx, payments)) +} + +var StoragePaymentsStoreActivity = Activities{}.StoragePaymentsStore + +func StoragePaymentsStore(ctx workflow.Context, payments []models.Payment) error { + return executeActivity(ctx, StoragePaymentsStoreActivity, nil, payments) +} diff --git a/internal/connectors/engine/activities/storage_pools_remove_accounts_from_connector_id.go b/internal/connectors/engine/activities/storage_pools_remove_accounts_from_connector_id.go new file mode 100644 index 00000000..b20b3e0d --- /dev/null +++ b/internal/connectors/engine/activities/storage_pools_remove_accounts_from_connector_id.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StoragePoolsRemoveAccountsFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.PoolsRemoveAccountsFromConnectorID(ctx, connectorID)) +} + +var StoragePoolsRemoveAccountsFromConnectorIDActivity = Activities{}.StoragePoolsRemoveAccountsFromConnectorID + +func StoragePoolsRemoveAccountsFromConnectorID(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StoragePoolsRemoveAccountsFromConnectorIDActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_schedules_delete.go b/internal/connectors/engine/activities/storage_schedules_delete.go new file mode 100644 index 00000000..57170fe6 --- /dev/null +++ b/internal/connectors/engine/activities/storage_schedules_delete.go @@ -0,0 +1,17 @@ +package activities + +import ( + "context" + + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageSchedulesDelete(ctx context.Context, scheduleID string) error { + return a.storage.SchedulesDelete(ctx, scheduleID) +} + +var StorageSchedulesDeleteActivity = Activities{}.StorageSchedulesDelete + +func StorageSchedulesDelete(ctx workflow.Context, scheduleID string) error { + return executeActivity(ctx, StorageSchedulesDeleteActivity, nil, scheduleID) +} diff --git a/internal/connectors/engine/activities/storage_schedules_delete_from_connector_id.go b/internal/connectors/engine/activities/storage_schedules_delete_from_connector_id.go new file mode 100644 index 00000000..0aad31e9 --- /dev/null +++ b/internal/connectors/engine/activities/storage_schedules_delete_from_connector_id.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageSchedulesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.SchedulesDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageSchedulesDeleteFromConnectorIDActivity = Activities{}.StorageSchedulesDeleteFromConnectorID + +func StorageSchedulesDeleteFromConnectorID(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageSchedulesDeleteFromConnectorIDActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_schedules_list.go b/internal/connectors/engine/activities/storage_schedules_list.go new file mode 100644 index 00000000..336e5df5 --- /dev/null +++ b/internal/connectors/engine/activities/storage_schedules_list.go @@ -0,0 +1,28 @@ +package activities + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageSchedulesList(ctx context.Context, query storage.ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) { + cursor, err := a.storage.SchedulesList(ctx, query) + if err != nil { + return nil, temporalStorageError(err) + } + return cursor, nil +} + +var StorageSchedulesListActivity = Activities{}.StorageSchedulesList + +func StorageSchedulesList(ctx workflow.Context, query storage.ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) { + ret := bunpaginate.Cursor[models.Schedule]{} + if err := executeActivity(ctx, StorageSchedulesListActivity, &ret, query); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/storage_schedules_store.go b/internal/connectors/engine/activities/storage_schedules_store.go new file mode 100644 index 00000000..0e78f09a --- /dev/null +++ b/internal/connectors/engine/activities/storage_schedules_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageSchedulesStore(ctx context.Context, schedule models.Schedule) error { + return temporalStorageError(a.storage.SchedulesUpsert(ctx, schedule)) +} + +var StorageSchedulesStoreActivity = Activities{}.StorageSchedulesStore + +func StorageSchedulesStore(ctx workflow.Context, schedule models.Schedule) error { + return executeActivity(ctx, StorageSchedulesStoreActivity, nil, schedule) +} diff --git a/internal/connectors/engine/activities/storage_states_delete.go b/internal/connectors/engine/activities/storage_states_delete.go new file mode 100644 index 00000000..b064c1e7 --- /dev/null +++ b/internal/connectors/engine/activities/storage_states_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageStatesDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.StatesDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageStatesDeleteActivity = Activities{}.StorageStatesDelete + +func StorageStatesDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageStatesDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_states_get.go b/internal/connectors/engine/activities/storage_states_get.go new file mode 100644 index 00000000..dc91aae9 --- /dev/null +++ b/internal/connectors/engine/activities/storage_states_get.go @@ -0,0 +1,35 @@ +package activities + +import ( + "context" + "errors" + + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageStatesGet(ctx context.Context, id models.StateID) (*models.State, error) { + resp, err := a.storage.StatesGet(ctx, id) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return &models.State{ + ID: id, + ConnectorID: id.ConnectorID, + State: nil, + }, nil + } + return nil, temporalStorageError(err) + } + return &resp, nil +} + +var StorageStatesGetActivity = Activities{}.StorageStatesGet + +func StorageStatesGet(ctx workflow.Context, id models.StateID) (*models.State, error) { + ret := models.State{} + if err := executeActivity(ctx, StorageStatesGetActivity, &ret, id); err != nil { + return nil, err + } + return &ret, nil +} diff --git a/internal/connectors/engine/activities/storage_states_store.go b/internal/connectors/engine/activities/storage_states_store.go new file mode 100644 index 00000000..ce98650d --- /dev/null +++ b/internal/connectors/engine/activities/storage_states_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageStatesStore(ctx context.Context, state models.State) error { + return temporalStorageError(a.storage.StatesUpsert(ctx, state)) +} + +var StorageStatesStoreActivity = Activities{}.StorageStatesStore + +func StorageStatesStore(ctx workflow.Context, state models.State) error { + return executeActivity(ctx, StorageStatesStoreActivity, nil, state) +} diff --git a/internal/connectors/engine/activities/storage_tasks_delete.go b/internal/connectors/engine/activities/storage_tasks_delete.go new file mode 100644 index 00000000..349a9659 --- /dev/null +++ b/internal/connectors/engine/activities/storage_tasks_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageTasksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.TasksDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageTasksDeleteFromConnectorIDActivity = Activities{}.StorageTasksDeleteFromConnectorID + +func StorageTasksDeleteFromConnectorID(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageTasksDeleteFromConnectorIDActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_tasks_store.go b/internal/connectors/engine/activities/storage_tasks_store.go new file mode 100644 index 00000000..4c088a55 --- /dev/null +++ b/internal/connectors/engine/activities/storage_tasks_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageTasksStore(ctx context.Context, task models.Task) error { + return temporalStorageError(a.storage.TasksUpsert(ctx, task)) +} + +var StorageTasksStoreActivity = Activities{}.StorageTasksStore + +func StorageTasksStore(ctx workflow.Context, task models.Task) error { + return executeActivity(ctx, StorageTasksStoreActivity, nil, task) +} diff --git a/internal/connectors/engine/activities/storage_webhooks_configs_delete.go b/internal/connectors/engine/activities/storage_webhooks_configs_delete.go new file mode 100644 index 00000000..36c0ae11 --- /dev/null +++ b/internal/connectors/engine/activities/storage_webhooks_configs_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageWebhooksConfigsDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.WebhooksConfigsDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageWebhooksConfigsDeleteActivity = Activities{}.StorageWebhooksConfigsDelete + +func StorageWebhooksConfigsDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageWebhooksConfigsDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_webhooks_configs_get.go b/internal/connectors/engine/activities/storage_webhooks_configs_get.go new file mode 100644 index 00000000..377e6748 --- /dev/null +++ b/internal/connectors/engine/activities/storage_webhooks_configs_get.go @@ -0,0 +1,24 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageWebhooksConfigsGet(ctx context.Context, connectorID models.ConnectorID) ([]models.WebhookConfig, error) { + configs, err := a.storage.WebhooksConfigsGetFromConnectorID(ctx, connectorID) + if err != nil { + return nil, temporalStorageError(err) + } + return configs, nil +} + +var StorageWebhooksConfigsGetActivity = Activities{}.StorageWebhooksConfigsGet + +func StorageWebhooksConfigsGet(ctx workflow.Context, connectorID models.ConnectorID) ([]models.WebhookConfig, error) { + var res []models.WebhookConfig + err := executeActivity(ctx, StorageWebhooksConfigsGetActivity, &res, connectorID) + return res, err +} diff --git a/internal/connectors/engine/activities/storage_webhooks_configs_store.go b/internal/connectors/engine/activities/storage_webhooks_configs_store.go new file mode 100644 index 00000000..9b6358d5 --- /dev/null +++ b/internal/connectors/engine/activities/storage_webhooks_configs_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageWebhooksConfigsStore(ctx context.Context, configs []models.WebhookConfig) error { + return temporalStorageError(a.storage.WebhooksConfigsUpsert(ctx, configs)) +} + +var StorageWebhooksConfigsStoreActivity = Activities{}.StorageWebhooksConfigsStore + +func StorageWebhooksConfigsStore(ctx workflow.Context, configs []models.WebhookConfig) error { + return executeActivity(ctx, StorageWebhooksConfigsStoreActivity, nil, configs) +} diff --git a/internal/connectors/engine/activities/storage_webhooks_delete.go b/internal/connectors/engine/activities/storage_webhooks_delete.go new file mode 100644 index 00000000..2227d9d9 --- /dev/null +++ b/internal/connectors/engine/activities/storage_webhooks_delete.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageWebhooksDelete(ctx context.Context, connectorID models.ConnectorID) error { + return temporalStorageError(a.storage.WebhooksDeleteFromConnectorID(ctx, connectorID)) +} + +var StorageWebhooksDeleteActivity = Activities{}.StorageWebhooksDelete + +func StorageWebhooksDelete(ctx workflow.Context, connectorID models.ConnectorID) error { + return executeActivity(ctx, StorageWebhooksDeleteActivity, nil, connectorID) +} diff --git a/internal/connectors/engine/activities/storage_webhooks_store.go b/internal/connectors/engine/activities/storage_webhooks_store.go new file mode 100644 index 00000000..090b2669 --- /dev/null +++ b/internal/connectors/engine/activities/storage_webhooks_store.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) StorageWebhooksStore(ctx context.Context, webhook models.Webhook) error { + return temporalStorageError(a.storage.WebhooksInsert(ctx, webhook)) +} + +var StorageWebhooksStoreActivity = Activities{}.StorageWebhooksStore + +func StorageWebhooksStore(ctx workflow.Context, webhook models.Webhook) error { + return executeActivity(ctx, StorageWebhooksStoreActivity, nil, webhook) +} diff --git a/internal/connectors/engine/activities/temporal_delete_schedule.go b/internal/connectors/engine/activities/temporal_delete_schedule.go new file mode 100644 index 00000000..6d35d1e9 --- /dev/null +++ b/internal/connectors/engine/activities/temporal_delete_schedule.go @@ -0,0 +1,18 @@ +package activities + +import ( + "context" + + "go.temporal.io/sdk/workflow" +) + +func (a Activities) TemporalDeleteSchedule(ctx context.Context, scheduleID string) error { + handle := a.temporalClient.ScheduleClient().GetHandle(ctx, scheduleID) + return handle.Delete(ctx) +} + +var TemporalDeleteScheduleActivity = Activities{}.TemporalDeleteSchedule + +func TemporalDeleteSchedule(ctx workflow.Context, scheduleID string) error { + return executeActivity(ctx, TemporalDeleteScheduleActivity, nil, scheduleID) +} diff --git a/internal/connectors/engine/activities/temporal_schedule_create.go b/internal/connectors/engine/activities/temporal_schedule_create.go new file mode 100644 index 00000000..c33d5f25 --- /dev/null +++ b/internal/connectors/engine/activities/temporal_schedule_create.go @@ -0,0 +1,62 @@ +package activities + +import ( + "context" + "time" + + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type ScheduleCreateOptions struct { + ScheduleID string + Interval client.ScheduleIntervalSpec + Action client.ScheduleWorkflowAction + Overlap enums.ScheduleOverlapPolicy + Jitter time.Duration + TriggerImmediately bool + SearchAttributes map[string]interface{} +} + +func (a Activities) TemporalScheduleCreate(ctx context.Context, options ScheduleCreateOptions) (string, error) { + attributes := make([]temporal.SearchAttributeUpdate, 0, len(options.SearchAttributes)) + for key, value := range options.SearchAttributes { + v, ok := value.(string) + if !ok { + continue + } + + attributes = append(attributes, + temporal.NewSearchAttributeKeyKeyword(key).ValueSet(v), + ) + } + options.Action.TypedSearchAttributes = temporal.NewSearchAttributes(attributes...) + + handle, err := a.temporalClient.ScheduleClient().Create(ctx, client.ScheduleOptions{ + ID: options.ScheduleID, + Spec: client.ScheduleSpec{ + Intervals: []client.ScheduleIntervalSpec{options.Interval}, + Jitter: options.Jitter, + }, + Action: &options.Action, + Overlap: options.Overlap, + TriggerImmediately: options.TriggerImmediately, + SearchAttributes: options.SearchAttributes, + }) + if err != nil { + return "", err + } + return handle.GetID(), nil +} + +var TemporalScheduleCreateActivity = Activities{}.TemporalScheduleCreate + +func TemporalScheduleCreate(ctx workflow.Context, options ScheduleCreateOptions) (string, error) { + var scheduleID string + if err := executeActivity(ctx, TemporalScheduleCreateActivity, &scheduleID, options); err != nil { + return "", err + } + return scheduleID, nil +} diff --git a/internal/connectors/engine/activities/temporal_schedule_delete.go b/internal/connectors/engine/activities/temporal_schedule_delete.go new file mode 100644 index 00000000..f73b87e7 --- /dev/null +++ b/internal/connectors/engine/activities/temporal_schedule_delete.go @@ -0,0 +1,34 @@ +package activities + +import ( + "context" + "errors" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) TemporalScheduleDelete(ctx context.Context, scheduleID string) error { + handle := a.temporalClient.ScheduleClient().GetHandle(ctx, scheduleID) + err := handle.Delete(ctx) + if err != nil { + var applicationErr *temporal.ApplicationError + if errors.As(err, &applicationErr) { + switch applicationErr.Type() { + case "NotFound": + return nil + default: + return err + } + } else { + return err + } + } + return nil +} + +var TemporalScheduleDeleteActivity = Activities{}.TemporalScheduleDelete + +func TemporalScheduleDelete(ctx workflow.Context, scheduleID string) error { + return executeActivity(ctx, TemporalScheduleDeleteActivity, nil, scheduleID) +} diff --git a/internal/connectors/engine/activities/temporal_workflow_executions_list.go b/internal/connectors/engine/activities/temporal_workflow_executions_list.go new file mode 100644 index 00000000..4365fc72 --- /dev/null +++ b/internal/connectors/engine/activities/temporal_workflow_executions_list.go @@ -0,0 +1,22 @@ +package activities + +import ( + "context" + + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) TemporalWorkflowExecutionsList(ctx context.Context, req *workflowservice.ListWorkflowExecutionsRequest) (*workflowservice.ListWorkflowExecutionsResponse, error) { + return a.temporalClient.WorkflowService().ListWorkflowExecutions(ctx, req) +} + +var TemporalWorkflowExecutionsListActivity = Activities{}.TemporalWorkflowExecutionsList + +func TemporalWorkflowExecutionsList(ctx workflow.Context, req *workflowservice.ListWorkflowExecutionsRequest) (*workflowservice.ListWorkflowExecutionsResponse, error) { + var resp workflowservice.ListWorkflowExecutionsResponse + if err := executeActivity(ctx, TemporalWorkflowExecutionsListActivity, &resp, req); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/internal/connectors/engine/activities/temporal_workflow_terminate.go b/internal/connectors/engine/activities/temporal_workflow_terminate.go new file mode 100644 index 00000000..48d7450c --- /dev/null +++ b/internal/connectors/engine/activities/temporal_workflow_terminate.go @@ -0,0 +1,33 @@ +package activities + +import ( + "context" + + "go.temporal.io/api/serviceerror" + "go.temporal.io/sdk/workflow" +) + +func (a Activities) TemporalWorkflowTerminate(ctx context.Context, workflowID string, runID string, reason string) error { + err := a.temporalClient.TerminateWorkflow( + ctx, + workflowID, + runID, + reason, + ) + if err != nil { + switch err.(type) { + case *serviceerror.NotFound: + // Do nothing, the workflow is already terminated + return nil + default: + return err + } + } + return nil +} + +var TemporalWorkflowTerminateActivity = Activities{}.TemporalWorkflowTerminate + +func TemporalWorkflowTerminate(ctx workflow.Context, workflowID string, runID string, reason string) error { + return executeActivity(ctx, TemporalWorkflowTerminateActivity, nil, workflowID, runID, reason) +} diff --git a/internal/connectors/engine/engine.go b/internal/connectors/engine/engine.go new file mode 100644 index 00000000..2db1df99 --- /dev/null +++ b/internal/connectors/engine/engine.go @@ -0,0 +1,1044 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + "github.com/formancehq/payments/internal/connectors/engine/webhooks" + "github.com/formancehq/payments/internal/connectors/engine/workflow" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "github.com/formancehq/payments/internal/storage" + "github.com/google/uuid" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/client" +) + +type Engine interface { + // Install a connector with the given provider and configuration. + InstallConnector(ctx context.Context, provider string, rawConfig json.RawMessage) (models.ConnectorID, error) + // Uninstall a connector with the given ID. + UninstallConnector(ctx context.Context, connectorID models.ConnectorID) error + // Reset a connector with the given ID, by uninstalling and reinstalling it. + ResetConnector(ctx context.Context, connectorID models.ConnectorID) error + + // Create a Formance account, no call to the plugin, just a creation + // of an account in the database related to the provided connector id. + CreateFormanceAccount(ctx context.Context, account models.Account) error + // Create a Formance payment, no call to the plugin, just a creation + // of a payment in the database related to the provided connector id. + CreateFormancePayment(ctx context.Context, payment models.Payment) error + + // Forward a bank account to the given connector, which will create it + // in the external system (PSP). + ForwardBankAccount(ctx context.Context, bankAccountID uuid.UUID, connectorID models.ConnectorID, waitResult bool) (models.Task, error) + // Create a transfer between two accounts on the given connector (PSP). + CreateTransfer(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error) + // Reverse a transfer on the given connector (PSP). + ReverseTransfer(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) + // Create a payout on the given connector (PSP). + CreatePayout(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error) + // Reverse a payout on the given connector (PSP). + ReversePayout(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) + + // We received a webhook, handle it by calling the corresponding plugin to + // translate it to a formance object and store it. + HandleWebhook(ctx context.Context, urlPath string, webhook models.Webhook) error + + // Create a Formance pool composed of accounts. + CreatePool(ctx context.Context, pool models.Pool) error + // Add an account to a Formance pool. + AddAccountToPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error + // Remove an account from a Formance pool. + RemoveAccountFromPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error + // Delete a Formance pool. + DeletePool(ctx context.Context, poolID uuid.UUID) error + + // Called when the engine is starting, to start all the plugins. + OnStart(ctx context.Context) error + // Called when the engine is stopping, to stop all the plugins. + OnStop(ctx context.Context) +} + +type engine struct { + logger logging.Logger + + temporalClient client.Client + + workers *Workers + plugins plugins.Plugins + storage storage.Storage + + stack string + + wg sync.WaitGroup +} + +func New( + logger logging.Logger, + temporalClient client.Client, + workers *Workers, + plugins plugins.Plugins, + storage storage.Storage, + webhooks webhooks.Webhooks, + stack string, +) Engine { + return &engine{ + logger: logger, + temporalClient: temporalClient, + workers: workers, + plugins: plugins, + storage: storage, + stack: stack, + wg: sync.WaitGroup{}, + } +} + +func (e *engine) InstallConnector(ctx context.Context, provider string, rawConfig json.RawMessage) (models.ConnectorID, error) { + ctx, span := otel.Tracer().Start(ctx, "engine.InstallConnector") + defer span.End() + + config := models.DefaultConfig() + if err := json.Unmarshal(rawConfig, &config); err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, err + } + + if err := config.Validate(); err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, errors.Wrap(ErrValidation, err.Error()) + } + + connector := models.Connector{ + ID: models.ConnectorID{ + Reference: uuid.New(), + Provider: provider, + }, + Name: config.Name, + CreatedAt: time.Now().UTC(), + Provider: provider, + Config: rawConfig, + } + + // Detached the context to avoid being in a weird state if request is + // cancelled in the middle of the operation. + detachedCtx := context.WithoutCancel(ctx) + // Since we detached the context, we need to wait for the operation to finish + // even if the app is shutting down gracefully. + e.wg.Add(1) + defer e.wg.Done() + + if err := e.storage.ConnectorsInstall(detachedCtx, connector); err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, err + } + + err := e.plugins.RegisterPlugin(connector.ID, connector.Name, config, rawConfig) + if err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, handlePluginError(err) + } + + err = e.workers.AddWorker(e.getConnectorTaskQueue(connector.ID)) + if err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, err + } + + // Launch the workflow + run, err := e.temporalClient.ExecuteWorkflow( + detachedCtx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("install-%s-%s", e.stack, connector.ID.String()), + TaskQueue: e.getConnectorTaskQueue(connector.ID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunInstallConnector, + workflow.InstallConnector{ + ConnectorID: connector.ID, + Config: config, + }, + ) + if err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, err + } + + // Wait for installation to complete in order to return connector ID through API + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return models.ConnectorID{}, err + } + + return connector.ID, nil +} + +func (e *engine) UninstallConnector(ctx context.Context, connectorID models.ConnectorID) error { + ctx, span := otel.Tracer().Start(ctx, "engine.UninstallConnector") + defer span.End() + + if err := e.workers.RemoveWorker(connectorID.String()); err != nil { + otel.RecordError(span, err) + return err + } + + if err := e.storage.ConnectorsScheduleForDeletion(ctx, connectorID); err != nil { + otel.RecordError(span, err) + return err + } + + // Launch the uninstallation in background + _, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("uninstall-%s-%s", e.stack, connectorID.String()), + TaskQueue: e.workers.GetDefaultWorker(), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunUninstallConnector, + workflow.UninstallConnector{ + ConnectorID: connectorID, + DefaultWorkerName: e.workers.GetDefaultWorker(), + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) ResetConnector(ctx context.Context, connectorID models.ConnectorID) error { + ctx, span := otel.Tracer().Start(ctx, "engine.ResetConnector") + defer span.End() + + connector, err := e.storage.ConnectorsGet(ctx, connectorID) + if err != nil { + otel.RecordError(span, err) + return err + } + + // Detached the context to avoid being in a weird state if request is + // cancelled in the middle of the operation. + detachedCtx := context.WithoutCancel(ctx) + // Since we detached the context, we need to wait for the operation to finish + // even if the app is shutting down gracefully. + e.wg.Add(1) + defer e.wg.Done() + + if err := e.UninstallConnector(detachedCtx, connectorID); err != nil { + otel.RecordError(span, err) + return err + } + + _, err = e.InstallConnector(detachedCtx, connectorID.Provider, connector.Config) + if err != nil { + otel.RecordError(span, err) + return err + } + + run, err := e.temporalClient.ExecuteWorkflow( + detachedCtx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("reset-%s-%s", e.stack, connectorID.String()), + TaskQueue: e.getConnectorTaskQueue(connectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + ConnectorReset: &connectorID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return err + } + return nil +} + +func (e *engine) CreateFormanceAccount(ctx context.Context, account models.Account) error { + ctx, span := otel.Tracer().Start(ctx, "engine.CreateFormanceAccount") + defer span.End() + + capabilities, err := registry.GetCapabilities(account.ConnectorID.Provider) + if err != nil { + otel.RecordError(span, err) + return err + } + + found := false + for _, c := range capabilities { + if c == models.CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION { + found = true + break + } + } + + if !found { + err := errors.New("connector does not support account creation") + otel.RecordError(span, err) + return err + } + + if err := e.storage.AccountsUpsert(ctx, []models.Account{account}); err != nil { + otel.RecordError(span, err) + return err + } + + // Do not wait for sending of events + _, err = e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("create-formance-account-send-events-%s-%s-%s", e.stack, account.ConnectorID.String(), account.Reference), + TaskQueue: e.getConnectorTaskQueue(account.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + Account: &account, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) CreateFormancePayment(ctx context.Context, payment models.Payment) error { + ctx, span := otel.Tracer().Start(ctx, "engine.CreateFormancePayment") + defer span.End() + + capabilities, err := registry.GetCapabilities(payment.ConnectorID.Provider) + if err != nil { + otel.RecordError(span, err) + return err + } + + found := false + for _, c := range capabilities { + if c == models.CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION { + found = true + break + } + } + + if !found { + err := errors.New("connector does not support payment creation") + otel.RecordError(span, err) + return err + } + + if err := e.storage.PaymentsUpsert(ctx, []models.Payment{payment}); err != nil { + otel.RecordError(span, err) + return err + } + + // Do not wait for sending of events + _, err = e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("create-formance-payment-send-events-%s-%s-%s", e.stack, payment.ConnectorID.String(), payment.Reference), + TaskQueue: e.getConnectorTaskQueue(payment.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + Payment: &payment, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) ForwardBankAccount(ctx context.Context, bankAccountID uuid.UUID, connectorID models.ConnectorID, waitResult bool) (models.Task, error) { + ctx, span := otel.Tracer().Start(ctx, "engine.ForwardBankAccount") + defer span.End() + + id := models.TaskIDReference(fmt.Sprintf("create-bank-account-%s", e.stack), connectorID, bankAccountID.String()) + + now := time.Now().UTC() + task := models.Task{ + ID: models.TaskID{ + Reference: id, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Status: models.TASK_STATUS_PROCESSING, + CreatedAt: now, + UpdatedAt: now, + } + + if err := e.storage.TasksUpsert(ctx, task); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + run, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: id, + TaskQueue: e.getConnectorTaskQueue(connectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunCreateBankAccount, + workflow.CreateBankAccount{ + TaskID: task.ID, + ConnectorID: connectorID, + BankAccountID: bankAccountID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + if waitResult { + // Wait for bank account creation to complete + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + } + + return task, nil +} + +func (e *engine) CreateTransfer(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error) { + ctx, span := otel.Tracer().Start(ctx, "engine.CreateTransfer") + defer span.End() + + id := models.TaskIDReference(fmt.Sprintf("create-transfer-%s-%d", e.stack, attempt), piID.ConnectorID, piID.String()) + + now := time.Now().UTC() + task := models.Task{ + ID: models.TaskID{ + Reference: id, + ConnectorID: piID.ConnectorID, + }, + ConnectorID: piID.ConnectorID, + Status: models.TASK_STATUS_PROCESSING, + CreatedAt: now, + UpdatedAt: now, + } + + if err := e.storage.TasksUpsert(ctx, task); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + run, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: id, + TaskQueue: e.getConnectorTaskQueue(piID.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunCreateTransfer, + workflow.CreateTransfer{ + TaskID: task.ID, + ConnectorID: piID.ConnectorID, + PaymentInitiationID: piID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + if waitResult { + // Wait for bank account creation to complete + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + } + + return task, nil +} + +func (e *engine) ReverseTransfer(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) { + ctx, span := otel.Tracer().Start(ctx, "engine.ReverseTransfer") + defer span.End() + + detachedCtx := context.WithoutCancel(ctx) + e.wg.Add(1) + defer e.wg.Done() + + id := models.TaskIDReference(fmt.Sprintf("reverse-transfer-%s-%s", e.stack, reversal.CreatedAt.String()), reversal.ConnectorID, reversal.ID.String()) + now := time.Now().UTC() + task := models.Task{ + ID: models.TaskID{ + Reference: id, + ConnectorID: reversal.ConnectorID, + }, + ConnectorID: reversal.ConnectorID, + Status: models.TASK_STATUS_PROCESSING, + CreatedAt: now, + UpdatedAt: now, + } + if err := e.storage.TasksUpsert(detachedCtx, task); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + run, err := e.temporalClient.ExecuteWorkflow( + detachedCtx, + client.StartWorkflowOptions{ + ID: id, + TaskQueue: e.getConnectorTaskQueue(reversal.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunReverseTransfer, + workflow.ReverseTransfer{ + TaskID: task.ID, + ConnectorID: reversal.ConnectorID, + PaymentInitiationReversalID: reversal.ID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + if waitResult { + // Wait for bank account creation to complete + // use ctx instead of detachedCtx to allow the caller to cancel the operation + // and not wait for the result + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + } + + return task, nil +} + +func (e *engine) CreatePayout(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error) { + ctx, span := otel.Tracer().Start(ctx, "engine.CreatePayout") + defer span.End() + + id := models.TaskIDReference(fmt.Sprintf("create-payout-%s-%d", e.stack, attempt), piID.ConnectorID, piID.String()) + + now := time.Now().UTC() + task := models.Task{ + ID: models.TaskID{ + Reference: id, + ConnectorID: piID.ConnectorID, + }, + ConnectorID: piID.ConnectorID, + Status: models.TASK_STATUS_PROCESSING, + CreatedAt: now, + UpdatedAt: now, + } + + if err := e.storage.TasksUpsert(ctx, task); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + run, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: id, + TaskQueue: e.getConnectorTaskQueue(piID.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunCreatePayout, + workflow.CreatePayout{ + TaskID: task.ID, + ConnectorID: piID.ConnectorID, + PaymentInitiationID: piID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + if waitResult { + // Wait for bank account creation to complete + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + } + + return task, nil +} + +func (e *engine) ReversePayout(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) { + ctx, span := otel.Tracer().Start(ctx, "engine.ReversePayout") + defer span.End() + + detachedCtx := context.WithoutCancel(ctx) + e.wg.Add(1) + defer e.wg.Done() + + id := models.TaskIDReference(fmt.Sprintf("reverse-payout-%s-%s", e.stack, reversal.CreatedAt.String()), reversal.ConnectorID, reversal.ID.String()) + now := time.Now().UTC() + task := models.Task{ + ID: models.TaskID{ + Reference: id, + ConnectorID: reversal.ConnectorID, + }, + ConnectorID: reversal.ConnectorID, + Status: models.TASK_STATUS_PROCESSING, + CreatedAt: now, + UpdatedAt: now, + } + + if err := e.storage.TasksUpsert(detachedCtx, task); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + run, err := e.temporalClient.ExecuteWorkflow( + detachedCtx, + client.StartWorkflowOptions{ + ID: id, + TaskQueue: e.getConnectorTaskQueue(reversal.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunReversePayout, + workflow.ReversePayout{ + TaskID: task.ID, + ConnectorID: reversal.ConnectorID, + PaymentInitiationReversalID: reversal.ID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + + if waitResult { + // Wait for bank account creation to complete + // use ctx instead of detachedCtx to allow the caller to cancel the operation + // and not wait for the result + if err := run.Get(ctx, nil); err != nil { + otel.RecordError(span, err) + return models.Task{}, err + } + } + + return task, nil +} + +func (e *engine) HandleWebhook(ctx context.Context, urlPath string, webhook models.Webhook) error { + ctx, span := otel.Tracer().Start(ctx, "engine.HandleWebhook") + defer span.End() + + if _, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("webhook-%s-%s-%s", e.stack, webhook.ConnectorID.String(), webhook.ID), + TaskQueue: e.getConnectorTaskQueue(webhook.ConnectorID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunHandleWebhooks, + workflow.HandleWebhooks{ + ConnectorID: webhook.ConnectorID, + URLPath: urlPath, + Webhook: webhook, + }, + ); err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) CreatePool(ctx context.Context, pool models.Pool) error { + ctx, span := otel.Tracer().Start(ctx, "engine.CreatePool") + defer span.End() + + if err := e.storage.PoolsUpsert(ctx, pool); err != nil { + otel.RecordError(span, err) + return err + } + + // Do not wait for sending of events + _, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("pools-creation-%s-%s", e.stack, pool.IdempotencyKey()), + TaskQueue: e.workers.GetDefaultWorker(), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + PoolsCreation: &pool, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) AddAccountToPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + ctx, span := otel.Tracer().Start(ctx, "engine.AddAccountToPool") + defer span.End() + + if err := e.storage.PoolsAddAccount(ctx, id, accountID); err != nil { + otel.RecordError(span, err) + return err + } + + detachedCtx := context.WithoutCancel(ctx) + // Since we detached the context, we need to wait for the operation to finish + // even if the app is shutting down gracefully. + e.wg.Add(1) + defer e.wg.Done() + + pool, err := e.storage.PoolsGet(detachedCtx, id) + if err != nil { + otel.RecordError(span, err) + return err + } + + // Do not wait for sending of events + _, err = e.temporalClient.ExecuteWorkflow( + detachedCtx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("pools-add-account-%s-%s", e.stack, pool.IdempotencyKey()), + TaskQueue: e.workers.GetDefaultWorker(), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + PoolsCreation: pool, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) RemoveAccountFromPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + ctx, span := otel.Tracer().Start(ctx, "engine.RemoveAccountFromPool") + defer span.End() + + if err := e.storage.PoolsRemoveAccount(ctx, id, accountID); err != nil { + otel.RecordError(span, err) + return err + } + + detachedCtx := context.WithoutCancel(ctx) + // Since we detached the context, we need to wait for the operation to finish + // even if the app is shutting down gracefully. + e.wg.Add(1) + defer e.wg.Done() + + pool, err := e.storage.PoolsGet(detachedCtx, id) + if err != nil { + otel.RecordError(span, err) + return err + } + + // Do not wait for sending of events + _, err = e.temporalClient.ExecuteWorkflow( + detachedCtx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("pools-remove-account-%s-%s", e.stack, pool.IdempotencyKey()), + TaskQueue: e.workers.GetDefaultWorker(), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + PoolsCreation: pool, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) DeletePool(ctx context.Context, poolID uuid.UUID) error { + ctx, span := otel.Tracer().Start(ctx, "engine.DeletePool") + defer span.End() + + if err := e.storage.PoolsDelete(ctx, poolID); err != nil { + otel.RecordError(span, err) + return err + } + + // Do not wait for sending of events + _, err := e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("pools-deletion-%s-%s", e.stack, poolID.String()), + TaskQueue: e.workers.GetDefaultWorker(), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunSendEvents, + workflow.SendEvents{ + PoolsDeletion: &poolID, + }, + ) + if err != nil { + otel.RecordError(span, err) + return err + } + + return nil +} + +func (e *engine) OnStop(ctx context.Context) { + waitingChan := make(chan struct{}) + go func() { + e.wg.Wait() + close(waitingChan) + }() + + select { + case <-waitingChan: + case <-ctx.Done(): + } +} + +func (e *engine) OnStart(ctx context.Context) error { + e.storage.ListenConnectorsChanges(ctx, storage.HandlerConnectorsChanges{ + storage.ConnectorChangesInsert: e.onInsertPlugin, + storage.ConnectorChangesUpdate: e.onUpdatePlugin, + storage.ConnectorChangesDelete: e.onDeletePlugin, + }) + + query := storage.NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.ConnectorQuery{}). + WithPageSize(100), + ) + + for { + connectors, err := e.storage.ConnectorsList(ctx, query) + if err != nil { + return err + } + + for _, connector := range connectors.Data { + if err := e.onStartPlugin(ctx, connector); err != nil { + return err + } + } + + if !connectors.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(connectors.Next, &query) + if err != nil { + return err + } + } + + return nil +} + +func (e *engine) onInsertPlugin(ctx context.Context, connectorID models.ConnectorID) error { + connector, err := e.storage.ConnectorsGet(ctx, connectorID) + if err != nil { + return err + } + + config := models.DefaultConfig() + if err := json.Unmarshal(connector.Config, &config); err != nil { + return err + } + + if err := e.plugins.RegisterPlugin(connector.ID, connector.Name, config, connector.Config); err != nil { + return err + } + + if err := e.workers.AddWorker(e.getConnectorTaskQueue(connector.ID)); err != nil { + return err + } + + return nil +} + +func (e *engine) onUpdatePlugin(ctx context.Context, connectorID models.ConnectorID) error { + connector, err := e.storage.ConnectorsGet(ctx, connectorID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return e.onDeletePlugin(ctx, connectorID) + } + return err + } + + // Only react to scheduled for deletion changes + if !connector.ScheduledForDeletion { + return nil + } + + if err := e.workers.RemoveWorker(connectorID.String()); err != nil { + return err + } + + return nil +} + +func (e *engine) onDeletePlugin(ctx context.Context, connectorID models.ConnectorID) error { + if err := e.plugins.UnregisterPlugin(connectorID); err != nil { + return err + } + + if err := e.workers.RemoveWorker(connectorID.String()); err != nil { + return err + } + + return nil +} + +func (e *engine) onStartPlugin(ctx context.Context, connector models.Connector) error { + defer func() { + // errors or not, we still need to start the default worker if + // the connector is scheduled for deletion + if connector.ScheduledForDeletion { + e.workers.GetDefaultWorker() + } + }() + + // Even if the connector is scheduled for deletion, we still need to register + // the plugin to be able to handle the uninstallation. + // It will be unregistered when the uninstallation is done in the workflow + // after the deletion of the connector entry in the database. + config := models.DefaultConfig() + if err := json.Unmarshal(connector.Config, &config); err != nil { + return err + } + + err := e.plugins.RegisterPlugin(connector.ID, connector.Name, config, connector.Config) + if err != nil { + e.logger.Errorf("failed to register plugin: %w", err) + // We don't want to crash the pod if the plugin registration fails, + // otherwise, the client will not be able to remove the failing + // connector from the database because of the crashes. + // We just log the error and continue. + return nil + } + + if !connector.ScheduledForDeletion { + err = e.workers.AddWorker(e.getConnectorTaskQueue(connector.ID)) + if err != nil { + return err + } + + // Launch the workflow + _, err = e.temporalClient.ExecuteWorkflow( + ctx, + client.StartWorkflowOptions{ + ID: fmt.Sprintf("install-%s-%s", e.stack, connector.ID.String()), + TaskQueue: e.getConnectorTaskQueue(connector.ID), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowExecutionErrorWhenAlreadyStarted: false, + SearchAttributes: map[string]interface{}{ + workflow.SearchAttributeStack: e.stack, + }, + }, + workflow.RunInstallConnector, + workflow.InstallConnector{ + ConnectorID: connector.ID, + Config: config, + }, + ) + if err != nil { + return err + } + } + + return nil +} + +func (e *engine) getConnectorTaskQueue(connectorID models.ConnectorID) string { + return fmt.Sprintf("%s-%s", e.stack, connectorID.String()) +} + +var _ Engine = &engine{} diff --git a/internal/connectors/engine/errors.go b/internal/connectors/engine/errors.go new file mode 100644 index 00000000..b557608d --- /dev/null +++ b/internal/connectors/engine/errors.go @@ -0,0 +1,28 @@ +package engine + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/plugins" + "github.com/pkg/errors" +) + +var ( + ErrValidation = errors.New("validation error") + ErrNotFound = errors.New("not found") +) + +func handlePluginError(err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, plugins.ErrNotFound): + return fmt.Errorf("%w: %w", ErrNotFound, err) + case errors.Is(err, plugins.ErrValidation): + return fmt.Errorf("%w: %w", ErrValidation, err) + default: + return err + } +} diff --git a/internal/connectors/engine/module.go b/internal/connectors/engine/module.go new file mode 100644 index 00000000..dd4f92fb --- /dev/null +++ b/internal/connectors/engine/module.go @@ -0,0 +1,90 @@ +package engine + +import ( + "context" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/temporal" + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + "github.com/formancehq/payments/internal/connectors/engine/webhooks" + "github.com/formancehq/payments/internal/connectors/engine/workflow" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" + "go.uber.org/fx" +) + +func Module( + stack string, + stackURL string, + temporalNamespace string, + temporalMaxConcurrentWorkflowTaskPollers int, + rawFlags []string, + debug bool, + jsonFormatter bool, +) fx.Option { + ret := []fx.Option{ + fx.Supply(worker.Options{ + MaxConcurrentWorkflowTaskPollers: temporalMaxConcurrentWorkflowTaskPollers, + }), + fx.Provide(func( + logger logging.Logger, + temporalClient client.Client, + workers *Workers, + plugins plugins.Plugins, + storage storage.Storage, + webhooks webhooks.Webhooks, + ) Engine { + return New(logger, temporalClient, workers, plugins, storage, webhooks, stack) + }), + fx.Provide(func(publisher message.Publisher) *events.Events { + return events.New(publisher, stackURL) + }), + fx.Provide(func(logger logging.Logger) plugins.Plugins { + return plugins.New(logger, rawFlags, debug, jsonFormatter) + }), + fx.Provide(func() webhooks.Webhooks { + return webhooks.New() + }), + fx.Provide(func(temporalClient client.Client, plugins plugins.Plugins, webhooks webhooks.Webhooks) workflow.Workflow { + return workflow.New(temporalClient, temporalNamespace, plugins, webhooks, stack, stackURL) + }), + fx.Provide(func(temporalClient client.Client, storage storage.Storage, events *events.Events, plugins plugins.Plugins) activities.Activities { + return activities.New(temporalClient, storage, events, plugins) + }), + fx.Provide( + fx.Annotate(func( + logger logging.Logger, + temporalClient client.Client, + workflows, + activities []temporal.DefinitionSet, + options worker.Options, + ) *Workers { + return NewWorkers(logger, stack, temporalClient, workflows, activities, options) + }, fx.ParamTags(``, ``, `group:"workflows"`, `group:"activities"`, ``)), + ), + fx.Invoke(func(lc fx.Lifecycle, engine Engine, workers *Workers) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return engine.OnStart(ctx) + }, + OnStop: func(ctx context.Context) error { + engine.OnStop(ctx) + workers.Close() + return nil + }, + }) + }), + fx.Provide(fx.Annotate(func(a activities.Activities) temporal.DefinitionSet { + return a.DefinitionSet() + }, fx.ResultTags(`group:"activities"`))), + fx.Provide(fx.Annotate(func(workflow workflow.Workflow) temporal.DefinitionSet { + return workflow.DefinitionSet() + }, fx.ResultTags(`group:"workflows"`))), + } + + return fx.Options(ret...) +} diff --git a/internal/connectors/engine/plugins/plugin.go b/internal/connectors/engine/plugins/plugin.go new file mode 100644 index 00000000..86677ebf --- /dev/null +++ b/internal/connectors/engine/plugins/plugin.go @@ -0,0 +1,132 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/formancehq/go-libs/v2/logging" + pluginserrors "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +var ( + ErrNotFound = errors.New("plugin not found") + ErrValidation = errors.New("validation error") +) + +//go:generate mockgen -source plugin.go -destination plugin_generated.go -package plugins . Plugins +type Plugins interface { + RegisterPlugin(models.ConnectorID, string, models.Config, json.RawMessage) error + UnregisterPlugin(models.ConnectorID) error + GetConfig(models.ConnectorID) (models.Config, error) + Get(models.ConnectorID) (models.Plugin, error) +} + +// Will start, hold, manage and stop *Plugins +type plugins struct { + logger logging.Logger + + plugins map[string]pluginInformation + rwMutex sync.RWMutex + + // used to pass flags to plugins + rawFlags []string + debug bool + jsonFormatter bool +} + +type pluginInformation struct { + client models.Plugin + config models.Config +} + +func New( + logger logging.Logger, + rawFlags []string, + debug bool, + jsonFormatter bool, +) *plugins { + return &plugins{ + logger: logger, + plugins: make(map[string]pluginInformation), + rawFlags: rawFlags, + debug: debug, + jsonFormatter: jsonFormatter, + } +} + +func (p *plugins) RegisterPlugin( + connectorID models.ConnectorID, + connectorName string, + config models.Config, + rawConfig json.RawMessage, +) error { + p.rwMutex.Lock() + defer p.rwMutex.Unlock() + + // Check if plugin is already installed + _, ok := p.plugins[connectorID.String()] + if ok { + return nil + } + + plugin, err := registry.GetPlugin(p.logger, connectorID.Provider, connectorName, rawConfig) + switch { + case errors.Is(err, pluginserrors.ErrNotImplemented), + errors.Is(err, pluginserrors.ErrInvalidClientRequest): + return fmt.Errorf("%w: %w", err, ErrValidation) + case err != nil: + return err + } + + p.plugins[connectorID.String()] = pluginInformation{ + client: plugin, + config: config, + } + + return nil +} + +func (p *plugins) UnregisterPlugin(connectorID models.ConnectorID) error { + p.rwMutex.Lock() + defer p.rwMutex.Unlock() + + _, ok := p.plugins[connectorID.String()] + if !ok { + // Nothing to do`` + return nil + } + + delete(p.plugins, connectorID.String()) + + return nil +} + +func (p *plugins) Get(connectorID models.ConnectorID) (models.Plugin, error) { + p.rwMutex.RLock() + defer p.rwMutex.RUnlock() + + pluginInfo, ok := p.plugins[connectorID.String()] + if !ok { + return nil, ErrNotFound + } + + return pluginInfo.client, nil +} + +func (p *plugins) GetConfig(connectorID models.ConnectorID) (models.Config, error) { + p.rwMutex.RLock() + defer p.rwMutex.RUnlock() + + pluginInfo, ok := p.plugins[connectorID.String()] + if !ok { + return models.Config{}, ErrNotFound + } + + return pluginInfo.config, nil +} + +var _ Plugins = &plugins{} diff --git a/internal/connectors/engine/plugins/plugin_generated.go b/internal/connectors/engine/plugins/plugin_generated.go new file mode 100644 index 00000000..12db0d29 --- /dev/null +++ b/internal/connectors/engine/plugins/plugin_generated.go @@ -0,0 +1,114 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: plugin.go +// +// Generated by this command: +// +// mockgen -source plugin.go -destination plugin_generated.go -package plugins . Plugins +// + +// Package plugins is a generated GoMock package. +package plugins + +import ( + json "encoding/json" + reflect "reflect" + + models "github.com/formancehq/payments/internal/models" + gomock "go.uber.org/mock/gomock" +) + +// MockPlugins is a mock of Plugins interface. +type MockPlugins struct { + ctrl *gomock.Controller + recorder *MockPluginsMockRecorder +} + +// MockPluginsMockRecorder is the mock recorder for MockPlugins. +type MockPluginsMockRecorder struct { + mock *MockPlugins +} + +// NewMockPlugins creates a new mock instance. +func NewMockPlugins(ctrl *gomock.Controller) *MockPlugins { + mock := &MockPlugins{ctrl: ctrl} + mock.recorder = &MockPluginsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPlugins) EXPECT() *MockPluginsMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockPlugins) Get(arg0 models.ConnectorID) (models.Plugin, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0) + ret0, _ := ret[0].(models.Plugin) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockPluginsMockRecorder) Get(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPlugins)(nil).Get), arg0) +} + +// GetCapabilities mocks base method. +func (m *MockPlugins) GetCapabilities(arg0 models.ConnectorID) ([]models.Capability, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCapabilities", arg0) + ret0, _ := ret[0].([]models.Capability) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCapabilities indicates an expected call of GetCapabilities. +func (mr *MockPluginsMockRecorder) GetCapabilities(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCapabilities", reflect.TypeOf((*MockPlugins)(nil).GetCapabilities), arg0) +} + +// GetConfig mocks base method. +func (m *MockPlugins) GetConfig(arg0 models.ConnectorID) (models.Config, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetConfig", arg0) + ret0, _ := ret[0].(models.Config) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetConfig indicates an expected call of GetConfig. +func (mr *MockPluginsMockRecorder) GetConfig(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockPlugins)(nil).GetConfig), arg0) +} + +// RegisterPlugin mocks base method. +func (m *MockPlugins) RegisterPlugin(arg0 models.ConnectorID, arg1 string, arg2 models.Config, arg3 json.RawMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegisterPlugin", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// RegisterPlugin indicates an expected call of RegisterPlugin. +func (mr *MockPluginsMockRecorder) RegisterPlugin(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterPlugin", reflect.TypeOf((*MockPlugins)(nil).RegisterPlugin), arg0, arg1, arg2, arg3) +} + +// UnregisterPlugin mocks base method. +func (m *MockPlugins) UnregisterPlugin(arg0 models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnregisterPlugin", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnregisterPlugin indicates an expected call of UnregisterPlugin. +func (mr *MockPluginsMockRecorder) UnregisterPlugin(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterPlugin", reflect.TypeOf((*MockPlugins)(nil).UnregisterPlugin), arg0) +} diff --git a/internal/connectors/engine/search_attributes.go b/internal/connectors/engine/search_attributes.go new file mode 100644 index 00000000..977eba08 --- /dev/null +++ b/internal/connectors/engine/search_attributes.go @@ -0,0 +1,14 @@ +package engine + +import ( + "github.com/formancehq/payments/internal/connectors/engine/workflow" + "go.temporal.io/api/enums/v1" +) + +var ( + SearchAttributes = map[string]enums.IndexedValueType{ + workflow.SearchAttributeWorkflowID: enums.INDEXED_VALUE_TYPE_KEYWORD, + workflow.SearchAttributeScheduleID: enums.INDEXED_VALUE_TYPE_KEYWORD, + workflow.SearchAttributeStack: enums.INDEXED_VALUE_TYPE_KEYWORD, + } +) diff --git a/internal/connectors/engine/tracer.go b/internal/connectors/engine/tracer.go new file mode 100644 index 00000000..2aae55db --- /dev/null +++ b/internal/connectors/engine/tracer.go @@ -0,0 +1,5 @@ +package engine + +import "go.opentelemetry.io/otel" + +var Tracer = otel.Tracer("connectors") diff --git a/internal/connectors/engine/webhooks/webhooks.go b/internal/connectors/engine/webhooks/webhooks.go new file mode 100644 index 00000000..94be9e35 --- /dev/null +++ b/internal/connectors/engine/webhooks/webhooks.go @@ -0,0 +1,53 @@ +package webhooks + +import ( + "errors" + "sync" + + "github.com/formancehq/payments/internal/models" +) + +type Webhooks interface { + RegisterWebhooks(connectorID models.ConnectorID, webhooks []models.WebhookConfig) + UnregisterWebhooks(connectorID models.ConnectorID) + GetConfigs(connectorID models.ConnectorID, urlPath string) ([]models.WebhookConfig, error) +} + +type webhooks struct { + registeredWebhooksConfigs map[string][]models.WebhookConfig + rwMutex sync.RWMutex +} + +func New() *webhooks { + return &webhooks{ + registeredWebhooksConfigs: make(map[string][]models.WebhookConfig), + } +} + +func (w *webhooks) RegisterWebhooks(connectorID models.ConnectorID, webhooks []models.WebhookConfig) { + w.rwMutex.Lock() + defer w.rwMutex.Unlock() + + w.registeredWebhooksConfigs[connectorID.String()] = webhooks +} + +func (w *webhooks) UnregisterWebhooks(connectorID models.ConnectorID) { + w.rwMutex.Lock() + defer w.rwMutex.Unlock() + + delete(w.registeredWebhooksConfigs, connectorID.String()) +} + +func (w *webhooks) GetConfigs(connectorID models.ConnectorID, urlPath string) ([]models.WebhookConfig, error) { + w.rwMutex.RLock() + defer w.rwMutex.RUnlock() + + webhooksConfigs, ok := w.registeredWebhooksConfigs[connectorID.String()] + if !ok { + return nil, errors.New("connector not found") + } + + return webhooksConfigs, nil +} + +var _ Webhooks = &webhooks{} diff --git a/internal/connectors/engine/workers.go b/internal/connectors/engine/workers.go new file mode 100644 index 00000000..139b1f59 --- /dev/null +++ b/internal/connectors/engine/workers.go @@ -0,0 +1,127 @@ +package engine + +import ( + "fmt" + "sync" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/temporal" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" + temporalworkflow "go.temporal.io/sdk/workflow" +) + +type Workers struct { + logger logging.Logger + + stack string + + temporalClient client.Client + + workers map[string]Worker + rwMutex sync.RWMutex + + workflows []temporal.DefinitionSet + activities []temporal.DefinitionSet + + options worker.Options +} + +type Worker struct { + worker worker.Worker +} + +// Returns the default worker name and create it if it doesn't exist yet. +func (w *Workers) GetDefaultWorker() string { + defaultWorker := fmt.Sprintf("%s-default", w.stack) + w.AddWorker(defaultWorker) + return defaultWorker +} + +func NewWorkers(logger logging.Logger, stack string, temporalClient client.Client, workflows, activities []temporal.DefinitionSet, options worker.Options) *Workers { + workers := &Workers{ + logger: logger, + stack: stack, + temporalClient: temporalClient, + workers: make(map[string]Worker), + workflows: workflows, + activities: activities, + options: options, + } + + return workers +} + +// Close is called when app is terminated +func (w *Workers) Close() { + w.rwMutex.Lock() + defer w.rwMutex.Unlock() + + for _, worker := range w.workers { + worker.worker.Stop() + } +} + +// Installing a new connector lauches a new worker +// A default one is instantiated when the workers struct is created +func (w *Workers) AddWorker(name string) error { + w.rwMutex.Lock() + defer w.rwMutex.Unlock() + + if _, ok := w.workers[name]; ok { + return nil + } + + worker := worker.New(w.temporalClient, name, w.options) + + for _, set := range w.workflows { + for _, workflow := range set { + worker.RegisterWorkflowWithOptions(workflow.Func, temporalworkflow.RegisterOptions{ + Name: workflow.Name, + }) + } + } + + for _, set := range w.activities { + for _, act := range set { + worker.RegisterActivityWithOptions(act.Func, activity.RegisterOptions{ + Name: act.Name, + }) + } + } + + go func() { + err := worker.Start() + if err != nil { + w.logger.Errorf("worker loop stopped: %v", err) + } + }() + + w.workers[name] = Worker{ + worker: worker, + } + + w.logger.Infof("worker for connector %s started", name) + + return nil +} + +// Uninstalling a connector stops the worker +func (w *Workers) RemoveWorker(name string) error { + w.rwMutex.Lock() + defer w.rwMutex.Unlock() + + worker, ok := w.workers[name] + if !ok { + return nil + } + + worker.worker.Stop() + + delete(w.workers, name) + + w.logger.Infof("worker for connector %s removed", name) + + return nil +} diff --git a/internal/connectors/engine/workflow/context.go b/internal/connectors/engine/workflow/context.go new file mode 100644 index 00000000..065de68f --- /dev/null +++ b/internal/connectors/engine/workflow/context.go @@ -0,0 +1,41 @@ +package workflow + +import ( + "time" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +const ( + ErrorCodeValidation = "VALIDATION" +) + +func infiniteRetryContext(ctx workflow.Context) workflow.Context { + return workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 60 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: time.Second, + BackoffCoefficient: 2, + MaximumInterval: 100 * time.Second, + NonRetryableErrorTypes: []string{ + ErrorCodeValidation, + }, + }, + }) +} + +func maximumAttemptsRetryContext(ctx workflow.Context, attempts int) workflow.Context { + return workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 60 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: int32(attempts), + InitialInterval: time.Second, + BackoffCoefficient: 2, + MaximumInterval: 100 * time.Second, + NonRetryableErrorTypes: []string{ + ErrorCodeValidation, + }, + }, + }) +} diff --git a/internal/connectors/engine/workflow/create_bank_account.go b/internal/connectors/engine/workflow/create_bank_account.go new file mode 100644 index 00000000..0ebaa5f6 --- /dev/null +++ b/internal/connectors/engine/workflow/create_bank_account.go @@ -0,0 +1,120 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type CreateBankAccount struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + BankAccountID uuid.UUID +} + +func (w Workflow) runCreateBankAccount( + ctx workflow.Context, + createBankAccount CreateBankAccount, +) error { + accountID, err := w.createBankAccount(ctx, createBankAccount) + if err != nil { + if errUpdateTask := w.updateTasksError( + ctx, + createBankAccount.TaskID, + createBankAccount.ConnectorID, + err, + ); errUpdateTask != nil { + return errUpdateTask + } + + return err + } + + return w.updateTaskSuccess( + ctx, + createBankAccount.TaskID, + createBankAccount.ConnectorID, + accountID, + ) +} + +func (w Workflow) createBankAccount( + ctx workflow.Context, + createBankAccount CreateBankAccount, +) (string, error) { + bankAccount, err := activities.StorageBankAccountsGet( + infiniteRetryContext(ctx), + createBankAccount.BankAccountID, + true, + ) + if err != nil { + return "", err + } + + createBAResponse, err := activities.PluginCreateBankAccount( + infiniteRetryContext(ctx), + createBankAccount.ConnectorID, + models.CreateBankAccountRequest{ + BankAccount: *bankAccount, + }, + ) + if err != nil { + return "", err + } + + account := models.FromPSPAccount( + createBAResponse.RelatedAccount, + models.ACCOUNT_TYPE_EXTERNAL, + createBankAccount.ConnectorID, + ) + + err = activities.StorageAccountsStore( + infiniteRetryContext(ctx), + []models.Account{account}, + ) + if err != nil { + return "", err + } + + relatedAccount := models.BankAccountRelatedAccount{ + BankAccountID: createBankAccount.BankAccountID, + AccountID: account.ID, + ConnectorID: createBankAccount.ConnectorID, + CreatedAt: createBAResponse.RelatedAccount.CreatedAt, + } + + err = activities.StorageBankAccountsAddRelatedAccount( + infiniteRetryContext(ctx), + relatedAccount, + ) + if err != nil { + return "", err + } + + bankAccount.RelatedAccounts = append(bankAccount.RelatedAccounts, relatedAccount) + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(relatedAccount.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + SendEvents{ + BankAccount: bankAccount, + }, + ).Get(ctx, nil); err != nil { + return "", err + } + + return account.ID.String(), nil +} + +const RunCreateBankAccount = "CreateBankAccount" diff --git a/internal/connectors/engine/workflow/create_payout.go b/internal/connectors/engine/workflow/create_payout.go new file mode 100644 index 00000000..fe316990 --- /dev/null +++ b/internal/connectors/engine/workflow/create_payout.go @@ -0,0 +1,184 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type CreatePayout struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + PaymentInitiationID models.PaymentInitiationID +} + +func (w Workflow) runCreatePayout( + ctx workflow.Context, + createPayout CreatePayout, +) error { + err := w.createPayout(ctx, createPayout) + if err != nil { + errUpdateTask := w.updateTasksError( + ctx, + createPayout.TaskID, + createPayout.ConnectorID, + err, + ) + if errUpdateTask != nil { + return errUpdateTask + } + + return err + } + + return nil +} + +func (w Workflow) createPayout( + ctx workflow.Context, + createPayout CreatePayout, +) error { + // Get the payment initiation + pi, err := activities.StoragePaymentInitiationsGet( + infiniteRetryContext(ctx), + createPayout.PaymentInitiationID, + ) + if err != nil { + return err + } + + pspPI, err := w.getPSPPI(ctx, pi) + if err != nil { + return err + } + + err = w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: createPayout.PaymentInitiationID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + }, + pi.Amount, + &pi.Asset, + nil, + nil, + ) + if err != nil { + return err + } + + createPayoutResponse, errPlugin := activities.PluginCreatePayout( + infiniteRetryContext(ctx), + createPayout.ConnectorID, + models.CreatePayoutRequest{ + PaymentInitiation: pspPI, + }, + ) + switch errPlugin { + case nil: + if createPayoutResponse.Payment != nil { + payment := models.FromPSPPaymentToPayment(*createPayoutResponse.Payment, createPayout.ConnectorID) + + if err := w.storePIPaymentWithStatus( + ctx, + payment, + createPayout.PaymentInitiationID, + getPIStatusFromPayment(payment.Status), + createPayout.ConnectorID, + ); err != nil { + return err + } + + return w.updateTaskSuccess( + ctx, + createPayout.TaskID, + createPayout.ConnectorID, + payment.ID.String(), + ) + } + + if createPayoutResponse.PollingPayoutID != nil { + config, err := w.plugins.GetConfig(createPayout.ConnectorID) + if err != nil { + return err + } + + scheduleID := fmt.Sprintf("polling-payout-%s-%s-%s", w.stack, createPayout.ConnectorID.String(), *createPayoutResponse.PollingPayoutID) + scheduleID, err = activities.TemporalScheduleCreate( + infiniteRetryContext(ctx), + activities.ScheduleCreateOptions{ + ScheduleID: scheduleID, + Interval: client.ScheduleIntervalSpec{ + Every: config.PollingPeriod, + }, + Action: client.ScheduleWorkflowAction{ + Workflow: RunPollTransfer, + Args: []interface{}{ + PollTransfer{ + TaskID: createPayout.TaskID, + ConnectorID: createPayout.ConnectorID, + PaymentInitiationID: createPayout.PaymentInitiationID, + TransferID: *createPayoutResponse.PollingPayoutID, + ScheduleID: scheduleID, + }, + }, + TaskQueue: w.getConnectorTaskQueue(createPayout.ConnectorID), + TypedSearchAttributes: temporal.NewSearchAttributes( + temporal.NewSearchAttributeKeyKeyword(SearchAttributeScheduleID).ValueSet(scheduleID), + temporal.NewSearchAttributeKeyKeyword(SearchAttributeStack).ValueSet(w.stack), + ), + }, + Overlap: enums.SCHEDULE_OVERLAP_POLICY_SKIP, + TriggerImmediately: true, + SearchAttributes: map[string]any{ + SearchAttributeScheduleID: scheduleID, + SearchAttributeStack: w.stack, + }, + }, + ) + if err != nil { + return err + } + + err = activities.StorageSchedulesStore( + infiniteRetryContext(ctx), + models.Schedule{ + ID: scheduleID, + ConnectorID: createPayout.ConnectorID, + CreatedAt: workflow.Now(ctx).UTC(), + }) + if err != nil { + return err + } + } + + return nil + + default: + err = w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: createPayout.PaymentInitiationID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED, + }, + pi.Amount, + &pi.Asset, + err, + nil, + ) + if err != nil { + return err + } + + return errPlugin + } +} + +const RunCreatePayout = "CreatePayout" diff --git a/internal/connectors/engine/workflow/create_transfer.go b/internal/connectors/engine/workflow/create_transfer.go new file mode 100644 index 00000000..6ea164a5 --- /dev/null +++ b/internal/connectors/engine/workflow/create_transfer.go @@ -0,0 +1,186 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type CreateTransfer struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + PaymentInitiationID models.PaymentInitiationID +} + +func (w Workflow) runCreateTransfer( + ctx workflow.Context, + createTransfer CreateTransfer, +) error { + err := w.createTransfer(ctx, createTransfer) + if err != nil { + errUpdateTask := w.updateTasksError( + ctx, + createTransfer.TaskID, + createTransfer.ConnectorID, + err, + ) + if errUpdateTask != nil { + return errUpdateTask + } + + return err + } + + return nil +} + +func (w Workflow) createTransfer( + ctx workflow.Context, + createTransfer CreateTransfer, +) error { + // Get the payment initiation + pi, err := activities.StoragePaymentInitiationsGet( + infiniteRetryContext(ctx), + createTransfer.PaymentInitiationID, + ) + if err != nil { + return err + } + + pspPI, err := w.getPSPPI(ctx, pi) + if err != nil { + return err + } + + err = w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: createTransfer.PaymentInitiationID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + }, + pi.Amount, + &pi.Asset, + nil, + nil, + ) + if err != nil { + return err + } + + createTransferResponse, errPlugin := activities.PluginCreateTransfer( + infiniteRetryContext(ctx), + createTransfer.ConnectorID, + models.CreateTransferRequest{ + PaymentInitiation: pspPI, + }, + ) + switch errPlugin { + case nil: + if createTransferResponse.Payment != nil { + // payment is already available, storing it + payment := models.FromPSPPaymentToPayment(*createTransferResponse.Payment, createTransfer.ConnectorID) + + if err := w.storePIPaymentWithStatus( + ctx, + payment, + createTransfer.PaymentInitiationID, + getPIStatusFromPayment(payment.Status), + createTransfer.ConnectorID, + ); err != nil { + return err + } + + return w.updateTaskSuccess( + ctx, + createTransfer.TaskID, + createTransfer.ConnectorID, + payment.ID.String(), + ) + } + + if createTransferResponse.PollingTransferID != nil { + // payment not yet available, waiting for the next polling + config, err := w.plugins.GetConfig(createTransfer.ConnectorID) + if err != nil { + return err + } + + scheduleID := fmt.Sprintf("polling-transfer-%s-%s-%s", w.stack, createTransfer.ConnectorID.String(), *createTransferResponse.PollingTransferID) + scheduleID, err = activities.TemporalScheduleCreate( + infiniteRetryContext(ctx), + activities.ScheduleCreateOptions{ + ScheduleID: scheduleID, + Interval: client.ScheduleIntervalSpec{ + Every: config.PollingPeriod, + }, + Action: client.ScheduleWorkflowAction{ + Workflow: RunPollTransfer, + Args: []interface{}{ + PollTransfer{ + TaskID: createTransfer.TaskID, + ConnectorID: createTransfer.ConnectorID, + PaymentInitiationID: createTransfer.PaymentInitiationID, + TransferID: *createTransferResponse.PollingTransferID, + ScheduleID: scheduleID, + }, + }, + TaskQueue: w.getConnectorTaskQueue(createTransfer.ConnectorID), + TypedSearchAttributes: temporal.NewSearchAttributes( + temporal.NewSearchAttributeKeyKeyword(SearchAttributeScheduleID).ValueSet(scheduleID), + temporal.NewSearchAttributeKeyKeyword(SearchAttributeStack).ValueSet(w.stack), + ), + }, + Overlap: enums.SCHEDULE_OVERLAP_POLICY_SKIP, + TriggerImmediately: true, + SearchAttributes: map[string]any{ + SearchAttributeScheduleID: scheduleID, + SearchAttributeStack: w.stack, + }, + }, + ) + if err != nil { + return err + } + + err = activities.StorageSchedulesStore( + infiniteRetryContext(ctx), + models.Schedule{ + ID: scheduleID, + ConnectorID: createTransfer.ConnectorID, + CreatedAt: workflow.Now(ctx).UTC(), + }) + if err != nil { + return err + } + } + + return nil + + default: + err := w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: createTransfer.PaymentInitiationID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED, + }, + pi.Amount, + &pi.Asset, + errPlugin, + nil, + ) + if err != nil { + return err + } + + return errPlugin + } +} + +const RunCreateTransfer = "CreateTransfer" diff --git a/internal/connectors/engine/workflow/create_webhooks.go b/internal/connectors/engine/workflow/create_webhooks.go new file mode 100644 index 00000000..dc9293be --- /dev/null +++ b/internal/connectors/engine/workflow/create_webhooks.go @@ -0,0 +1,82 @@ +package workflow + +import ( + "net/url" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type CreateWebhooks struct { + ConnectorID models.ConnectorID + Config models.Config + FromPayload *FromPayload +} + +func (w Workflow) runCreateWebhooks( + ctx workflow.Context, + createWebhooks CreateWebhooks, + nextTasks []models.ConnectorTaskTree, +) error { + if err := w.createInstance(ctx, createWebhooks.ConnectorID); err != nil { + return errors.Wrap(err, "creating instance") + } + err := w.createWebhooks(ctx, createWebhooks, nextTasks) + return w.terminateInstance(ctx, createWebhooks.ConnectorID, err) +} + +func (w Workflow) createWebhooks( + ctx workflow.Context, + createWebhooks CreateWebhooks, + nextTasks []models.ConnectorTaskTree, +) error { + webhookBaseURL, err := url.JoinPath(w.stackPublicURL, "api/payments/v3/connectors/webhooks", createWebhooks.ConnectorID.String()) + if err != nil { + return errors.Wrap(err, "joining webhook base URL") + } + + resp, err := activities.PluginCreateWebhooks( + infiniteRetryContext(ctx), + createWebhooks.ConnectorID, + models.CreateWebhooksRequest{ + WebhookBaseUrl: webhookBaseURL, + ConnectorID: createWebhooks.ConnectorID.String(), + FromPayload: createWebhooks.FromPayload.GetPayload(), + }, + ) + if err != nil { + return errors.Wrap(err, "failed to create webhooks") + } + + for _, other := range resp.Others { + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(createWebhooks.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + createWebhooks.Config, + createWebhooks.ConnectorID, + &FromPayload{ + ID: other.ID, + Payload: other.Other, + }, + nextTasks, + ).Get(ctx, nil); err != nil { + return errors.Wrap(err, "running next workflow") + } + } + + return nil +} + +const RunCreateWebhooks = "RunCreateWebhooks" diff --git a/internal/connectors/engine/workflow/fetch_accounts.go b/internal/connectors/engine/workflow/fetch_accounts.go new file mode 100644 index 00000000..74dc28de --- /dev/null +++ b/internal/connectors/engine/workflow/fetch_accounts.go @@ -0,0 +1,182 @@ +package workflow + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type FetchNextAccounts struct { + Config models.Config `json:"config"` + ConnectorID models.ConnectorID `json:"connectorID"` + FromPayload *FromPayload `json:"fromPayload"` +} + +func (w Workflow) runFetchNextAccounts( + ctx workflow.Context, + fetchNextAccount FetchNextAccounts, + nextTasks []models.ConnectorTaskTree, +) error { + if err := w.createInstance(ctx, fetchNextAccount.ConnectorID); err != nil { + return errors.Wrap(err, "creating instance") + } + err := w.fetchAccounts(ctx, fetchNextAccount, nextTasks) + return w.terminateInstance(ctx, fetchNextAccount.ConnectorID, err) +} + +func (w Workflow) fetchAccounts( + ctx workflow.Context, + fetchNextAccount FetchNextAccounts, + nextTasks []models.ConnectorTaskTree, +) error { + stateReference := models.CAPABILITY_FETCH_ACCOUNTS.String() + if fetchNextAccount.FromPayload != nil { + stateReference = fmt.Sprintf("%s-%s", models.CAPABILITY_FETCH_ACCOUNTS.String(), fetchNextAccount.FromPayload.ID) + } + + stateID := models.StateID{ + Reference: stateReference, + ConnectorID: fetchNextAccount.ConnectorID, + } + state, err := activities.StorageStatesGet(infiniteRetryContext(ctx), stateID) + if err != nil { + return fmt.Errorf("retrieving state %s: %v", stateID.String(), err) + } + + hasMore := true + for hasMore { + accountsResponse, err := activities.PluginFetchNextAccounts( + infiniteRetryContext(ctx), + fetchNextAccount.ConnectorID, + fetchNextAccount.FromPayload.GetPayload(), + state.State, + fetchNextAccount.Config.PageSize, + ) + if err != nil { + return errors.Wrap(err, "fetching next accounts") + } + + accounts := models.FromPSPAccounts( + accountsResponse.Accounts, + models.ACCOUNT_TYPE_INTERNAL, + fetchNextAccount.ConnectorID, + ) + + if len(accountsResponse.Accounts) > 0 { + err = activities.StorageAccountsStore( + infiniteRetryContext(ctx), + accounts, + ) + if err != nil { + return errors.Wrap(err, "storing next accounts") + } + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, len(accountsResponse.Accounts)*2) + for _, account := range accounts { + acc := account + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextAccount.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + SendEvents{ + Account: &acc, + }, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "sending events") + } + }) + } + + for _, account := range accountsResponse.Accounts { + acc := account + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + payload, err := json.Marshal(acc) + if err != nil { + errChan <- errors.Wrap(err, "marshalling account") + } + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextAccount.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + fetchNextAccount.Config, + fetchNextAccount.ConnectorID, + &FromPayload{ + ID: acc.Reference, + Payload: payload, + }, + nextTasks, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "running next workflow") + } + }) + } + + wg.Wait(ctx) + close(errChan) + for err := range errChan { + if err != nil { + return err + } + } + + state.State = accountsResponse.NewState + err = activities.StorageStatesStore( + infiniteRetryContext(ctx), + *state, + ) + if err != nil { + return errors.Wrap(err, "storing state") + } + + hasMore = accountsResponse.HasMore + + if w.shouldContinueAsNew(ctx) { + // If we have lots and lots of accounts, sometimes, we need to + // continue as new to not exeed the maximum history size or length + // of a workflow. + return workflow.NewContinueAsNewError( + ctx, + RunFetchNextAccounts, + fetchNextAccount, + nextTasks, + ) + } + } + + return nil +} + +const RunFetchNextAccounts = "FetchAccounts" diff --git a/internal/connectors/engine/workflow/fetch_balances.go b/internal/connectors/engine/workflow/fetch_balances.go new file mode 100644 index 00000000..21d51bb2 --- /dev/null +++ b/internal/connectors/engine/workflow/fetch_balances.go @@ -0,0 +1,180 @@ +package workflow + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type FetchNextBalances struct { + Config models.Config `json:"config"` + ConnectorID models.ConnectorID `json:"connectorID"` + FromPayload *FromPayload `json:"fromPayload"` +} + +func (w Workflow) runFetchNextBalances( + ctx workflow.Context, + fetchNextBalances FetchNextBalances, + nextTasks []models.ConnectorTaskTree, +) error { + if err := w.createInstance(ctx, fetchNextBalances.ConnectorID); err != nil { + return errors.Wrap(err, "creating instance") + } + err := w.fetchBalances(ctx, fetchNextBalances, nextTasks) + return w.terminateInstance(ctx, fetchNextBalances.ConnectorID, err) +} + +func (w Workflow) fetchBalances( + ctx workflow.Context, + fetchNextBalances FetchNextBalances, + nextTasks []models.ConnectorTaskTree, +) error { + stateReference := models.CAPABILITY_FETCH_BALANCES.String() + if fetchNextBalances.FromPayload != nil { + stateReference = fmt.Sprintf("%s-%s", models.CAPABILITY_FETCH_BALANCES.String(), fetchNextBalances.FromPayload.ID) + } + + stateID := models.StateID{ + Reference: stateReference, + ConnectorID: fetchNextBalances.ConnectorID, + } + state, err := activities.StorageStatesGet(infiniteRetryContext(ctx), stateID) + if err != nil { + return fmt.Errorf("retrieving state %s: %v", stateID.String(), err) + } + + hasMore := true + for hasMore { + balancesResponse, err := activities.PluginFetchNextBalances( + infiniteRetryContext(ctx), + fetchNextBalances.ConnectorID, + fetchNextBalances.FromPayload.GetPayload(), + state.State, + fetchNextBalances.Config.PageSize, + ) + if err != nil { + return errors.Wrap(err, "fetching next accounts") + } + + balances := models.FromPSPBalances( + balancesResponse.Balances, + fetchNextBalances.ConnectorID, + ) + if len(balancesResponse.Balances) > 0 { + err = activities.StorageBalancesStore( + infiniteRetryContext(ctx), + balances, + ) + if err != nil { + return errors.Wrap(err, "storing next accounts") + } + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, len(balancesResponse.Balances)*2) + for _, balance := range balances { + b := balance + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextBalances.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + SendEvents{ + Balance: &b, + }, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "sending events") + } + }) + } + + for _, balance := range balancesResponse.Balances { + b := balance + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + payload, err := json.Marshal(b) + if err != nil { + errChan <- errors.Wrap(err, "marshalling account") + } + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextBalances.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + fetchNextBalances.Config, + fetchNextBalances.ConnectorID, + &FromPayload{ + ID: fmt.Sprintf("%s-balances", b.AccountReference), + Payload: payload, + }, + nextTasks, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "running next workflow") + } + }) + } + + wg.Wait(ctx) + close(errChan) + for err := range errChan { + if err != nil { + return err + } + } + + state.State = balancesResponse.NewState + err = activities.StorageStatesStore( + infiniteRetryContext(ctx), + *state, + ) + if err != nil { + return errors.Wrap(err, "storing state") + } + + hasMore = balancesResponse.HasMore + + if w.shouldContinueAsNew(ctx) { + // If we have lots and lots of accounts, sometimes, we need to + // continue as new to not exeed the maximum history size or length + // of a workflow. + return workflow.NewContinueAsNewError( + ctx, + RunFetchNextBalances, + fetchNextBalances, + nextTasks, + ) + } + } + + return nil +} + +const RunFetchNextBalances = "FetchBalances" diff --git a/internal/connectors/engine/workflow/fetch_external_accounts.go b/internal/connectors/engine/workflow/fetch_external_accounts.go new file mode 100644 index 00000000..6fab43b2 --- /dev/null +++ b/internal/connectors/engine/workflow/fetch_external_accounts.go @@ -0,0 +1,181 @@ +package workflow + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type FetchNextExternalAccounts struct { + Config models.Config `json:"config"` + ConnectorID models.ConnectorID `json:"connectorID"` + FromPayload *FromPayload `json:"fromPayload"` +} + +func (w Workflow) runFetchNextExternalAccounts( + ctx workflow.Context, + fetchNextExternalAccount FetchNextExternalAccounts, + nextTasks []models.ConnectorTaskTree, +) error { + if err := w.createInstance(ctx, fetchNextExternalAccount.ConnectorID); err != nil { + return errors.Wrap(err, "creating instance") + } + err := w.fetchExternalAccounts(ctx, fetchNextExternalAccount, nextTasks) + return w.terminateInstance(ctx, fetchNextExternalAccount.ConnectorID, err) +} + +func (w Workflow) fetchExternalAccounts( + ctx workflow.Context, + fetchNextExternalAccount FetchNextExternalAccounts, + nextTasks []models.ConnectorTaskTree, +) error { + stateReference := models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS.String() + if fetchNextExternalAccount.FromPayload != nil { + stateReference = fmt.Sprintf("%s-%s", models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS.String(), fetchNextExternalAccount.FromPayload.ID) + } + + stateID := models.StateID{ + Reference: stateReference, + ConnectorID: fetchNextExternalAccount.ConnectorID, + } + state, err := activities.StorageStatesGet(infiniteRetryContext(ctx), stateID) + if err != nil { + return fmt.Errorf("retrieving state %s: %v", stateID.String(), err) + } + + hasMore := true + for hasMore { + externalAccountsResponse, err := activities.PluginFetchNextExternalAccounts( + infiniteRetryContext(ctx), + fetchNextExternalAccount.ConnectorID, + fetchNextExternalAccount.FromPayload.GetPayload(), + state.State, + fetchNextExternalAccount.Config.PageSize, + ) + if err != nil { + return errors.Wrap(err, "fetching next accounts") + } + + accounts := models.FromPSPAccounts( + externalAccountsResponse.ExternalAccounts, + models.ACCOUNT_TYPE_EXTERNAL, + fetchNextExternalAccount.ConnectorID, + ) + + if len(externalAccountsResponse.ExternalAccounts) > 0 { + err = activities.StorageAccountsStore( + infiniteRetryContext(ctx), + accounts, + ) + if err != nil { + return errors.Wrap(err, "storing next accounts") + } + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, len(externalAccountsResponse.ExternalAccounts)*2) + for _, externalAccount := range accounts { + acc := externalAccount + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextExternalAccount.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + SendEvents{ + Account: &acc, + }, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "sending events") + } + }) + } + + for _, externalAccount := range externalAccountsResponse.ExternalAccounts { + acc := externalAccount + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + payload, err := json.Marshal(acc) + if err != nil { + errChan <- errors.Wrap(err, "marshalling external account") + } + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextExternalAccount.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + fetchNextExternalAccount.Config, + fetchNextExternalAccount.ConnectorID, + &FromPayload{ + ID: acc.Reference, + Payload: payload, + }, + nextTasks, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "running next workflow") + } + }) + } + + wg.Wait(ctx) + close(errChan) + for err := range errChan { + if err != nil { + return err + } + } + + state.State = externalAccountsResponse.NewState + err = activities.StorageStatesStore( + infiniteRetryContext(ctx), + *state, + ) + if err != nil { + return errors.Wrap(err, "storing state") + } + + hasMore = externalAccountsResponse.HasMore + + if w.shouldContinueAsNew(ctx) { + // If we have lots and lots of accounts, sometimes, we need to + // continue as new to not exeed the maximum history size or length + // of a workflow. + return workflow.NewContinueAsNewError( + ctx, + RunFetchNextExternalAccounts, + fetchNextExternalAccount, + nextTasks, + ) + } + } + + return nil +} + +const RunFetchNextExternalAccounts = "FetchExternalAccounts" diff --git a/internal/connectors/engine/workflow/fetch_others.go b/internal/connectors/engine/workflow/fetch_others.go new file mode 100644 index 00000000..d74a42cd --- /dev/null +++ b/internal/connectors/engine/workflow/fetch_others.go @@ -0,0 +1,134 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type FetchNextOthers struct { + Config models.Config `json:"config"` + ConnectorID models.ConnectorID `json:"connectorID"` + Name string `json:"name"` + FromPayload *FromPayload `json:"fromPayload"` +} + +func (w Workflow) runFetchNextOthers( + ctx workflow.Context, + fetchNextOthers FetchNextOthers, + nextTasks []models.ConnectorTaskTree, +) error { + if err := w.createInstance(ctx, fetchNextOthers.ConnectorID); err != nil { + return errors.Wrap(err, "creating instance") + } + err := w.fetchNextOthers(ctx, fetchNextOthers, nextTasks) + return w.terminateInstance(ctx, fetchNextOthers.ConnectorID, err) +} + +func (w Workflow) fetchNextOthers( + ctx workflow.Context, + fetchNextOthers FetchNextOthers, + nextTasks []models.ConnectorTaskTree, +) error { + stateReference := models.CAPABILITY_FETCH_OTHERS.String() + if fetchNextOthers.FromPayload != nil { + stateReference = fmt.Sprintf("%s-%s", models.CAPABILITY_FETCH_OTHERS.String(), fetchNextOthers.FromPayload.ID) + } + + stateID := models.StateID{ + Reference: stateReference, + ConnectorID: fetchNextOthers.ConnectorID, + } + state, err := activities.StorageStatesGet(infiniteRetryContext(ctx), stateID) + if err != nil { + return fmt.Errorf("retrieving state %s: %v", stateID.String(), err) + } + + hasMore := true + for hasMore { + othersResponse, err := activities.PluginFetchNextOthers( + infiniteRetryContext(ctx), + fetchNextOthers.ConnectorID, + fetchNextOthers.Name, + fetchNextOthers.FromPayload.GetPayload(), + state.State, + fetchNextOthers.Config.PageSize, + ) + if err != nil { + return errors.Wrap(err, "fetching next others") + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, len(othersResponse.Others)) + for _, other := range othersResponse.Others { + o := other + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextOthers.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + fetchNextOthers.Config, + fetchNextOthers.ConnectorID, + &FromPayload{ + ID: o.ID, + Payload: o.Other, + }, + nextTasks, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "running next workflow") + } + }) + } + + wg.Wait(ctx) + close(errChan) + for err := range errChan { + if err != nil { + return err + } + } + + state.State = othersResponse.NewState + err = activities.StorageStatesStore( + infiniteRetryContext(ctx), + *state, + ) + if err != nil { + return errors.Wrap(err, "storing state") + } + + hasMore = othersResponse.HasMore + + if w.shouldContinueAsNew(ctx) { + // If we have lots and lots of accounts, sometimes, we need to + // continue as new to not exeed the maximum history size or length + // of a workflow. + return workflow.NewContinueAsNewError( + ctx, + RunFetchNextOthers, + fetchNextOthers, + nextTasks, + ) + } + } + + return nil +} + +const RunFetchNextOthers = "FetchOthers" diff --git a/internal/connectors/engine/workflow/fetch_payments.go b/internal/connectors/engine/workflow/fetch_payments.go new file mode 100644 index 00000000..99db6953 --- /dev/null +++ b/internal/connectors/engine/workflow/fetch_payments.go @@ -0,0 +1,209 @@ +package workflow + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type FetchNextPayments struct { + Config models.Config `json:"config"` + ConnectorID models.ConnectorID `json:"connectorID"` + FromPayload *FromPayload `json:"fromPayload"` +} + +func (w Workflow) runFetchNextPayments( + ctx workflow.Context, + fetchNextPayments FetchNextPayments, + nextTasks []models.ConnectorTaskTree, +) error { + if err := w.createInstance(ctx, fetchNextPayments.ConnectorID); err != nil { + return errors.Wrap(err, "creating instance") + } + err := w.fetchNextPayments(ctx, fetchNextPayments, nextTasks) + return w.terminateInstance(ctx, fetchNextPayments.ConnectorID, err) +} + +func (w Workflow) fetchNextPayments( + ctx workflow.Context, + fetchNextPayments FetchNextPayments, + nextTasks []models.ConnectorTaskTree, +) error { + stateReference := models.CAPABILITY_FETCH_PAYMENTS.String() + if fetchNextPayments.FromPayload != nil { + stateReference = fmt.Sprintf("%s-%s", models.CAPABILITY_FETCH_PAYMENTS.String(), fetchNextPayments.FromPayload.ID) + } + + stateID := models.StateID{ + Reference: stateReference, + ConnectorID: fetchNextPayments.ConnectorID, + } + state, err := activities.StorageStatesGet(infiniteRetryContext(ctx), stateID) + if err != nil { + return fmt.Errorf("retrieving state %s: %v", stateID.String(), err) + } + + hasMore := true + for hasMore { + paymentsResponse, err := activities.PluginFetchNextPayments( + infiniteRetryContext(ctx), + fetchNextPayments.ConnectorID, + fetchNextPayments.FromPayload.GetPayload(), + state.State, + fetchNextPayments.Config.PageSize, + ) + if err != nil { + return errors.Wrap(err, "fetching next payments") + } + + payments := models.FromPSPPayments( + paymentsResponse.Payments, + fetchNextPayments.ConnectorID, + ) + + if len(paymentsResponse.Payments) > 0 { + err = activities.StoragePaymentsStore( + infiniteRetryContext(ctx), + payments, + ) + if err != nil { + return errors.Wrap(err, "storing next accounts") + } + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, len(paymentsResponse.Payments)*3) + for _, payment := range payments { + p := payment + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + // We want to update the payment initiation from the payment + // if it exists + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextPayments.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunUpdatePaymentInitiationFromPayment, + UpdatePaymentInitiationFromPayment{ + Payment: &p, + }, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "sending events") + } + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + // Send the payment event + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextPayments.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + SendEvents{ + Payment: &p, + }, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "sending events") + } + }) + } + + for _, payment := range paymentsResponse.Payments { + p := payment + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + payload, err := json.Marshal(p) + if err != nil { + errChan <- errors.Wrap(err, "marshalling payment") + } + + // Run next tasks + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(fetchNextPayments.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + fetchNextPayments.Config, + fetchNextPayments.ConnectorID, + &FromPayload{ + ID: p.Reference, + Payload: payload, + }, + nextTasks, + ).Get(ctx, nil); err != nil { + errChan <- errors.Wrap(err, "running next workflow") + } + }) + } + + wg.Wait(ctx) + close(errChan) + for err := range errChan { + if err != nil { + return err + } + } + + state.State = paymentsResponse.NewState + err = activities.StorageStatesStore( + infiniteRetryContext(ctx), + *state, + ) + if err != nil { + return errors.Wrap(err, "storing state") + } + + hasMore = paymentsResponse.HasMore + + if w.shouldContinueAsNew(ctx) { + // If we have lots and lots of accounts, sometimes, we need to + // continue as new to not exeed the maximum history size or length + // of a workflow. + return workflow.NewContinueAsNewError( + ctx, + RunFetchNextPayments, + fetchNextPayments, + nextTasks, + ) + } + } + + return nil +} + +const RunFetchNextPayments = "FetchPayments" diff --git a/internal/connectors/engine/workflow/handle_webhooks.go b/internal/connectors/engine/workflow/handle_webhooks.go new file mode 100644 index 00000000..204b7ed8 --- /dev/null +++ b/internal/connectors/engine/workflow/handle_webhooks.go @@ -0,0 +1,200 @@ +package workflow + +import ( + "fmt" + "strings" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type HandleWebhooks struct { + ConnectorID models.ConnectorID + URLPath string + Webhook models.Webhook +} + +func (w Workflow) runHandleWebhooks( + ctx workflow.Context, + handleWebhooks HandleWebhooks, +) error { + configs, err := activities.StorageWebhooksConfigsGet( + infiniteRetryContext(ctx), + handleWebhooks.ConnectorID, + ) + if err != nil { + return fmt.Errorf("getting webhook configs: %w", err) + } + + var config *models.WebhookConfig + for _, c := range configs { + if !strings.Contains(handleWebhooks.URLPath, c.URLPath) { + continue + } + + config = &c + break + } + + if config == nil { + return temporal.NewNonRetryableApplicationError("webhook config not found", "NOT_FOUND", err) + } + + err = activities.StorageWebhooksStore(infiniteRetryContext(ctx), handleWebhooks.Webhook) + if err != nil { + return fmt.Errorf("storing webhook: %w", err) + } + + resp, err := activities.PluginTranslateWebhook( + infiniteRetryContext(ctx), + handleWebhooks.ConnectorID, + models.TranslateWebhookRequest{ + Name: config.Name, + Webhook: models.PSPWebhook{ + BasicAuth: handleWebhooks.Webhook.BasicAuth, + QueryValues: handleWebhooks.Webhook.QueryValues, + Headers: handleWebhooks.Webhook.Headers, + Body: handleWebhooks.Webhook.Body, + }, + }, + ) + if err != nil { + return fmt.Errorf("translating webhook: %w", err) + } + + for _, response := range resp.Responses { + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("store-webhook-%s-%s-%s", w.stack, handleWebhooks.ConnectorID.String(), response.IdempotencyKey), + TaskQueue: w.getConnectorTaskQueue(handleWebhooks.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunStoreWebhookTranslation, + StoreWebhookTranslation{ + ConnectorID: handleWebhooks.ConnectorID, + Account: response.Account, + ExternalAccount: response.ExternalAccount, + Payment: response.Payment, + }, + ).Get(ctx, nil); err != nil { + applicationError := &temporal.ApplicationError{} + if errors.As(err, &applicationError) { + if applicationError.Type() != "ChildWorkflowExecutionAlreadyStartedError" { + return err + } + } else { + return fmt.Errorf("storing webhook translation: %w", err) + } + } + } + + return nil +} + +const RunHandleWebhooks = "RunHandleWebhooks" + +type StoreWebhookTranslation struct { + ConnectorID models.ConnectorID + Account *models.PSPAccount + ExternalAccount *models.PSPAccount + Payment *models.PSPPayment +} + +func (w Workflow) runStoreWebhookTranslation( + ctx workflow.Context, + storeWebhookTranslation StoreWebhookTranslation, +) error { + var sendEvent *SendEvents + if storeWebhookTranslation.Account != nil { + accounts := models.FromPSPAccounts( + []models.PSPAccount{*storeWebhookTranslation.Account}, + models.ACCOUNT_TYPE_INTERNAL, + storeWebhookTranslation.ConnectorID, + ) + + err := activities.StorageAccountsStore( + infiniteRetryContext(ctx), + accounts, + ) + if err != nil { + return fmt.Errorf("storing next accounts: %w", err) + } + + sendEvent = &SendEvents{ + Account: pointer.For(accounts[0]), + } + } + + if storeWebhookTranslation.ExternalAccount != nil { + accounts := models.FromPSPAccounts( + []models.PSPAccount{*storeWebhookTranslation.ExternalAccount}, + models.ACCOUNT_TYPE_EXTERNAL, + storeWebhookTranslation.ConnectorID, + ) + + err := activities.StorageAccountsStore( + infiniteRetryContext(ctx), + accounts, + ) + if err != nil { + return fmt.Errorf("storing next accounts: %w", err) + } + + sendEvent = &SendEvents{ + Account: pointer.For(accounts[0]), + } + } + + if storeWebhookTranslation.Payment != nil { + payments := models.FromPSPPayments( + []models.PSPPayment{*storeWebhookTranslation.Payment}, + storeWebhookTranslation.ConnectorID, + ) + err := activities.StoragePaymentsStore( + infiniteRetryContext(ctx), + payments, + ) + if err != nil { + return fmt.Errorf("storing next payments: %w", err) + } + + sendEvent = &SendEvents{ + Payment: pointer.For(payments[0]), + } + } + + if sendEvent != nil { + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(storeWebhookTranslation.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + *sendEvent, + ).Get(ctx, nil); err != nil { + return fmt.Errorf("sending events: %w", err) + } + } + + return nil +} + +const RunStoreWebhookTranslation = "RunStoreWebhookTranslation" diff --git a/internal/connectors/engine/workflow/install_connector.go b/internal/connectors/engine/workflow/install_connector.go new file mode 100644 index 00000000..43772233 --- /dev/null +++ b/internal/connectors/engine/workflow/install_connector.go @@ -0,0 +1,92 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type InstallConnector struct { + ConnectorID models.ConnectorID + Config models.Config +} + +func (w Workflow) runInstallConnector( + ctx workflow.Context, + installConnector InstallConnector, +) error { + // Second step: install the connector via the plugin and get the list of + // capabilities and the workflow of polling data + installResponse, err := activities.PluginInstallConnector( + // disable retries as grpc plugin boot command cannot be run more than once by the go-plugin client + // this also causes API install calls to fail immediately which is more desirable in the case that a plugin is timing out or not compiled correctly + maximumAttemptsRetryContext(ctx, 1), + installConnector.ConnectorID, + ) + if err != nil { + return errors.Wrap(err, "failed to install connector") + } + + // Third step: store the workflow of the connector + err = activities.StorageConnectorTasksTreeStore(infiniteRetryContext(ctx), installConnector.ConnectorID, installResponse.Workflow) + if err != nil { + return errors.Wrap(err, "failed to store tasks tree") + } + + if len(installResponse.WebhooksConfigs) > 0 { + configs := make([]models.WebhookConfig, 0, len(installResponse.WebhooksConfigs)) + for _, webhookConfig := range installResponse.WebhooksConfigs { + configs = append(configs, models.WebhookConfig{ + Name: webhookConfig.Name, + ConnectorID: installConnector.ConnectorID, + URLPath: webhookConfig.URLPath, + }) + } + + err = activities.StorageWebhooksConfigsStore(infiniteRetryContext(ctx), configs) + if err != nil { + return errors.Wrap(err, "failed to store webhooks configs") + } + } + + // Fifth step: launch the workflow tree, do not wait for the result + // by using the GetChildWorkflowExecution function that returns a future + // which will be ready when the child workflow has successfully started. + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("run-tasks-%s-%s", w.stack, installConnector.ConnectorID.String()), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY, + TaskQueue: w.getConnectorTaskQueue(installConnector.ConnectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + Run, + installConnector.Config, + installConnector.ConnectorID, + nil, + []models.ConnectorTaskTree(installResponse.Workflow), + ).GetChildWorkflowExecution().Get(ctx, nil); err != nil { + applicationError := &temporal.ApplicationError{} + if errors.As(err, &applicationError) { + if applicationError.Type() != "ChildWorkflowExecutionAlreadyStartedError" { + return err + } + } else { + return errors.Wrap(err, "running next workflow") + } + } + + return nil +} + +const RunInstallConnector = "InstallConnector" diff --git a/internal/connectors/engine/workflow/instances.go b/internal/connectors/engine/workflow/instances.go new file mode 100644 index 00000000..0ac96d19 --- /dev/null +++ b/internal/connectors/engine/workflow/instances.go @@ -0,0 +1,97 @@ +package workflow + +import ( + "encoding/json" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/sdk/workflow" +) + +var ( + errNotFromSchedule = errors.New("not from schedule") +) + +func (w Workflow) createInstance( + ctx workflow.Context, + connectorID models.ConnectorID, +) error { + info := workflow.GetInfo(ctx) + + scheduleID, err := getPaymentScheduleID(info) + if err != nil { + if errors.Is(err, errNotFromSchedule) { + return nil + } + return err + } + + instance := models.Instance{ + ID: info.WorkflowExecution.ID, + ScheduleID: scheduleID, + ConnectorID: connectorID, + CreatedAt: workflow.Now(ctx).UTC(), + UpdatedAt: workflow.Now(ctx).UTC(), + Terminated: false, + } + + return activities.StorageInstancesStore(infiniteRetryContext(ctx), instance) +} + +func (w Workflow) terminateInstance( + ctx workflow.Context, + connectorID models.ConnectorID, + terminateError error, +) error { + info := workflow.GetInfo(ctx) + + scheduleID, err := getPaymentScheduleID(info) + if err != nil { + if errors.Is(err, errNotFromSchedule) { + return nil + } + return err + } + + var errMessage *string + if terminateError != nil { + errMessage = pointer.For(terminateError.Error()) + } + + now := workflow.Now(ctx).UTC() + + instance := models.Instance{ + ID: info.WorkflowExecution.ID, + ScheduleID: scheduleID, + ConnectorID: connectorID, + UpdatedAt: now, + Terminated: true, + TerminatedAt: &now, + Error: errMessage, + } + + return activities.StorageInstancesUpdate(infiniteRetryContext(ctx), instance) +} + +func getPaymentScheduleID( + info *workflow.Info, +) (string, error) { + attributes := info.SearchAttributes.GetIndexedFields() + if attributes == nil { + return "", errNotFromSchedule + } + + v, ok := attributes[SearchAttributeScheduleID] + if !ok || v == nil { + return "", errNotFromSchedule + } + + var scheduleID string + if err := json.Unmarshal(v.Data, &scheduleID); err != nil { + return "", errors.Wrap(err, "unmarshalling schedule ID") + } + + return scheduleID, nil +} diff --git a/internal/connectors/engine/workflow/plugin_workflow.go b/internal/connectors/engine/workflow/plugin_workflow.go new file mode 100644 index 00000000..f1ccf00b --- /dev/null +++ b/internal/connectors/engine/workflow/plugin_workflow.go @@ -0,0 +1,172 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +func (w Workflow) run( + ctx workflow.Context, + config models.Config, + connectorID models.ConnectorID, + fromPayload *FromPayload, + taskTree []models.ConnectorTaskTree, +) error { + var nextWorkflow interface{} + var request interface{} + var capability models.Capability + for _, task := range taskTree { + switch task.TaskType { + case models.TASK_FETCH_ACCOUNTS: + req := FetchNextAccounts{ + Config: config, + ConnectorID: connectorID, + FromPayload: fromPayload, + } + + nextWorkflow = RunFetchNextAccounts + request = req + capability = models.CAPABILITY_FETCH_ACCOUNTS + + case models.TASK_FETCH_EXTERNAL_ACCOUNTS: + req := FetchNextExternalAccounts{ + Config: config, + ConnectorID: connectorID, + FromPayload: fromPayload, + } + + nextWorkflow = RunFetchNextExternalAccounts + request = req + capability = models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS + + case models.TASK_FETCH_OTHERS: + req := FetchNextOthers{ + Config: config, + ConnectorID: connectorID, + Name: task.Name, + FromPayload: fromPayload, + } + + nextWorkflow = RunFetchNextOthers + request = req + capability = models.CAPABILITY_FETCH_OTHERS + + case models.TASK_FETCH_PAYMENTS: + req := FetchNextPayments{ + Config: config, + ConnectorID: connectorID, + FromPayload: fromPayload, + } + + nextWorkflow = RunFetchNextPayments + request = req + capability = models.CAPABILITY_FETCH_PAYMENTS + + case models.TASK_FETCH_BALANCES: + req := FetchNextBalances{ + Config: config, + ConnectorID: connectorID, + FromPayload: fromPayload, + } + + nextWorkflow = RunFetchNextBalances + request = req + capability = models.CAPABILITY_FETCH_BALANCES + + case models.TASK_CREATE_WEBHOOKS: + req := CreateWebhooks{ + Config: config, + ConnectorID: connectorID, + FromPayload: fromPayload, + } + + nextWorkflow = RunCreateWebhooks + request = req + capability = models.CAPABILITY_CREATE_WEBHOOKS + + default: + return fmt.Errorf("unknown task type: %v", task.TaskType) + } + + if task.Periodically { + // Schedule next workflow every polling duration + // TODO(polo): context + var scheduleID string + if fromPayload == nil { + scheduleID = fmt.Sprintf("%s-%s-%s", w.stack, connectorID.String(), capability.String()) + } else { + scheduleID = fmt.Sprintf("%s-%s-%s-%s", w.stack, connectorID.String(), capability.String(), fromPayload.ID) + } + + scheduleID, err := activities.TemporalScheduleCreate( + infiniteRetryContext(ctx), + activities.ScheduleCreateOptions{ + ScheduleID: scheduleID, + Jitter: config.PollingPeriod / 2, + Interval: client.ScheduleIntervalSpec{ + Every: config.PollingPeriod, + }, + Action: client.ScheduleWorkflowAction{ + // Use the same ID as the schedule ID, so we can identify the workflows running. + // This is useful for debugging purposes. + ID: scheduleID, + Workflow: nextWorkflow, + Args: []interface{}{ + request, + task.NextTasks, + }, + TaskQueue: w.getConnectorTaskQueue(connectorID), + }, + Overlap: enums.SCHEDULE_OVERLAP_POLICY_SKIP, + TriggerImmediately: true, + SearchAttributes: map[string]any{ + SearchAttributeScheduleID: scheduleID, + SearchAttributeStack: w.stack, + }, + }, + ) + if err != nil { + return err + } + + err = activities.StorageSchedulesStore( + infiniteRetryContext(ctx), + models.Schedule{ + ID: scheduleID, + ConnectorID: connectorID, + CreatedAt: workflow.Now(ctx).UTC(), + }) + if err != nil { + return err + } + } else { + // Run next workflow immediately + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(connectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + nextWorkflow, + request, + task.NextTasks, + ).GetChildWorkflowExecution().Get(ctx, nil); err != nil { + return errors.Wrap(err, "running next workflow") + } + } + } + return nil +} + +const Run = "Run" diff --git a/internal/connectors/engine/workflow/poll_payout.go b/internal/connectors/engine/workflow/poll_payout.go new file mode 100644 index 00000000..50ecaeb3 --- /dev/null +++ b/internal/connectors/engine/workflow/poll_payout.go @@ -0,0 +1,78 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type PollPayout struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + PaymentInitiationID models.PaymentInitiationID + PayoutID string + ScheduleID string +} + +func (w Workflow) runPollPayout( + ctx workflow.Context, + pollPayout PollPayout, +) error { + paymentID, err := w.pollPayout(ctx, pollPayout) + if err != nil { + return w.updateTasksError( + ctx, + pollPayout.TaskID, + pollPayout.ConnectorID, + err, + ) + } + return w.updateTaskSuccess( + ctx, + pollPayout.TaskID, + pollPayout.ConnectorID, + paymentID, + ) +} + +func (w Workflow) pollPayout( + ctx workflow.Context, + pollPayout PollPayout, +) (string, error) { + pollPayoutStatusResponse, err := activities.PluginPollPayoutStatus( + infiniteRetryContext(ctx), + pollPayout.ConnectorID, + models.PollPayoutStatusRequest{ + PayoutID: pollPayout.PayoutID, + }, + ) + if err != nil { + return "", err + } + + if pollPayoutStatusResponse.Payment == nil { + // payment not yet available, waiting for the next polling + return "", nil + } + + payment := models.FromPSPPaymentToPayment(*pollPayoutStatusResponse.Payment, pollPayout.ConnectorID) + + if err := w.storePIPaymentWithStatus( + ctx, + payment, + pollPayout.PaymentInitiationID, + getPIStatusFromPayment(payment.Status), + pollPayout.ConnectorID, + ); err != nil { + return "", err + } + + // everything is done, delete the related schedule + if err := activities.TemporalDeleteSchedule(ctx, pollPayout.ScheduleID); err != nil { + return "", err + } + + return payment.ID.String(), activities.StorageSchedulesDelete(ctx, pollPayout.ScheduleID) +} + +const RunPollPayout = "PollPayout" diff --git a/internal/connectors/engine/workflow/poll_transfer.go b/internal/connectors/engine/workflow/poll_transfer.go new file mode 100644 index 00000000..d7abc4e7 --- /dev/null +++ b/internal/connectors/engine/workflow/poll_transfer.go @@ -0,0 +1,78 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type PollTransfer struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + PaymentInitiationID models.PaymentInitiationID + TransferID string + ScheduleID string +} + +func (w Workflow) runPollTransfer( + ctx workflow.Context, + pollTransfer PollTransfer, +) error { + paymentID, err := w.pollTransfer(ctx, pollTransfer) + if err != nil { + return w.updateTasksError( + ctx, + pollTransfer.TaskID, + pollTransfer.ConnectorID, + err, + ) + } + return w.updateTaskSuccess( + ctx, + pollTransfer.TaskID, + pollTransfer.ConnectorID, + paymentID, + ) +} + +func (w Workflow) pollTransfer( + ctx workflow.Context, + pollTransfer PollTransfer, +) (string, error) { + pollTransferStatusResponse, err := activities.PluginPollTransferStatus( + infiniteRetryContext(ctx), + pollTransfer.ConnectorID, + models.PollTransferStatusRequest{ + TransferID: pollTransfer.TransferID, + }, + ) + if err != nil { + return "", err + } + + if pollTransferStatusResponse.Payment == nil { + // payment not yet available, waiting for the next polling + return "", nil + } + + payment := models.FromPSPPaymentToPayment(*pollTransferStatusResponse.Payment, pollTransfer.ConnectorID) + + if err := w.storePIPaymentWithStatus( + ctx, + payment, + pollTransfer.PaymentInitiationID, + getPIStatusFromPayment(payment.Status), + pollTransfer.ConnectorID, + ); err != nil { + return "", err + } + + // everything is done, delete the related schedule + if err := activities.TemporalDeleteSchedule(ctx, pollTransfer.ScheduleID); err != nil { + return "", err + } + + return payment.ID.String(), activities.StorageSchedulesDelete(ctx, pollTransfer.ScheduleID) +} + +const RunPollTransfer = "PollTransfer" diff --git a/internal/connectors/engine/workflow/reverse.go b/internal/connectors/engine/workflow/reverse.go new file mode 100644 index 00000000..02e7adb1 --- /dev/null +++ b/internal/connectors/engine/workflow/reverse.go @@ -0,0 +1,178 @@ +package workflow + +import ( + "errors" + "math/big" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +var ErrPaymentInitiationNotProcessed = errors.New("payment initiation not processed") + +type ValidateReverse struct { + ConnectorID models.ConnectorID + PI *models.PaymentInitiation + PIReversal *models.PaymentInitiationReversal +} + +func (w Workflow) validateReverse( + ctx workflow.Context, + validateReverse ValidateReverse, +) error { + // First ensure that the payment initiation was processed successfully + err := w.validatePaymentInitiationProcessed(ctx, validateReverse) + if err != nil { + return err + } + + err = w.validateReverseAmount(ctx, validateReverse) + if err != nil { + return err + } + + err = w.validateOnlyReverse(ctx, validateReverse) + if err != nil { + return err + } + + return nil +} + +func (w Workflow) validateOnlyReverse( + ctx workflow.Context, + validateReverse ValidateReverse, +) error { + now := workflow.Now(ctx) + // Second, ensure that we do not have another reverse currently being processed + inserted, err := activities.StoragePaymentInitiationsAdjusmentsIfPredicateStore( + infiniteRetryContext(ctx), + models.PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: validateReverse.PIReversal.PaymentInitiationID, + CreatedAt: now, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_PROCESSING, + }, + PaymentInitiationID: validateReverse.PIReversal.PaymentInitiationID, + CreatedAt: now, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_PROCESSING, + Amount: validateReverse.PIReversal.Amount, + Asset: &validateReverse.PI.Asset, + Metadata: validateReverse.PIReversal.Metadata, + }, + []models.PaymentInitiationAdjustmentStatus{ + models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_PROCESSING, + }, + ) + if err != nil { + return err + } + + if !inserted { + err = errors.New("another reverse is already in progress") + return temporal.NewNonRetryableApplicationError(err.Error(), "ANOTHER_REVERSE_IN_PROGRESS", err) + } + + return nil +} + +func (w Workflow) validatePaymentInitiationProcessed( + ctx workflow.Context, + validateReverse ValidateReverse, +) error { + query := storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(1). + WithQueryBuilder(query.Match("status", models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED.String())), + ) + + adjustments, err := activities.StoragePaymentInitiationAdjustmentsList( + infiniteRetryContext(ctx), + models.PaymentInitiationID(validateReverse.PIReversal.PaymentInitiationID), + query, + ) + if err != nil { + return err + } + + if len(adjustments.Data) > 0 { + // Payment initiation has been processed + return nil + } + return temporal.NewNonRetryableApplicationError("no adjustments found", "PAYMENT_INITIATION_NOT_PROCESSED", ErrPaymentInitiationNotProcessed) +} + +func (w Workflow) validateReverseAmount( + ctx workflow.Context, + validateReverse ValidateReverse, +) error { + amount := validateReverse.PI.Amount + + query := storage.NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(storage.PaymentInitiationAdjustmentsQuery{}). + WithPageSize(100). + WithQueryBuilder(query.Match("status", models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED.String())), + ) + + amountReversed := big.NewInt(0) + for { + adjs, err := activities.StoragePaymentInitiationAdjustmentsList( + infiniteRetryContext(ctx), + validateReverse.PI.ID, + query, + ) + if err != nil { + return err + } + + for _, adj := range adjs.Data { + amountReversed.Add(amountReversed, adj.Amount) + } + + if !adjs.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(adjs.Next, &query) + if err != nil { + return err + } + } + + currentAmount := new(big.Int).Sub(amount, amountReversed) + nextAmount := new(big.Int).Sub(currentAmount, validateReverse.PIReversal.Amount) + switch nextAmount.Sign() { + case -1: + // we are in the negative, we cannot reverse more than the amount + err := errors.New("cannot reverse more than the amount") + return temporal.NewNonRetryableApplicationError(err.Error(), "CANNOT_REVERSE_MORE_THAN_AMOUNT", err) + default: + return nil + } +} + +func (w Workflow) addPIReversalAdjustment( + ctx workflow.Context, + adjustmentID models.PaymentInitiationReversalAdjustmentID, + err error, + metadata map[string]string, +) error { + adj := models.PaymentInitiationReversalAdjustment{ + ID: adjustmentID, + PaymentInitiationReversalID: adjustmentID.PaymentInitiationReversalID, + CreatedAt: workflow.Now(ctx), + Status: adjustmentID.Status, + Error: err, + Metadata: metadata, + } + + return activities.StoragePaymentInitiationReversalsAdjustmentsStore( + infiniteRetryContext(ctx), + adj, + ) +} diff --git a/internal/connectors/engine/workflow/reverse_payout.go b/internal/connectors/engine/workflow/reverse_payout.go new file mode 100644 index 00000000..abdd6a24 --- /dev/null +++ b/internal/connectors/engine/workflow/reverse_payout.go @@ -0,0 +1,157 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type ReversePayout struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + PaymentInitiationReversalID models.PaymentInitiationReversalID +} + +func (w Workflow) runReversePayout( + ctx workflow.Context, + reversePayout ReversePayout, +) error { + paymentID, err := w.reversePayout(ctx, reversePayout) + if err != nil { + errUpdateTask := w.updateTasksError( + ctx, + reversePayout.TaskID, + reversePayout.ConnectorID, + err, + ) + if errUpdateTask != nil { + return errUpdateTask + } + + return err + } + + return w.updateTaskSuccess( + ctx, + reversePayout.TaskID, + reversePayout.ConnectorID, + paymentID, + ) +} + +func (w Workflow) reversePayout( + ctx workflow.Context, + reversePayout ReversePayout, +) (string, error) { + // Get the payment initiation reversal + piReversal, err := activities.StoragePaymentInitiationReversalsGet( + infiniteRetryContext(ctx), + reversePayout.PaymentInitiationReversalID, + ) + if err != nil { + return "", err + } + + pi, err := activities.StoragePaymentInitiationsGet( + infiniteRetryContext(ctx), + piReversal.PaymentInitiationID, + ) + if err != nil { + return "", err + } + + if err := w.validateReverse( + ctx, + ValidateReverse{ + ConnectorID: reversePayout.ConnectorID, + PI: pi, + PIReversal: piReversal, + }, + ); err != nil { + return "", err + } + + pspPI, err := w.getPSPPI(ctx, pi) + if err != nil { + return "", err + } + + pspReversal := models.FromPaymentInitiationReversalToPSPPaymentInitiationReversal( + piReversal, + pspPI, + ) + + reversePayoutResponse, errPlugin := activities.PluginReversePayout( + infiniteRetryContext(ctx), + reversePayout.ConnectorID, + models.ReversePayoutRequest{ + PaymentInitiationReversal: pspReversal, + }, + ) + switch errPlugin { + case nil: + payment := models.FromPSPPaymentToPayment(reversePayoutResponse.Payment, reversePayout.ConnectorID) + + // Store refund for the payment initiation + if err := w.storePIPaymentWithStatus( + ctx, + payment, + pi.ID, + getPIStatusFromPayment(payment.Status), + reversePayout.ConnectorID, + ); err != nil { + return "", err + } + + err := w.addPIReversalAdjustment( + ctx, + models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reversePayout.PaymentInitiationReversalID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, + }, + nil, + nil, + ) + if err != nil { + return "", err + } + + return payment.ID.String(), nil + + default: + err := w.addPIReversalAdjustment( + ctx, + models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reversePayout.PaymentInitiationReversalID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_FAILED, + }, + errPlugin, + nil, + ) + if err != nil { + return "", err + } + + err = w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: pi.ID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED, + }, + pi.Amount, + &pi.Asset, + nil, + nil, + ) + if err != nil { + return "", err + } + + return "", errPlugin + } +} + +var RunReversePayout = "ReversePayout" diff --git a/internal/connectors/engine/workflow/reverse_transfer.go b/internal/connectors/engine/workflow/reverse_transfer.go new file mode 100644 index 00000000..94614894 --- /dev/null +++ b/internal/connectors/engine/workflow/reverse_transfer.go @@ -0,0 +1,157 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type ReverseTransfer struct { + TaskID models.TaskID + ConnectorID models.ConnectorID + PaymentInitiationReversalID models.PaymentInitiationReversalID +} + +func (w Workflow) runReverseTransfer( + ctx workflow.Context, + reverseTransfer ReverseTransfer, +) error { + paymentID, err := w.reverseTransfer(ctx, reverseTransfer) + if err != nil { + errUpdateTask := w.updateTasksError( + ctx, + reverseTransfer.TaskID, + reverseTransfer.ConnectorID, + err, + ) + if errUpdateTask != nil { + return errUpdateTask + } + + return err + } + + return w.updateTaskSuccess( + ctx, + reverseTransfer.TaskID, + reverseTransfer.ConnectorID, + paymentID, + ) +} + +func (w Workflow) reverseTransfer( + ctx workflow.Context, + reverseTransfer ReverseTransfer, +) (string, error) { + // Get the payment initiation reversal + piReversal, err := activities.StoragePaymentInitiationReversalsGet( + infiniteRetryContext(ctx), + reverseTransfer.PaymentInitiationReversalID, + ) + if err != nil { + return "", err + } + + pi, err := activities.StoragePaymentInitiationsGet( + infiniteRetryContext(ctx), + piReversal.PaymentInitiationID, + ) + if err != nil { + return "", err + } + + if err := w.validateReverse( + ctx, + ValidateReverse{ + ConnectorID: reverseTransfer.ConnectorID, + PI: pi, + PIReversal: piReversal, + }, + ); err != nil { + return "", err + } + + pspPI, err := w.getPSPPI(ctx, pi) + if err != nil { + return "", err + } + + pspReversal := models.FromPaymentInitiationReversalToPSPPaymentInitiationReversal( + piReversal, + pspPI, + ) + + reverseTransferResponse, errPlugin := activities.PluginReverseTransfer( + infiniteRetryContext(ctx), + reverseTransfer.ConnectorID, + models.ReverseTransferRequest{ + PaymentInitiationReversal: pspReversal, + }, + ) + switch errPlugin { + case nil: + payment := models.FromPSPPaymentToPayment(reverseTransferResponse.Payment, reverseTransfer.ConnectorID) + + // Store refund for the payment initiation + if err := w.storePIPaymentWithStatus( + ctx, + payment, + pi.ID, + getPIStatusFromPayment(payment.Status), + reverseTransfer.ConnectorID, + ); err != nil { + return "", err + } + + err := w.addPIReversalAdjustment( + ctx, + models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reverseTransfer.PaymentInitiationReversalID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, + }, + nil, + nil, + ) + if err != nil { + return "", err + } + + return payment.ID.String(), nil + + default: + err := w.addPIReversalAdjustment( + ctx, + models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reverseTransfer.PaymentInitiationReversalID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_FAILED, + }, + errPlugin, + nil, + ) + if err != nil { + return "", err + } + + err = w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: pi.ID, + CreatedAt: workflow.Now(ctx), + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED, + }, + pi.Amount, + &pi.Asset, + nil, + nil, + ) + if err != nil { + return "", err + } + + return "", errPlugin + } +} + +var RunReverseTransfer = "ReverseTransfer" diff --git a/internal/connectors/engine/workflow/send_events.go b/internal/connectors/engine/workflow/send_events.go new file mode 100644 index 00000000..592ac353 --- /dev/null +++ b/internal/connectors/engine/workflow/send_events.go @@ -0,0 +1,195 @@ +package workflow + +import ( + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "go.temporal.io/sdk/workflow" +) + +type sendEventActivityFunction func(ctx workflow.Context) error + +type SendEvents struct { + Account *models.Account + Balance *models.Balance + BankAccount *models.BankAccount + Payment *models.Payment + ConnectorReset *models.ConnectorID + PoolsCreation *models.Pool + PoolsDeletion *uuid.UUID +} + +func (w Workflow) runSendEvents( + ctx workflow.Context, + sendEvents SendEvents, +) error { + if sendEvents.Account != nil { + err := sendEvent( + ctx, + sendEvents.Account.IdempotencyKey(), + &sendEvents.Account.ConnectorID, + func(ctx workflow.Context) error { + return activities.EventsSendAccount( + infiniteRetryContext(ctx), + *sendEvents.Account, + ) + }, + ) + if err != nil { + return err + } + } + + if sendEvents.Balance != nil { + err := sendEvent( + ctx, + sendEvents.Balance.IdempotencyKey(), + &sendEvents.Balance.AccountID.ConnectorID, + func(ctx workflow.Context) error { + return activities.EventsSendBalance( + infiniteRetryContext(ctx), + *sendEvents.Balance, + ) + }, + ) + if err != nil { + return err + } + } + + if sendEvents.BankAccount != nil { + err := sendEvent( + ctx, + sendEvents.BankAccount.IdempotencyKey(), + nil, + func(ctx workflow.Context) error { + return activities.EventsSendBankAccount( + infiniteRetryContext(ctx), + *sendEvents.BankAccount, + ) + }, + ) + if err != nil { + return err + } + } + + if sendEvents.Payment != nil { + for _, adjustment := range sendEvents.Payment.Adjustments { + err := sendEvent( + ctx, + adjustment.IdempotencyKey(), + &sendEvents.Payment.ConnectorID, + func(ctx workflow.Context) error { + return activities.EventsSendPayment( + infiniteRetryContext(ctx), + *sendEvents.Payment, + adjustment, + ) + }, + ) + if err != nil { + return err + } + } + } + + if sendEvents.ConnectorReset != nil { + now := workflow.Now(ctx).UTC() + err := sendEvent( + ctx, + fmt.Sprintf("%s-%s", sendEvents.ConnectorReset.String(), now.Format(time.RFC3339Nano)), + sendEvents.ConnectorReset, + func(ctx workflow.Context) error { + return activities.EventsSendConnectorReset( + infiniteRetryContext(ctx), + *sendEvents.ConnectorReset, + now, + ) + }, + ) + if err != nil { + return err + } + } + + if sendEvents.PoolsCreation != nil { + err := sendEvent( + ctx, + sendEvents.PoolsCreation.IdempotencyKey(), + nil, + func(ctx workflow.Context) error { + return activities.EventsSendPoolCreation( + infiniteRetryContext(ctx), + *sendEvents.PoolsCreation, + ) + }, + ) + if err != nil { + return err + } + } + + if sendEvents.PoolsDeletion != nil { + err := sendEvent( + ctx, + sendEvents.PoolsDeletion.String(), + nil, + func(ctx workflow.Context) error { + return activities.EventsSendPoolDeletion( + infiniteRetryContext(ctx), + *sendEvents.PoolsDeletion, + ) + }, + ) + if err != nil { + return err + } + } + + return nil +} + +const RunSendEvents = "RunSendEvents" + +func sendEvent( + ctx workflow.Context, + idempotencyKey string, + connectorID *models.ConnectorID, + fn sendEventActivityFunction, +) error { + isExisting, err := activities.StorageEventsSentExists( + infiniteRetryContext(ctx), + idempotencyKey, + connectorID, + ) + if err != nil { + return err + } + + if !isExisting { + // event was not sent yet + if err := fn(ctx); err != nil { + return err + } + + if err := activities.StorageEventsSentStore( + infiniteRetryContext(ctx), + models.EventSent{ + ID: models.EventID{ + EventIdempotencyKey: idempotencyKey, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + SentAt: workflow.Now(ctx).UTC(), + }, + ); err != nil { + return err + } + } + + return nil +} diff --git a/internal/connectors/engine/workflow/terminate_schedules.go b/internal/connectors/engine/workflow/terminate_schedules.go new file mode 100644 index 00000000..47490de7 --- /dev/null +++ b/internal/connectors/engine/workflow/terminate_schedules.go @@ -0,0 +1,60 @@ +package workflow + +import ( + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/storage" + "go.temporal.io/sdk/workflow" +) + +func (w Workflow) runTerminateSchedules( + ctx workflow.Context, + uninstallConnector UninstallConnector, +) error { + query := storage.NewListSchedulesQuery( + bunpaginate.NewPaginatedQueryOptions(storage.ScheduleQuery{}). + WithPageSize(100). + WithQueryBuilder( + query.Match("connector_id", uninstallConnector.ConnectorID.String()), + ), + ) + for { + schedules, err := activities.StorageSchedulesList(infiniteRetryContext(ctx), query) + if err != nil { + return err + } + + wg := workflow.NewWaitGroup(ctx) + + for _, schedule := range schedules.Data { + s := schedule + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + if err := activities.TemporalScheduleDelete( + infiniteRetryContext(ctx), + s.ID, + ); err != nil { + workflow.GetLogger(ctx).Error("failed to delete schedule", "schedule_id", s.ID, "error", err) + } + }) + } + + wg.Wait(ctx) + + if !schedules.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(schedules.Next, &query) + if err != nil { + return err + } + } + + return nil +} + +const RunTerminateSchedules = "TerminateSchedules" diff --git a/internal/connectors/engine/workflow/terminate_workflows.go b/internal/connectors/engine/workflow/terminate_workflows.go new file mode 100644 index 00000000..402f57a9 --- /dev/null +++ b/internal/connectors/engine/workflow/terminate_workflows.go @@ -0,0 +1,90 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/api/enums/v1" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/workflow" +) + +type TerminateWorkflows struct { + ConnectorID models.ConnectorID + NextPageToken []byte +} + +func (w Workflow) runTerminateWorkflows( + ctx workflow.Context, + terminateWorkflows TerminateWorkflows, +) error { + var nextPageToken []byte = terminateWorkflows.NextPageToken + + for { + resp, err := activities.TemporalWorkflowExecutionsList( + infiniteRetryContext(ctx), + &workflowservice.ListWorkflowExecutionsRequest{ + Namespace: w.temporalNamespace, + PageSize: 100, + NextPageToken: nextPageToken, + Query: fmt.Sprintf("Stack=\"%s\" and TaskQueue=\"%s\"", w.stack, terminateWorkflows.ConnectorID.String()), + }, + ) + if err != nil { + return err + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, len(resp.Executions)) + for _, e := range resp.Executions { + if e.Status != enums.WORKFLOW_EXECUTION_STATUS_RUNNING { + continue + } + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + + if err := activities.TemporalWorkflowTerminate( + infiniteRetryContext(ctx), + e.Execution.WorkflowId, + e.Execution.RunId, + "uninstalling connector", + ); err != nil { + errChan <- err + } + }) + } + + wg.Wait(ctx) + close(errChan) + + for err := range errChan { + if err != nil { + return err + } + } + + if resp.NextPageToken == nil { + break + } + + nextPageToken = resp.NextPageToken + + if w.shouldContinueAsNew(ctx) { + // Because we can have lots and lots of workflows, sometimes, we + // will exceed the maximum history size or length of a workflow. + // When that arrive, the workflow will be forced to terminate. + // We need to continue as new to avoid this. + return workflow.NewContinueAsNewError(ctx, RunTerminateWorkflows, TerminateWorkflows{ + ConnectorID: terminateWorkflows.ConnectorID, + NextPageToken: nextPageToken, + }) + } + } + + return nil +} + +const RunTerminateWorkflows = "TerminateWorkflows" diff --git a/internal/connectors/engine/workflow/uninstall_connector.go b/internal/connectors/engine/workflow/uninstall_connector.go new file mode 100644 index 00000000..86011f51 --- /dev/null +++ b/internal/connectors/engine/workflow/uninstall_connector.go @@ -0,0 +1,176 @@ +package workflow + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +type UninstallConnector struct { + ConnectorID models.ConnectorID + DefaultWorkerName string +} + +func (w Workflow) runUninstallConnector( + ctx workflow.Context, + uninstallConnector UninstallConnector, +) error { + // First, terminate all schedules in order to prevent any workflows + // to be launched again. + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: uninstallConnector.DefaultWorkerName, + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunTerminateSchedules, + uninstallConnector, + ).Get(ctx, nil); err != nil { + return fmt.Errorf("terminate schedules: %w", err) + } + + // Since we can have lots of workflows running, we don't need to wait for + // them to be terminated before proceeding with the uninstallation. + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: uninstallConnector.DefaultWorkerName, + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunTerminateWorkflows, + TerminateWorkflows{ + ConnectorID: uninstallConnector.ConnectorID, + }, + ).GetChildWorkflowExecution().Get(ctx, nil); err != nil { + return fmt.Errorf("terminate workflows: %w", err) + } + + wg := workflow.NewWaitGroup(ctx) + errChan := make(chan error, 32) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + _, err := activities.PluginUninstallConnector(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageEventsSentDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageSchedulesDeleteFromConnectorID(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageInstancesDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageConnectortTasksTreeDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageTasksDeleteFromConnectorID(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageBankAccountsDeleteRelatedAccounts(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageAccountsDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StoragePaymentsDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageStatesDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageWebhooksConfigsDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StorageWebhooksDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Add(1) + workflow.Go(ctx, func(ctx workflow.Context) { + defer wg.Done() + err := activities.StoragePoolsRemoveAccountsFromConnectorID(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + errChan <- err + }) + + wg.Wait(ctx) + close(errChan) + + for err := range errChan { + if err != nil { + return err + } + } + + err := activities.StorageConnectorsDelete(infiniteRetryContext(ctx), uninstallConnector.ConnectorID) + if err != nil { + return err + } + + if err := w.plugins.UnregisterPlugin(uninstallConnector.ConnectorID); err != nil { + return err + } + + return nil +} + +const RunUninstallConnector = "UninstallConnector" diff --git a/internal/connectors/engine/workflow/update_payment_initiation_from_payment.go b/internal/connectors/engine/workflow/update_payment_initiation_from_payment.go new file mode 100644 index 00000000..59e1ea62 --- /dev/null +++ b/internal/connectors/engine/workflow/update_payment_initiation_from_payment.go @@ -0,0 +1,51 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +type UpdatePaymentInitiationFromPayment struct { + Payment *models.Payment +} + +func (w Workflow) runUpdatePaymentInitiationFromPayment( + ctx workflow.Context, + updatePaymentInitiationFromPayment UpdatePaymentInitiationFromPayment, +) error { + piIDs, err := activities.StoragePaymentInitiationIDsListFromPaymentID( + infiniteRetryContext(ctx), + updatePaymentInitiationFromPayment.Payment.ID, + ) + if err != nil { + return err + } + + if len(piIDs) == 0 { + // Nothing to do here + return nil + } + + for _, piID := range piIDs { + adjustment := models.FromPaymentToPaymentInitiationAdjustment( + updatePaymentInitiationFromPayment.Payment, + piID, + ) + + if adjustment == nil { + continue + } + + if err := activities.StoragePaymentInitiationsAdjustmentsStore( + infiniteRetryContext(ctx), + *adjustment, + ); err != nil { + return err + } + } + + return nil +} + +const RunUpdatePaymentInitiationFromPayment = "RunUpdatePaymentInitiationFromPayment" diff --git a/internal/connectors/engine/workflow/update_tasks.go b/internal/connectors/engine/workflow/update_tasks.go new file mode 100644 index 00000000..c60e4e66 --- /dev/null +++ b/internal/connectors/engine/workflow/update_tasks.go @@ -0,0 +1,41 @@ +package workflow + +import ( + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/sdk/workflow" +) + +func (w Workflow) updateTasksError( + ctx workflow.Context, + taskID models.TaskID, + connectorID models.ConnectorID, + err error, +) error { + return activities.StorageTasksStore( + infiniteRetryContext(ctx), + models.Task{ + ID: taskID, + ConnectorID: connectorID, + Status: models.TASK_STATUS_FAILED, + UpdatedAt: workflow.Now(ctx).UTC(), + Error: err, + }) +} + +func (w Workflow) updateTaskSuccess( + ctx workflow.Context, + taskID models.TaskID, + connectorID models.ConnectorID, + relatedObjectID string, +) error { + return activities.StorageTasksStore( + infiniteRetryContext(ctx), + models.Task{ + ID: taskID, + ConnectorID: connectorID, + Status: models.TASK_STATUS_SUCCEEDED, + UpdatedAt: workflow.Now(ctx).UTC(), + CreatedObjectID: &relatedObjectID, + }) +} diff --git a/internal/connectors/engine/workflow/utils.go b/internal/connectors/engine/workflow/utils.go new file mode 100644 index 00000000..a8093da2 --- /dev/null +++ b/internal/connectors/engine/workflow/utils.go @@ -0,0 +1,156 @@ +package workflow + +import ( + "fmt" + "math/big" + + "github.com/formancehq/payments/internal/connectors/engine/activities" + "github.com/formancehq/payments/internal/models" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +func (w Workflow) storePIPaymentWithStatus( + ctx workflow.Context, + payment models.Payment, + paymentInitiationID models.PaymentInitiationID, + status models.PaymentInitiationAdjustmentStatus, + connectorID models.ConnectorID, +) error { + // payment is available, storing it + err := activities.StoragePaymentsStore( + infiniteRetryContext(ctx), + []models.Payment{payment}, + ) + if err != nil { + return err + } + + if err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions( + ctx, + workflow.ChildWorkflowOptions{ + TaskQueue: w.getConnectorTaskQueue(connectorID), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + SearchAttributes: map[string]interface{}{ + SearchAttributeStack: w.stack, + }, + }, + ), + RunSendEvents, + SendEvents{ + Payment: &payment, + }, + ).Get(ctx, nil); err != nil { + return err + } + + err = activities.StoragePaymentInitiationsRelatedPaymentsStore( + infiniteRetryContext(ctx), + paymentInitiationID, + payment.ID, + payment.CreatedAt, + ) + if err != nil { + return err + } + + err = w.addPIAdjustment( + ctx, + models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: paymentInitiationID, + CreatedAt: workflow.Now(ctx), + Status: status, + }, + payment.Amount, + &payment.Asset, + nil, + nil, + ) + if err != nil { + return err + } + return nil +} + +func (w Workflow) addPIAdjustment( + ctx workflow.Context, + adjustmentID models.PaymentInitiationAdjustmentID, + amount *big.Int, + asset *string, + err error, + metadata map[string]string, +) error { + adj := models.PaymentInitiationAdjustment{ + ID: adjustmentID, + PaymentInitiationID: adjustmentID.PaymentInitiationID, + CreatedAt: workflow.Now(ctx), + Status: adjustmentID.Status, + Amount: amount, + Asset: asset, + Error: err, + Metadata: metadata, + } + + return activities.StoragePaymentInitiationsAdjustmentsStore( + infiniteRetryContext(ctx), + adj, + ) +} + +func (w Workflow) getPSPPI( + ctx workflow.Context, + pi *models.PaymentInitiation, +) (models.PSPPaymentInitiation, error) { + var sourceAccount *models.Account + if pi.SourceAccountID != nil { + var err error + sourceAccount, err = activities.StorageAccountsGet( + infiniteRetryContext(ctx), + *pi.SourceAccountID, + ) + if err != nil { + return models.PSPPaymentInitiation{}, err + } + } + var destinationAccount *models.Account + if pi.DestinationAccountID != nil { + var err error + destinationAccount, err = activities.StorageAccountsGet( + infiniteRetryContext(ctx), + *pi.DestinationAccountID, + ) + if err != nil { + return models.PSPPaymentInitiation{}, err + } + } + pspPI := models.FromPaymentInitiationToPSPPaymentInitiation(pi, models.ToPSPAccount(sourceAccount), models.ToPSPAccount(destinationAccount)) + return pspPI, nil +} + +func getPIStatusFromPayment(status models.PaymentStatus) models.PaymentInitiationAdjustmentStatus { + switch status { + case models.PAYMENT_STATUS_SUCCEEDED, + models.PAYMENT_STATUS_CAPTURE, + models.PAYMENT_STATUS_REFUND_REVERSED: + return models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED + case models.PAYMENT_STATUS_CANCELLED, + models.PAYMENT_STATUS_CAPTURE_FAILED, + models.PAYMENT_STATUS_FAILED, + models.PAYMENT_STATUS_EXPIRED: + return models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED + case models.PAYMENT_STATUS_PENDING, + models.PAYMENT_STATUS_AUTHORISATION: + return models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING + case models.PAYMENT_STATUS_REFUNDED: + return models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED + case models.PAYMENT_STATUS_REFUNDED_FAILURE: + return models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED + default: + return models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_UNKNOWN + } +} + +func (w Workflow) getConnectorTaskQueue(connectorID models.ConnectorID) string { + return fmt.Sprintf("%s-%s", w.stack, connectorID.String()) +} diff --git a/internal/connectors/engine/workflow/workflow.go b/internal/connectors/engine/workflow/workflow.go new file mode 100644 index 00000000..e795845b --- /dev/null +++ b/internal/connectors/engine/workflow/workflow.go @@ -0,0 +1,148 @@ +package workflow + +import ( + "encoding/json" + + temporalworker "github.com/formancehq/go-libs/v2/temporal" + "github.com/formancehq/payments/internal/connectors/engine/plugins" + "github.com/formancehq/payments/internal/connectors/engine/webhooks" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const ( + SearchAttributeWorkflowID = "PaymentWorkflowID" + SearchAttributeScheduleID = "PaymentScheduleID" + SearchAttributeStack = "Stack" +) + +type FromPayload struct { + ID string `json:"id"` + Payload json.RawMessage `json:"payload"` +} + +func (f *FromPayload) GetPayload() json.RawMessage { + if f == nil { + return nil + } + return f.Payload +} + +type Workflow struct { + temporalNamespace string + temporalClient client.Client + + plugins plugins.Plugins + webhooks webhooks.Webhooks + + stackPublicURL string + stack string +} + +func New(temporalClient client.Client, temporalNamespace string, plugins plugins.Plugins, webhooks webhooks.Webhooks, stack string, stackPublicURL string) Workflow { + return Workflow{ + temporalClient: temporalClient, + temporalNamespace: temporalNamespace, + plugins: plugins, + webhooks: webhooks, + stack: stack, + stackPublicURL: stackPublicURL, + } +} + +func (w Workflow) DefinitionSet() temporalworker.DefinitionSet { + return temporalworker.NewDefinitionSet(). + Append(temporalworker.Definition{ + Name: RunFetchNextAccounts, + Func: w.runFetchNextAccounts, + }). + Append(temporalworker.Definition{ + Name: RunFetchNextBalances, + Func: w.runFetchNextBalances, + }). + Append(temporalworker.Definition{ + Name: RunFetchNextExternalAccounts, + Func: w.runFetchNextExternalAccounts, + }). + Append(temporalworker.Definition{ + Name: RunFetchNextOthers, + Func: w.runFetchNextOthers, + }). + Append(temporalworker.Definition{ + Name: RunFetchNextPayments, + Func: w.runFetchNextPayments, + }). + Append(temporalworker.Definition{ + Name: RunTerminateSchedules, + Func: w.runTerminateSchedules, + }). + Append(temporalworker.Definition{ + Name: RunTerminateWorkflows, + Func: w.runTerminateWorkflows, + }). + Append(temporalworker.Definition{ + Name: RunInstallConnector, + Func: w.runInstallConnector, + }). + Append(temporalworker.Definition{ + Name: RunUninstallConnector, + Func: w.runUninstallConnector, + }). + Append(temporalworker.Definition{ + Name: RunCreateBankAccount, + Func: w.runCreateBankAccount, + }). + Append(temporalworker.Definition{ + Name: RunCreatePayout, + Func: w.runCreatePayout, + }). + Append(temporalworker.Definition{ + Name: RunReversePayout, + Func: w.runReversePayout, + }). + Append(temporalworker.Definition{ + Name: RunPollPayout, + Func: w.runPollPayout, + }). + Append(temporalworker.Definition{ + Name: RunCreateTransfer, + Func: w.runCreateTransfer, + }). + Append(temporalworker.Definition{ + Name: RunReverseTransfer, + Func: w.runReverseTransfer, + }). + Append(temporalworker.Definition{ + Name: RunPollTransfer, + Func: w.runPollTransfer, + }). + Append(temporalworker.Definition{ + Name: Run, + Func: w.run, + }). + Append(temporalworker.Definition{ + Name: RunCreateWebhooks, + Func: w.runCreateWebhooks, + }). + Append(temporalworker.Definition{ + Name: RunHandleWebhooks, + Func: w.runHandleWebhooks, + }). + Append(temporalworker.Definition{ + Name: RunStoreWebhookTranslation, + Func: w.runStoreWebhookTranslation, + }). + Append(temporalworker.Definition{ + Name: RunSendEvents, + Func: w.runSendEvents, + }). + Append(temporalworker.Definition{ + Name: RunUpdatePaymentInitiationFromPayment, + Func: w.runUpdatePaymentInitiationFromPayment, + }) +} + +func (w Workflow) shouldContinueAsNew(ctx workflow.Context) bool { + workflowInfo := workflow.GetInfo(ctx) + return workflowInfo.GetContinueAsNewSuggested() +} diff --git a/internal/connectors/httpwrapper/client.go b/internal/connectors/httpwrapper/client.go new file mode 100644 index 00000000..16bd75a3 --- /dev/null +++ b/internal/connectors/httpwrapper/client.go @@ -0,0 +1,145 @@ +package httpwrapper + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/metrics" + "github.com/hashicorp/go-hclog" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "golang.org/x/oauth2" +) + +const MetricOperationContextKey string = "_metric_operation_context_key" + +var ( + ErrStatusCodeUnexpected = errors.New("unexpected status code") + ErrStatusCodeClientError = fmt.Errorf("%w: http client error", ErrStatusCodeUnexpected) + ErrStatusCodeServerError = fmt.Errorf("%w: http server error", ErrStatusCodeUnexpected) + + defaultHttpErrorCheckerFn = func(statusCode int) error { + if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError { + return ErrStatusCodeClientError + } else if statusCode >= http.StatusInternalServerError { + return ErrStatusCodeServerError + } + return nil + } +) + +// Client is a convenience wrapper that encapsulates common code related to interacting with HTTP endpoints +type Client interface { + // Do performs an HTTP request while handling errors and unmarshaling success and error responses into the provided interfaces + // expectedBody and errorBody should be pointers to structs + Do(ctx context.Context, req *http.Request, expectedBody, errorBody any) (statusCode int, err error) +} + +type client struct { + httpClient *http.Client + commonMetricsAttributes []attribute.KeyValue + + httpErrorCheckerFn func(statusCode int) error +} + +func NewClient(config *Config) Client { + if config.Timeout == 0 { + config.Timeout = 10 * time.Second + } + if config.Transport != nil { + config.Transport = otelhttp.NewTransport(config.Transport) + } else { + config.Transport = http.DefaultTransport.(*http.Transport).Clone() + } + + httpClient := &http.Client{ + Timeout: config.Timeout, + Transport: config.Transport, + } + if config.OAuthConfig != nil { + // pass a pre-configured http client to oauth lib via the context + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + httpClient = config.OAuthConfig.Client(ctx) + } + + if config.HttpErrorCheckerFn == nil { + config.HttpErrorCheckerFn = defaultHttpErrorCheckerFn + } + + metricsAttributes := make([]attribute.KeyValue, 0) + metricsAttributes = append(metricsAttributes, config.CommonMetricsAttributes...) + + return &client{ + httpErrorCheckerFn: config.HttpErrorCheckerFn, + httpClient: httpClient, + commonMetricsAttributes: metricsAttributes, + } +} + +func (c *client) Do(ctx context.Context, req *http.Request, expectedBody, errorBody any) (int, error) { + start := time.Now() + attrs := c.metricsAttributes(ctx, req) + defer func() { + registry := metrics.GetMetricsRegistry() + opts := metric.WithAttributes(attrs...) + registry.ConnectorPSPCalls().Add(ctx, 1, opts) + registry.ConnectorPSPCallLatencies().Record(ctx, time.Since(start).Milliseconds(), opts) + }() + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to make request: %w", err) + } + attrs = append(attrs, attribute.Int("status", resp.StatusCode)) + + reqErr := c.httpErrorCheckerFn(resp.StatusCode) + // the caller doesn't care about the response body so we return early + if resp.Body == nil || (reqErr == nil && expectedBody == nil) || (reqErr != nil && errorBody == nil) { + return resp.StatusCode, reqErr + } + + defer func() { + err = resp.Body.Close() + if err != nil { + hclog.Default().Error("failed to close response body", "error", err) + } + }() + + // TODO: reading everything into memory might not be optimal if we expect long responses + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) + } + + if reqErr != nil { + if err = json.Unmarshal(rawBody, errorBody); err != nil { + return resp.StatusCode, fmt.Errorf("failed to unmarshal error response (%w) with status %d: %w", err, resp.StatusCode, reqErr) + } + return resp.StatusCode, reqErr + } + + // TODO: assuming json bodies for now, but may need to handle other body types + if err = json.Unmarshal(rawBody, expectedBody); err != nil { + return resp.StatusCode, fmt.Errorf("failed to unmarshal response with status %d: %w", resp.StatusCode, err) + } + return resp.StatusCode, nil +} + +func (c *client) metricsAttributes(ctx context.Context, req *http.Request) []attribute.KeyValue { + attrs := c.commonMetricsAttributes + attrs = append(attrs, attribute.String("endpoint", req.URL.Path)) + + val := ctx.Value(MetricOperationContextKey) + if val != nil { + if name, ok := val.(string); ok { + attrs = append(attrs, attribute.String("operation", name)) + } + } + return attrs +} diff --git a/internal/connectors/httpwrapper/client_test.go b/internal/connectors/httpwrapper/client_test.go new file mode 100644 index 00000000..a76cd723 --- /dev/null +++ b/internal/connectors/httpwrapper/client_test.go @@ -0,0 +1,101 @@ +package httpwrapper_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Client Suite") +} + +type successRes struct { + ID string `json:"id"` +} + +type errorRes struct { + Code string `json:"code"` +} + +var _ = Describe("ClientWrapper", func() { + var ( + config *httpwrapper.Config + client httpwrapper.Client + server *httptest.Server + ) + + BeforeEach(func() { + config = &httpwrapper.Config{Timeout: 30 * time.Millisecond} + client = httpwrapper.NewClient(config) + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + params, err := url.ParseQuery(r.URL.RawQuery) + Expect(err).To(BeNil()) + + code := params.Get("code") + statusCode, err := strconv.Atoi(code) + Expect(err).To(BeNil()) + if statusCode == http.StatusOK { + w.Write([]byte(`{"id":"someid"}`)) + return + } + + w.WriteHeader(statusCode) + w.Write([]byte(`{"code":"err123"}`)) + })) + }) + AfterEach(func() { + server.Close() + }) + + Context("making a request with default client settings", func() { + It("unmarshals successful responses when acceptable status code seen", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?code=200", http.NoBody) + Expect(err).To(BeNil()) + + res := &successRes{} + code, doErr := client.Do(context.Background(), req, res, nil) + Expect(code).To(Equal(http.StatusOK)) + Expect(doErr).To(BeNil()) + Expect(res.ID).To(Equal("someid")) + }) + It("unmarshals error responses when bad status code seen", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?code=500", http.NoBody) + Expect(err).To(BeNil()) + + res := &errorRes{} + code, doErr := client.Do(context.Background(), req, &successRes{}, res) + Expect(code).To(Equal(http.StatusInternalServerError)) + Expect(doErr).To(MatchError(httpwrapper.ErrStatusCodeServerError)) + Expect(res.Code).To(Equal("err123")) + }) + It("unmarshals error responses when http client error seen", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?code=400", http.NoBody) + Expect(err).To(BeNil()) + + res := &errorRes{} + code, doErr := client.Do(context.Background(), req, &successRes{}, res) + Expect(code).To(Equal(http.StatusBadRequest)) + Expect(doErr).To(MatchError(httpwrapper.ErrStatusCodeClientError)) + Expect(res.Code).To(Equal("err123")) + }) + It("responds with error when HTTP request fails", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "notaurl", http.NoBody) + Expect(err).To(BeNil()) + + res := &errorRes{} + code, doErr := client.Do(context.Background(), req, &successRes{}, res) + Expect(code).To(Equal(0)) + Expect(doErr).To(MatchError(ContainSubstring("failed to make request"))) + }) + }) +}) diff --git a/internal/connectors/httpwrapper/config.go b/internal/connectors/httpwrapper/config.go new file mode 100644 index 00000000..56586641 --- /dev/null +++ b/internal/connectors/httpwrapper/config.go @@ -0,0 +1,25 @@ +package httpwrapper + +import ( + "net/http" + "time" + + "go.opentelemetry.io/otel/attribute" + "golang.org/x/oauth2/clientcredentials" +) + +type Config struct { + HttpErrorCheckerFn func(code int) error + CommonMetricsAttributes []attribute.KeyValue + + Timeout time.Duration + Transport http.RoundTripper + OAuthConfig *clientcredentials.Config +} + +func CommonMetricsAttributesFor(connectorName string) []attribute.KeyValue { + metricsAttributes := []attribute.KeyValue{ + attribute.String("connector", connectorName), + } + return metricsAttributes +} diff --git a/cmd/connectors/internal/metrics/metrics.go b/internal/connectors/metrics/metrics.go similarity index 100% rename from cmd/connectors/internal/metrics/metrics.go rename to internal/connectors/metrics/metrics.go diff --git a/internal/connectors/plugins/configs.go b/internal/connectors/plugins/configs.go new file mode 100644 index 00000000..794eca3b --- /dev/null +++ b/internal/connectors/plugins/configs.go @@ -0,0 +1,79 @@ +package plugins + +import ( + _ "embed" + "encoding/json" + "errors" + "sync" +) + +//go:embed configs.json +var configsFile []byte + +type Type string + +const ( + TypeLongString Type = "long string" + TypeString Type = "string" + TypeDurationNs Type = "duration ns" + TypeDurationUnsignedInteger Type = "unsigned integer" + TypeBoolean Type = "boolean" +) + +type Configs map[string]Config +type Config map[string]Parameter +type Parameter struct { + DataType Type `json:"dataType"` + Required bool `json:"required"` + DefaultValue string `json:"defaultValue"` +} + +var ( + defaultParameters = map[string]Parameter{ + "pollingPeriod": { + DataType: "duration ns", + Required: false, + DefaultValue: "2m", + }, + "pageSize": { + DataType: "unsigned integer", + Required: false, + DefaultValue: "100", + }, + "name": { + DataType: "string", + Required: true, + }, + } + + configs Configs +) + +var once sync.Once + +func GetConfigs() Configs { + once.Do(func() { + if err := json.Unmarshal(configsFile, &configs); err != nil { + panic(err) + } + + for key := range configs { + for paramName, param := range defaultParameters { + if _, ok := configs[key][paramName]; !ok { + configs[key][paramName] = param + } + } + } + }) + + return configs +} + +func GetConfig(provider string) (Config, error) { + config, ok := configs[provider] + if !ok { + return nil, errors.New("config not found") + } + + return config, nil +} diff --git a/cmd/connectors/internal/connectors/generic/client/generated/go.sum b/internal/connectors/plugins/configs.json similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/go.sum rename to internal/connectors/plugins/configs.json diff --git a/cmd/connectors/internal/connectors/currency/amount.go b/internal/connectors/plugins/currency/amount.go similarity index 100% rename from cmd/connectors/internal/connectors/currency/amount.go rename to internal/connectors/plugins/currency/amount.go diff --git a/cmd/connectors/internal/connectors/currency/amount_test.go b/internal/connectors/plugins/currency/amount_test.go similarity index 100% rename from cmd/connectors/internal/connectors/currency/amount_test.go rename to internal/connectors/plugins/currency/amount_test.go diff --git a/cmd/connectors/internal/connectors/currency/currency.go b/internal/connectors/plugins/currency/currency.go similarity index 94% rename from cmd/connectors/internal/connectors/currency/currency.go rename to internal/connectors/plugins/currency/currency.go index 3ec4e7ce..7524d4a4 100644 --- a/cmd/connectors/internal/connectors/currency/currency.go +++ b/internal/connectors/plugins/currency/currency.go @@ -4,11 +4,11 @@ import ( "errors" "fmt" "strings" - - "github.com/formancehq/payments/internal/models" ) var ( + ErrMissingCurrencies = errors.New("missing currencies") + UnsupportedCurrencies = map[string]struct{}{ "HUF": {}, "ISK": {}, @@ -185,19 +185,19 @@ var ( } ) -func FormatAsset(currencies map[string]int, cur string) models.Asset { +func FormatAsset(currencies map[string]int, cur string) string { asset := strings.ToUpper(string(cur)) def, ok := currencies[asset] if !ok { - return models.Asset(asset) + return asset } if def == 0 { - return models.Asset(asset) + return asset } - return models.Asset(fmt.Sprintf("%s/%d", asset, def)) + return fmt.Sprintf("%s/%d", asset, def) } func GetPrecision(currencies map[string]int, cur string) (int, error) { @@ -205,14 +205,14 @@ func GetPrecision(currencies map[string]int, cur string) (int, error) { def, ok := currencies[asset] if !ok { - return 0, errors.New("missing currencies") + return 0, ErrMissingCurrencies } return def, nil } -func GetCurrencyAndPrecisionFromAsset(currencies map[string]int, asset models.Asset) (string, int, error) { - parts := strings.Split(asset.String(), "/") +func GetCurrencyAndPrecisionFromAsset(currencies map[string]int, asset string) (string, int, error) { + parts := strings.Split(asset, "/") if len(parts) != 2 { return "", 0, errors.New("invalid asset") } diff --git a/internal/connectors/plugins/errors.go b/internal/connectors/plugins/errors.go new file mode 100644 index 00000000..4a44a3d7 --- /dev/null +++ b/internal/connectors/plugins/errors.go @@ -0,0 +1,12 @@ +package plugins + +import ( + "errors" +) + +var ( + ErrNotImplemented = errors.New("not implemented") + ErrNotYetInstalled = errors.New("not yet installed") + ErrInvalidClientRequest = errors.New("invalid client request") + ErrCurrencyNotSupported = errors.New("currency not supported") +) diff --git a/internal/connectors/plugins/public/adyen/accounts.go b/internal/connectors/plugins/public/adyen/accounts.go new file mode 100644 index 00000000..5ad0b9ef --- /dev/null +++ b/internal/connectors/plugins/public/adyen/accounts.go @@ -0,0 +1,121 @@ +package adyen + +import ( + "context" + "encoding/json" + "time" + + "github.com/adyen/adyen-go-api-library/v7/src/management" + "github.com/formancehq/payments/internal/models" +) + +type accountsState struct { + LastPage int `json:"lastPage"` + + // Adyen API sort the accounts by ID which is the same as the name + // and we cannot sort by other things. It means that when we fetched + // everything, we will need to return an empty state in order to + // refetch everything at the next polling iteration... + // It should not change anything in the database, but it will generate + // duplicates in events, but with the same IdempotencyKey. + LastID string `json:"lastId"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + if oldState.LastPage == 0 { + oldState.LastPage = 1 + } + + newState := accountsState{ + LastPage: oldState.LastPage, + LastID: oldState.LastID, + } + + var accounts []models.PSPAccount + hasMore := false + page := oldState.LastPage + for { + pagedAccount, err := p.client.GetMerchantAccounts(ctx, int32(page), int32(req.PageSize)) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + if len(pagedAccount) == 0 { + hasMore = false + break + } + + accounts, err = fillAccounts(accounts, pagedAccount, oldState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore := true + needMore, hasMore, accounts = shouldFetchMore(accounts, pagedAccount, req.PageSize) + if !needMore || !hasMore { + break + } + + page++ + } + + newState.LastPage = page + if len(accounts) > 0 { + newState.LastID = accounts[len(accounts)-1].Reference + } + + if !hasMore { + // Since the merchant accounts sorting is done by ID, if a new one is + // created with and ID lower than the last one we fetched, we will not + // fetch it. So we need to reset the state to fetch everything again + // when we have fetched eveything. + // It will not create duplicates inside the database since we're based + // on the ID of the account, but it will create duplicates in the events + // but with the same IdempotencyKey, so should be fine. + newState = accountsState{} + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillAccounts( + accounts []models.PSPAccount, + pagedAccount []management.Merchant, + oldState accountsState, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccount { + if *account.Id <= oldState.LastID { + continue + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: *account.Id, + CreatedAt: time.Now().UTC(), + Name: account.Name, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/adyen/accounts_test.go b/internal/connectors/plugins/public/adyen/accounts_test.go new file mode 100644 index 00000000..035a1750 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/accounts_test.go @@ -0,0 +1,152 @@ +package adyen + +import ( + "encoding/json" + "fmt" + + "github.com/adyen/adyen-go-api-library/v7/src/management" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/adyen/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Adyen Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []management.Merchant + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + for i := 10; i < 60; i++ { + sampleAccounts = append(sampleAccounts, management.Merchant{ + Id: pointer.For(fmt.Sprintf("%d", i)), + Name: pointer.For(fmt.Sprintf("name-%d", i)), + }) + } + }) + + AfterEach(func() { + sampleAccounts = nil + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetMerchantAccounts(gomock.Any(), int32(1), int32(60)).Return( + []management.Merchant{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastID).To(BeEmpty()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetMerchantAccounts(gomock.Any(), int32(1), int32(60)).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastID).To(BeEmpty()) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetMerchantAccounts(gomock.Any(), int32(1), int32(40)).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastID).To(Equal(*sampleAccounts[39].Id)) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": %d, "lastId": "%s"}`, 1, *sampleAccounts[38].Id)), + PageSize: 40, + } + + m.EXPECT().GetMerchantAccounts(gomock.Any(), int32(1), int32(40)).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().GetMerchantAccounts(gomock.Any(), int32(2), int32(40)).Return( + sampleAccounts[41:], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastID).To(BeEmpty()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/adyen/capabilities.go b/internal/connectors/plugins/public/adyen/capabilities.go new file mode 100644 index 00000000..180623aa --- /dev/null +++ b/internal/connectors/plugins/public/adyen/capabilities.go @@ -0,0 +1,9 @@ +package adyen + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_CREATE_WEBHOOKS, + models.CAPABILITY_TRANSLATE_WEBHOOKS, +} diff --git a/internal/connectors/plugins/public/adyen/client/accounts.go b/internal/connectors/plugins/public/adyen/client/accounts.go new file mode 100644 index 00000000..cdaeebeb --- /dev/null +++ b/internal/connectors/plugins/public/adyen/client/accounts.go @@ -0,0 +1,23 @@ +package client + +import ( + "context" + "time" + + "github.com/adyen/adyen-go-api-library/v7/src/management" +) + +func (c *client) GetMerchantAccounts(ctx context.Context, pageNumber, pageSize int32) ([]management.Merchant, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_merchant_accounts") + + listMerchantsResponse, raw, err := c.client.Management().AccountMerchantLevelApi.ListMerchantAccounts( + ctx, + c.client.Management().AccountMerchantLevelApi.ListMerchantAccountsInput().PageNumber(pageNumber).PageSize(pageSize), + ) + err = c.wrapSDKError(err, raw.StatusCode) + if err != nil { + return nil, err + } + return listMerchantsResponse.Data, nil +} diff --git a/internal/connectors/plugins/public/adyen/client/client.go b/internal/connectors/plugins/public/adyen/client/client.go new file mode 100644 index 00000000..2f3ffd5c --- /dev/null +++ b/internal/connectors/plugins/public/adyen/client/client.go @@ -0,0 +1,99 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/adyen/adyen-go-api-library/v7/src/adyen" + "github.com/adyen/adyen-go-api-library/v7/src/common" + "github.com/adyen/adyen-go-api-library/v7/src/management" + "github.com/adyen/adyen-go-api-library/v7/src/webhook" + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/connectors/metrics" + "github.com/formancehq/payments/internal/models" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetMerchantAccounts(ctx context.Context, pageNumber, pageSize int32) ([]management.Merchant, error) + CreateWebhook(ctx context.Context, url string, connectorID string) error + VerifyWebhookBasicAuth(basicAuth *models.BasicAuth) bool + VerifyWebhookHMAC(item webhook.NotificationItem) bool + DeleteWebhook(ctx context.Context, connectorID string) error + TranslateWebhook(req string) (*webhook.Webhook, error) +} + +type client struct { + client *adyen.APIClient + commonMetricAttributes []attribute.KeyValue + + webhookUsername string + webhookPassword string + + companyID string + + standardWebhook *management.Webhook + hmacKey string +} + +func New(apiKey, username, password, companyID string, liveEndpointPrefix string) Client { + adyenConfig := &common.Config{ + ApiKey: apiKey, + Environment: common.TestEnv, + Debug: true, + } + + if liveEndpointPrefix != "" { + adyenConfig.Environment = common.LiveEnv + adyenConfig.LiveEndpointURLPrefix = liveEndpointPrefix + adyenConfig.Debug = false + } + + c := adyen.NewClient(adyenConfig) + + return &client{ + client: c, + commonMetricAttributes: CommonMetricsAttributes(), + webhookUsername: username, + webhookPassword: password, + companyID: companyID, + } +} + +// wrap a public error for cases that we don't want to retry +// so that activities can classify this error for temporal +func (c *client) wrapSDKError(err error, statusCode int) error { + if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError { + return fmt.Errorf("%w: %w", httpwrapper.ErrStatusCodeClientError, err) + } + + // Adyen SDK doesn't appear to catch anything above 500 + // let's return an error here too even if it was nil + if statusCode >= http.StatusInternalServerError { + return fmt.Errorf("unexpected status code %d: %w", statusCode, err) + } + return err +} + +// recordMetrics is meant to be called in a defer +func (c *client) recordMetrics(ctx context.Context, start time.Time, operation string) { + registry := metrics.GetMetricsRegistry() + + attrs := c.commonMetricAttributes + attrs = append(attrs, attribute.String("operation", operation)) + opts := metric.WithAttributes(attrs...) + + registry.ConnectorPSPCalls().Add(ctx, 1, opts) + registry.ConnectorPSPCallLatencies().Record(ctx, time.Since(start).Milliseconds(), opts) +} + +func CommonMetricsAttributes() []attribute.KeyValue { + metricsAttributes := []attribute.KeyValue{ + attribute.String("connector", "adyen"), + } + return metricsAttributes +} diff --git a/internal/connectors/plugins/public/adyen/client/client_generated.go b/internal/connectors/plugins/public/adyen/client/client_generated.go new file mode 100644 index 00000000..64b65630 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/client/client_generated.go @@ -0,0 +1,130 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + management "github.com/adyen/adyen-go-api-library/v7/src/management" + webhook "github.com/adyen/adyen-go-api-library/v7/src/webhook" + models "github.com/formancehq/payments/internal/models" + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreateWebhook mocks base method. +func (m *MockClient) CreateWebhook(ctx context.Context, url, connectorID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateWebhook", ctx, url, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateWebhook indicates an expected call of CreateWebhook. +func (mr *MockClientMockRecorder) CreateWebhook(ctx, url, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWebhook", reflect.TypeOf((*MockClient)(nil).CreateWebhook), ctx, url, connectorID) +} + +// DeleteWebhook mocks base method. +func (m *MockClient) DeleteWebhook(ctx context.Context, connectorID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWebhook", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWebhook indicates an expected call of DeleteWebhook. +func (mr *MockClientMockRecorder) DeleteWebhook(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebhook", reflect.TypeOf((*MockClient)(nil).DeleteWebhook), ctx, connectorID) +} + +// GetMerchantAccounts mocks base method. +func (m *MockClient) GetMerchantAccounts(ctx context.Context, pageNumber, pageSize int32) ([]management.Merchant, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMerchantAccounts", ctx, pageNumber, pageSize) + ret0, _ := ret[0].([]management.Merchant) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMerchantAccounts indicates an expected call of GetMerchantAccounts. +func (mr *MockClientMockRecorder) GetMerchantAccounts(ctx, pageNumber, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMerchantAccounts", reflect.TypeOf((*MockClient)(nil).GetMerchantAccounts), ctx, pageNumber, pageSize) +} + +// TranslateWebhook mocks base method. +func (m *MockClient) TranslateWebhook(req string) (*webhook.Webhook, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TranslateWebhook", req) + ret0, _ := ret[0].(*webhook.Webhook) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TranslateWebhook indicates an expected call of TranslateWebhook. +func (mr *MockClientMockRecorder) TranslateWebhook(req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TranslateWebhook", reflect.TypeOf((*MockClient)(nil).TranslateWebhook), req) +} + +// VerifyWebhookBasicAuth mocks base method. +func (m *MockClient) VerifyWebhookBasicAuth(basicAuth *models.BasicAuth) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyWebhookBasicAuth", basicAuth) + ret0, _ := ret[0].(bool) + return ret0 +} + +// VerifyWebhookBasicAuth indicates an expected call of VerifyWebhookBasicAuth. +func (mr *MockClientMockRecorder) VerifyWebhookBasicAuth(basicAuth any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyWebhookBasicAuth", reflect.TypeOf((*MockClient)(nil).VerifyWebhookBasicAuth), basicAuth) +} + +// VerifyWebhookHMAC mocks base method. +func (m *MockClient) VerifyWebhookHMAC(item webhook.NotificationItem) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyWebhookHMAC", item) + ret0, _ := ret[0].(bool) + return ret0 +} + +// VerifyWebhookHMAC indicates an expected call of VerifyWebhookHMAC. +func (mr *MockClientMockRecorder) VerifyWebhookHMAC(item any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyWebhookHMAC", reflect.TypeOf((*MockClient)(nil).VerifyWebhookHMAC), item) +} diff --git a/internal/connectors/plugins/public/adyen/client/webhooks.go b/internal/connectors/plugins/public/adyen/client/webhooks.go new file mode 100644 index 00000000..8b5d583e --- /dev/null +++ b/internal/connectors/plugins/public/adyen/client/webhooks.go @@ -0,0 +1,163 @@ +package client + +import ( + "context" + "time" + + "github.com/adyen/adyen-go-api-library/v7/src/hmacvalidator" + "github.com/adyen/adyen-go-api-library/v7/src/management" + "github.com/adyen/adyen-go-api-library/v7/src/webhook" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/models" +) + +func (c *client) searchWebhook(ctx context.Context, connectorID string) error { + pageSize := 50 + for page := 1; ; page++ { + start := time.Now() + webhooks, raw, err := c.client.Management().WebhooksCompanyLevelApi.ListAllWebhooks( + ctx, + c.client.Management().WebhooksCompanyLevelApi.ListAllWebhooksInput(c.companyID).PageNumber(int32(page)).PageSize(int32(pageSize)), + ) + c.recordMetrics(ctx, start, "list_hooks") + err = c.wrapSDKError(err, raw.StatusCode) + if err != nil { + return err + } + + if len(webhooks.Data) == 0 { + break + } + + for _, webhook := range webhooks.Data { + if webhook.Description == nil { + continue + } + + if *webhook.Description != connectorID { + continue + } + + if webhook.Type != "standard" { + continue + } + + c.standardWebhook = &webhook + break + } + + if len(webhooks.Data) < pageSize { + break + } + } + + return nil +} + +func (c *client) CreateWebhook(ctx context.Context, url string, connectorID string) error { + if c.standardWebhook != nil { + return nil + } + + if err := c.searchWebhook(ctx, connectorID); err != nil { + return err + } + + if c.standardWebhook != nil { + return nil + } + + req := management.CreateCompanyWebhookRequest{ + Active: true, + CommunicationFormat: "json", + FilterMerchantAccountType: "allAccounts", + Description: pointer.For(connectorID), + SslVersion: pointer.For("TLSv1.3"), + Type: "standard", + Url: url, + } + + if c.webhookUsername != "" { + req.Username = pointer.For(c.webhookUsername) + } + + if c.webhookPassword != "" { + req.Password = pointer.For(c.webhookPassword) + } + + start := time.Now() + webhook, raw, err := c.client.Management().WebhooksCompanyLevelApi.SetUpWebhook( + ctx, + c.client.Management().WebhooksCompanyLevelApi.SetUpWebhookInput(c.companyID). + CreateCompanyWebhookRequest(req), + ) + c.recordMetrics(ctx, start, "create_hook") + err = c.wrapSDKError(err, raw.StatusCode) + if err != nil { + return err + } + + start = time.Now() + hmac, raw, err := c.client.Management().WebhooksCompanyLevelApi.GenerateHmacKey( + ctx, + c.client.Management().WebhooksCompanyLevelApi.GenerateHmacKeyInput(c.companyID, *webhook.Id), + ) + c.recordMetrics(ctx, start, "create_hook_hmac_key") + err = c.wrapSDKError(err, raw.StatusCode) + if err != nil { + return err + } + + c.standardWebhook = &webhook + c.hmacKey = hmac.HmacKey + + return nil +} + +func (c *client) VerifyWebhookBasicAuth(basicAuth *models.BasicAuth) bool { + switch { + case c.webhookUsername != "" && c.webhookPassword != "" && basicAuth == nil: + return false + case c.webhookUsername == "" && c.webhookPassword == "" && basicAuth == nil: + return true + case c.webhookUsername != "" && c.webhookPassword != "" && basicAuth != nil: + return c.webhookUsername == basicAuth.Username && c.webhookPassword == basicAuth.Password + } + + return false +} + +func (c *client) VerifyWebhookHMAC(item webhook.NotificationItem) bool { + return hmacvalidator.ValidateHmac(item.NotificationRequestItem, c.hmacKey) +} + +func (c *client) DeleteWebhook(ctx context.Context, connectorID string) error { + if c.standardWebhook == nil { + if err := c.searchWebhook(ctx, connectorID); err != nil { + return err + } + + if c.standardWebhook == nil { + return nil + } + } + + start := time.Now() + raw, err := c.client.Management().WebhooksCompanyLevelApi.RemoveWebhook( + ctx, + c.client.Management().WebhooksCompanyLevelApi.RemoveWebhookInput(c.companyID, *c.standardWebhook.Id), + ) + c.recordMetrics(ctx, start, "delete_hook") + err = c.wrapSDKError(err, raw.StatusCode) + if err != nil { + return err + } + + c.standardWebhook = nil + + return nil +} + +func (c *client) TranslateWebhook(req string) (*webhook.Webhook, error) { + return webhook.HandleRequest(req) +} diff --git a/internal/connectors/plugins/public/adyen/config.go b/internal/connectors/plugins/public/adyen/config.go new file mode 100644 index 00000000..00746481 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/config.go @@ -0,0 +1,37 @@ +package adyen + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + APIKey string `json:"apiKey"` + WebhookUsername string `json:"webhookUsername"` + WebhookPassword string `json:"webhookPassword"` + CompanyID string `json:"companyID"` + LiveEndpointPrefix string `json:"liveEndpointPrefix"` +} + +func (c Config) validate() error { + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing apiKey in config") + } + + if c.CompanyID == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing companyID in config") + } + + return nil +} + +func unmarshalAndValidateConfig(payload []byte) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/adyen/config.json b/internal/connectors/plugins/public/adyen/config.json new file mode 100644 index 00000000..7f5a682d --- /dev/null +++ b/internal/connectors/plugins/public/adyen/config.json @@ -0,0 +1,27 @@ +{ + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "webhookUsername": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "webhookPassword": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "companyID": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "liveEndpointPrefix": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} diff --git a/internal/connectors/plugins/public/adyen/currencies.go b/internal/connectors/plugins/public/adyen/currencies.go new file mode 100644 index 00000000..9217fe8b --- /dev/null +++ b/internal/connectors/plugins/public/adyen/currencies.go @@ -0,0 +1,7 @@ +package adyen + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + supportedCurrenciesWithDecimal = currency.ISO4217Currencies +) diff --git a/internal/connectors/plugins/public/adyen/plugin.go b/internal/connectors/plugins/public/adyen/plugin.go new file mode 100644 index 00000000..57e81134 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/plugin.go @@ -0,0 +1,154 @@ +package adyen + +import ( + "context" + "encoding/json" + "errors" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/adyen/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("adyen", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client + webhookConfigs map[string]webhookConfig + + connectorID string +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New( + config.APIKey, + config.WebhookUsername, + config.WebhookPassword, + config.CompanyID, + config.LiveEndpointPrefix, + ) + + p := &Plugin{ + name: name, + client: client, + } + + p.initWebhookConfig() + + return p, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + configs := []models.PSPWebhookConfig{} + for name, c := range p.webhookConfigs { + configs = append(configs, models.PSPWebhookConfig{ + Name: name, + URLPath: c.urlPath, + }) + } + + return models.InstallResponse{ + Workflow: workflow(), + WebhooksConfigs: configs, + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + if p.client == nil { + return models.UninstallResponse{}, plugins.ErrNotYetInstalled + } + + err := p.client.DeleteWebhook(ctx, req.ConnectorID) + return models.UninstallResponse{}, err +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + return models.FetchNextBalancesResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + return models.CreateTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + return models.CreatePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + if p.client == nil { + return models.CreateWebhooksResponse{}, plugins.ErrNotYetInstalled + } + p.connectorID = req.ConnectorID + err := p.createWebhooks(ctx, req) + return models.CreateWebhooksResponse{}, err +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + if p.client == nil { + return models.TranslateWebhookResponse{}, plugins.ErrNotYetInstalled + } + + config, ok := p.webhookConfigs[req.Name] + if !ok { + return models.TranslateWebhookResponse{}, errors.New("unknown webhook") + } + + return config.fn(ctx, req) +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/adyen/plugin_test.go b/internal/connectors/plugins/public/adyen/plugin_test.go new file mode 100644 index 00000000..010bf739 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/plugin_test.go @@ -0,0 +1,181 @@ +package adyen + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/adyen/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Adyen Plugin Suite") +} + +var _ = Describe("Adyen Plugin", func() { + var ( + plg *Plugin + m *client.MockClient + ) + + BeforeEach(func() { + plg = &Plugin{} + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + }) + + Context("install", func() { + It("reports validation errors in the config - apiKey", func(ctx SpecContext) { + _, err := New("adyen", json.RawMessage(`{"companyID": "test"}`)) + Expect(err).To(MatchError("missing apiKey in config: invalid config")) + }) + It("reports validation errors in the config - companyID", func(ctx SpecContext) { + _, err := New("adyen", json.RawMessage(`{"apiKey": "test"}`)) + Expect(err).To(MatchError("missing companyID in config: invalid config")) + }) + It("returns valid install response", func(ctx SpecContext) { + _, err := New("adyen", json.RawMessage(`{"apiKey":"test", "companyID": "test"}`)) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return a valid uninstall response when client is set", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + + m.EXPECT().DeleteWebhook(gomock.Any(), req.ConnectorID).Return(nil) + + plg := &Plugin{client: m} + _, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + }) + It("should fail if client is not set", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + + _, err := plg.Uninstall(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled.Error())) + }) + }) + + Context("fetch next accounts", func() { + It("should fail if client is not set", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) + + Context("fetch next balances", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("fetch next external accounts", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("fetch next payments", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("create bank account", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("create transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("create payout", func() { + It("should fail if client is not set", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented.Error())) + }) + }) + + Context("create webhooks", func() { + It("should fail because not yet installed", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled.Error())) + }) + }) + + Context("translate webhook", func() { + It("should fail because not yet installed", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled.Error())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/adyen/utils.go b/internal/connectors/plugins/public/adyen/utils.go new file mode 100644 index 00000000..f9981a71 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/utils.go @@ -0,0 +1,12 @@ +package adyen + +func shouldFetchMore[T any, V any](ret []T, pagedT []V, pageSize int) (bool, bool, []T) { + switch { + case len(pagedT) < pageSize: + return false, false, ret + case len(ret) >= pageSize: + return false, true, ret[:pageSize] + default: + return true, true, ret + } +} diff --git a/internal/connectors/plugins/public/adyen/webhooks.go b/internal/connectors/plugins/public/adyen/webhooks.go new file mode 100644 index 00000000..ebfcdb87 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/webhooks.go @@ -0,0 +1,464 @@ +package adyen + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/url" + "strings" + + "github.com/adyen/adyen-go-api-library/v7/src/webhook" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +type webhookConfig struct { + urlPath string + fn func(context.Context, models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) +} + +func (p *Plugin) initWebhookConfig() { + p.webhookConfigs = map[string]webhookConfig{ + "standard": { + urlPath: "/standard", + fn: p.translateStandardWebhook, + }, + } +} + +func (p *Plugin) createWebhooks(ctx context.Context, req models.CreateWebhooksRequest) error { + if req.WebhookBaseUrl == "" { + return errors.New("STACK_PUBLIC_URL is not set") + } + + standardConfig := p.webhookConfigs["standard"] + + url, err := url.JoinPath(req.WebhookBaseUrl, standardConfig.urlPath) + if err != nil { + return err + } + + return p.client.CreateWebhook(ctx, url, req.ConnectorID) +} + +func (p *Plugin) translateStandardWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + if !p.client.VerifyWebhookBasicAuth(req.Webhook.BasicAuth) { + return models.TranslateWebhookResponse{}, errors.New("invalid basic auth") + } + + webhooks, err := p.client.TranslateWebhook(string(req.Webhook.Body)) + if err != nil { + return models.TranslateWebhookResponse{}, err + } + + responses := make([]models.WebhookResponse, 0, len(*webhooks.NotificationItems)) + for _, item := range *webhooks.NotificationItems { + if !p.client.VerifyWebhookHMAC(item) { + continue + } + + var payment *models.PSPPayment + var err error + switch item.NotificationRequestItem.EventCode { + case webhook.EventCodeAuthorisation: + payment, err = p.handleAuthorisation(item.NotificationRequestItem) + case webhook.EventCodeAuthorisationAdjustment: + payment, err = p.handleAuthorisationAdjustment(item.NotificationRequestItem) + case webhook.EventCodeCancellation: + payment, err = p.handleCancellation(item.NotificationRequestItem) + case webhook.EventCodeCapture: + payment, err = p.handleCapture(item.NotificationRequestItem) + case webhook.EventCodeCaptureFailed: + payment, err = p.handleCaptureFailed(item.NotificationRequestItem) + case webhook.EventCodeRefund: + payment, err = p.handleRefund(item.NotificationRequestItem) + case webhook.EventCodeRefundFailed: + payment, err = p.handleRefundFailed(item.NotificationRequestItem) + case webhook.EventCodeRefundedReversed: + payment, err = p.handleRefundedReversed(item.NotificationRequestItem) + case webhook.EventCodeRefundWithData: + payment, err = p.handleRefundWithData(item.NotificationRequestItem) + case webhook.EventCodePayoutThirdparty: + payment, err = p.handlePayoutThirdparty(item.NotificationRequestItem) + case webhook.EventCodePayoutDecline: + payment, err = p.handlePayoutDecline(item.NotificationRequestItem) + case webhook.EventCodePayoutExpire: + payment, err = p.handlePayoutExpire(item.NotificationRequestItem) + } + if err != nil { + return models.TranslateWebhookResponse{}, err + } + + if payment != nil { + responses = append(responses, models.WebhookResponse{ + IdempotencyKey: fmt.Sprintf("%s-%s-%d", item.NotificationRequestItem.MerchantReference, item.NotificationRequestItem.EventCode, item.NotificationRequestItem.EventDate.UnixNano()), + Payment: payment, + }) + } + } + + return models.TranslateWebhookResponse{ + Responses: responses, + }, nil +} + +func (p *Plugin) handleAuthorisation( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + status := models.PAYMENT_STATUS_AUTHORISATION + if item.Success == "false" { + status = models.PAYMENT_STATUS_FAILED + } + + payment := models.PSPPayment{ + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: parseScheme(item.PaymentMethod), + Status: status, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handleAuthorisationAdjustment( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + return &payment, nil +} + +func (p *Plugin) handleCancellation( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: parseScheme(item.PaymentMethod), + Status: models.PAYMENT_STATUS_CANCELLED, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handleCapture( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_CAPTURE, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + return &payment, nil +} + +func (p *Plugin) handleCaptureFailed( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_CAPTURE_FAILED, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handleRefund( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handleRefundFailed( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED_FAILURE, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + return &payment, nil +} + +func (p *Plugin) handleRefundedReversed( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUND_REVERSED, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handleRefundWithData( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("failed to marshal item: %w", err) + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + DestinationAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handlePayoutThirdparty( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(item) + if err != nil { + return nil, err + } + + status := models.PAYMENT_STATUS_SUCCEEDED + if item.Success == "false" { + status = models.PAYMENT_STATUS_FAILED + } + + payment := models.PSPPayment{ + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + SourceAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handlePayoutDecline( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, err + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_FAILED, + SourceAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func (p *Plugin) handlePayoutExpire( + item webhook.NotificationRequestItem, +) (*models.PSPPayment, error) { + if item.Success == "false" { + return nil, nil + } + + raw, err := json.Marshal(item) + if err != nil { + return nil, err + } + + payment := models.PSPPayment{ + ParentReference: item.OriginalReference, + Reference: item.PspReference, + CreatedAt: *item.EventDate, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(item.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, item.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_EXPIRED, + SourceAccountReference: pointer.For(item.MerchantAccountCode), + Raw: raw, + } + + return &payment, nil +} + +func parseScheme(scheme string) models.PaymentScheme { + switch { + case strings.HasPrefix(scheme, "visa"): + return models.PAYMENT_SCHEME_CARD_VISA + case strings.HasPrefix(scheme, "electron"): + return models.PAYMENT_SCHEME_CARD_VISA + case strings.HasPrefix(scheme, "amex"): + return models.PAYMENT_SCHEME_CARD_AMEX + case strings.HasPrefix(scheme, "alipay"): + return models.PAYMENT_SCHEME_CARD_ALIPAY + case strings.HasPrefix(scheme, "cup"): + return models.PAYMENT_SCHEME_CARD_CUP + case strings.HasPrefix(scheme, "discover"): + return models.PAYMENT_SCHEME_CARD_DISCOVER + case strings.HasPrefix(scheme, "doku"): + return models.PAYMENT_SCHEME_DOKU + case strings.HasPrefix(scheme, "dragonpay"): + return models.PAYMENT_SCHEME_DRAGON_PAY + case strings.HasPrefix(scheme, "jcb"): + return models.PAYMENT_SCHEME_CARD_JCB + case strings.HasPrefix(scheme, "maestro"): + return models.PAYMENT_SCHEME_MAESTRO + case strings.HasPrefix(scheme, "mc"): + return models.PAYMENT_SCHEME_CARD_MASTERCARD + case strings.HasPrefix(scheme, "molpay"): + return models.PAYMENT_SCHEME_MOL_PAY + case strings.HasPrefix(scheme, "diners"): + return models.PAYMENT_SCHEME_CARD_DINERS + default: + return models.PAYMENT_SCHEME_OTHER + } +} diff --git a/internal/connectors/plugins/public/adyen/webhooks_test.go b/internal/connectors/plugins/public/adyen/webhooks_test.go new file mode 100644 index 00000000..df7b43ae --- /dev/null +++ b/internal/connectors/plugins/public/adyen/webhooks_test.go @@ -0,0 +1,507 @@ +package adyen + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "time" + + "github.com/adyen/adyen-go-api-library/v7/src/webhook" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/adyen/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Adyen Plugin Accounts", func() { + var ( + plg *Plugin + m *client.MockClient + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + plg.initWebhookConfig() + now = time.Now().UTC() + }) + + Context("creating webhooks", func() { + It("should fail - stack public url not set", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + } + + err := plg.createWebhooks(ctx, req) + Expect(err).To(MatchError("STACK_PUBLIC_URL is not set")) + }) + + It("should fail - wrong url format", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + WebhookBaseUrl: "&grjete%", + } + + err := plg.createWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + }) + + It("should work perfectly", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + WebhookBaseUrl: "http://localhost:8080/test", + } + + expectedURL := "http://localhost:8080/test/standard" + m.EXPECT().CreateWebhook(gomock.Any(), expectedURL, req.ConnectorID).Return(nil) + + err := plg.createWebhooks(ctx, req) + Expect(err).To(BeNil()) + }) + }) + + Context("translating webhooks", func() { + It("should fail - wrong basic auth", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "standard", + Webhook: models.PSPWebhook{ + BasicAuth: &models.BasicAuth{ + Username: "test", + Password: "test", + }, + QueryValues: map[string][]string{}, + Headers: map[string][]string{}, + Body: []byte{}, + }, + } + + m.EXPECT().VerifyWebhookBasicAuth(req.Webhook.BasicAuth).Return( + false, + ) + + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("invalid basic auth")) + }) + + It("should not process - invalid hmac", func(ctx SpecContext) { + w := webhook.Webhook{ + Live: "false", + NotificationItems: &[]webhook.NotificationItem{ + { + NotificationRequestItem: webhook.NotificationRequestItem{ + PspReference: "test", + Amount: webhook.Amount{ + Currency: "EUR", + Value: 100, + }, + EventCode: webhook.EventCodeAuthorisation, + EventDate: &now, + MerchantAccountCode: "test", + Operations: []string{}, + PaymentMethod: "visa", + Success: "true", + }, + }, + }, + } + + b, _ := json.Marshal(&w) + + req := models.TranslateWebhookRequest{ + Name: "standard", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{}, + Headers: map[string][]string{}, + Body: b, + }, + } + + m.EXPECT().VerifyWebhookBasicAuth(req.Webhook.BasicAuth).Return(true) + m.EXPECT().TranslateWebhook(string(req.Webhook.Body)).Return(&w, nil) + m.EXPECT().VerifyWebhookHMAC(gomock.Any()).Return(false) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Responses).To(BeEmpty()) + }) + + It("should handle authorization", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_CARD_VISA, + Status: models.PAYMENT_STATUS_AUTHORISATION, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeAuthorisation, + 100, + now, + expectedPSPPayment, + ) + }) + }) + + It("should handle authorisation adjustments", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(150), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeAuthorisationAdjustment, + 150, + now, + expectedPSPPayment, + ) + }) + + It("should handle cancellation", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_CARD_VISA, + Status: models.PAYMENT_STATUS_CANCELLED, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeCancellation, + 100, + now, + expectedPSPPayment, + ) + }) + + It("should handle capture", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(50), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_CAPTURE, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeCapture, + 50, + now, + expectedPSPPayment, + ) + }) + + It("should handle capture failed", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(50), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_CAPTURE_FAILED, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeCaptureFailed, + 50, + now, + expectedPSPPayment, + ) + }) + + It("should handle refund", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(50), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeRefund, + 50, + now, + expectedPSPPayment, + ) + }) + + It("should handle refund failed", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(50), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED_FAILURE, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeRefundFailed, + 50, + now, + expectedPSPPayment, + ) + }) + + It("should handle refund reversed", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUND_REVERSED, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeRefundedReversed, + 100, + now, + expectedPSPPayment, + ) + }) + + It("should handle refund with data", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + DestinationAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodeRefundWithData, + 100, + now, + expectedPSPPayment, + ) + }) + + It("should handle payouts to third party", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodePayoutThirdparty, + 100, + now, + expectedPSPPayment, + ) + }) + + It("should handle payouts declined", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_FAILED, + SourceAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodePayoutDecline, + 100, + now, + expectedPSPPayment, + ) + }) + + It("should handle payouts expired", func(ctx SpecContext) { + expectedPSPPayment := models.PSPPayment{ + ParentReference: "test1", + Reference: "test", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_EXPIRED, + SourceAccountReference: pointer.For("test"), + } + + doTranslateCall( + ctx, + plg, + m, + webhook.EventCodePayoutExpire, + 100, + now, + expectedPSPPayment, + ) + }) +}) + +func doTranslateCall( + ctx context.Context, + plg *Plugin, + m *client.MockClient, + eventCode string, + amount int64, + now time.Time, + expectedPSPPayment models.PSPPayment, +) { + w := webhook.Webhook{ + Live: "false", + NotificationItems: &[]webhook.NotificationItem{ + { + NotificationRequestItem: webhook.NotificationRequestItem{ + OriginalReference: "test1", + PspReference: "test", + Amount: webhook.Amount{ + Currency: "EUR", + Value: amount, + }, + EventCode: eventCode, + EventDate: &now, + MerchantReference: "test", + MerchantAccountCode: "test", + PaymentMethod: "visa", + Success: "true", + }, + }, + }, + } + + b, _ := json.Marshal(&w) + + req := models.TranslateWebhookRequest{ + Name: "standard", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{}, + Headers: map[string][]string{}, + Body: b, + }, + } + + m.EXPECT().VerifyWebhookBasicAuth(req.Webhook.BasicAuth).Return(true) + m.EXPECT().TranslateWebhook(string(req.Webhook.Body)).Return(&w, nil) + m.EXPECT().VerifyWebhookHMAC(gomock.Any()).Return(true) + + expectedIK := fmt.Sprintf("test-%s-%d", eventCode, now.UnixNano()) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(len(resp.Responses)).To(Equal(1)) + Expect(resp.Responses[0].IdempotencyKey).To(Equal(expectedIK)) + comparePayments(*resp.Responses[0].Payment, expectedPSPPayment) +} + +func comparePayments(a, b models.PSPPayment) { + Expect(a.ParentReference).To(Equal(b.ParentReference)) + Expect(a.Reference).To(Equal(b.Reference)) + Expect(a.CreatedAt).To(Equal(b.CreatedAt)) + Expect(a.Type).To(Equal(b.Type)) + Expect(a.Amount.String()).To(Equal(b.Amount.String())) + Expect(a.Asset).To(Equal(b.Asset)) + Expect(a.Scheme).To(Equal(b.Scheme)) + Expect(a.Status).To(Equal(b.Status)) + + switch { + case a.SourceAccountReference != nil && b.SourceAccountReference != nil: + Expect(*a.SourceAccountReference).To(Equal(*b.SourceAccountReference)) + case a.SourceAccountReference == nil && b.SourceAccountReference == nil: + default: + Fail("SourceAccountReference is not equal") + } + + switch { + case a.DestinationAccountReference != nil && b.DestinationAccountReference != nil: + Expect(*a.DestinationAccountReference).To(Equal(*b.DestinationAccountReference)) + case a.DestinationAccountReference == nil && b.DestinationAccountReference == nil: + default: + Fail("DestinationAccountReference is not equal") + } + + Expect(len(a.Metadata)).To(Equal(len(b.Metadata))) + for k, v := range a.Metadata { + Expect(b.Metadata[k]).To(Equal(v)) + } +} diff --git a/internal/connectors/plugins/public/adyen/workflow.go b/internal/connectors/plugins/public/adyen/workflow.go new file mode 100644 index 00000000..8bab31b6 --- /dev/null +++ b/internal/connectors/plugins/public/adyen/workflow.go @@ -0,0 +1,20 @@ +package adyen + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_CREATE_WEBHOOKS, + Name: "create_webhooks", + Periodically: false, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/atlar/accounts.go b/internal/connectors/plugins/public/atlar/accounts.go new file mode 100644 index 00000000..33cc436f --- /dev/null +++ b/internal/connectors/plugins/public/atlar/accounts.go @@ -0,0 +1,127 @@ +package atlar + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/go-libs/v2/metadata" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/accounts" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" +) + +type accountsState struct { + NextToken string `json:"nextToken"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + var accounts []models.PSPAccount + nextToken := oldState.NextToken + for { + resp, err := p.client.GetV1Accounts(ctx, nextToken, int64(req.PageSize)) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + accounts, err = p.fillAccounts(ctx, resp, accounts) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + nextToken = resp.Payload.NextToken + if resp.Payload.NextToken == "" || len(accounts) >= req.PageSize { + break + } + } + + // If token is empty, this is perfect as the next polling task will refetch + // everything ! And that's what we want since Atlar doesn't provide any + // filters or sorting options. + newState := accountsState{ + NextToken: nextToken, + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: nextToken != "", + }, nil +} + +func (p *Plugin) fillAccounts( + ctx context.Context, + pagedAccounts *accounts.GetV1AccountsOK, + accounts []models.PSPAccount, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts.Payload.Items { + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + createdAt, err := ParseAtlarTimestamp(account.Created) + if err != nil { + return nil, err + } + + thirdPartyResponse, err := p.client.GetV1BetaThirdPartiesID(ctx, account.ThirdPartyID) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: *account.ID, + CreatedAt: createdAt, + Name: &account.Name, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency)), + Metadata: extractAccountMetadata(account, thirdPartyResponse.Payload), + Raw: raw, + }) + } + + return accounts, nil +} + +func extractAccountMetadata(account *atlar_models.Account, bank *atlar_models.ThirdParty) metadata.Metadata { + result := metadata.Metadata{} + result = result.Merge(computeMetadataBool("fictive", account.Fictive)) + result = result.Merge(computeMetadata("bank/id", bank.ID)) + result = result.Merge(computeMetadata("bank/name", bank.Name)) + result = result.Merge(computeMetadata("bank/bic", account.Bank.Bic)) + result = result.Merge(identifiersToMetadata(account.Identifiers)) + result = result.Merge(computeMetadata("alias", account.Alias)) + result = result.Merge(computeMetadata("owner/name", account.Owner.Name)) + return result +} + +func identifiersToMetadata(identifiers []*atlar_models.AccountIdentifier) metadata.Metadata { + result := metadata.Metadata{} + for _, i := range identifiers { + result = result.Merge(computeMetadata( + fmt.Sprintf("identifier/%s/%s", *i.Market, *i.Type), + *i.Number, + )) + if *i.Type == "IBAN" { + result = result.Merge(computeMetadata( + fmt.Sprintf("identifier/%s", *i.Type), + *i.Number, + )) + } + } + return result +} diff --git a/internal/connectors/plugins/public/atlar/accounts_test.go b/internal/connectors/plugins/public/atlar/accounts_test.go new file mode 100644 index 00000000..873e864a --- /dev/null +++ b/internal/connectors/plugins/public/atlar/accounts_test.go @@ -0,0 +1,241 @@ +package atlar + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/accounts" + "github.com/get-momo/atlar-v1-go-client/client/third_parties" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Atlar Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []*atlar_models.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]*atlar_models.Account, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, &atlar_models.Account{ + ID: pointer.For(fmt.Sprintf("%d", i)), + Created: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format(time.RFC3339Nano), + Name: fmt.Sprintf("Account %d", i), + Currency: "EUR", + ThirdPartyID: fmt.Sprintf("t%d", i), + Fictive: false, + Alias: fmt.Sprintf("test-%d", i), + Owner: &atlar_models.PartyIdentification{ + Name: fmt.Sprintf("owner-%d", i), + }, + Bank: &atlar_models.BankSlim{ + Bic: "BIC", + }, + }) + } + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1Accounts(gomock.Any(), "", int64(60)).Return( + &accounts.GetV1AccountsOK{ + Payload: &accounts.GetV1AccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: sampleAccounts, + }, + }, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1Accounts(gomock.Any(), "", int64(60)).Return( + &accounts.GetV1AccountsOK{ + Payload: &accounts.GetV1AccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: []*atlar_models.Account{}, + }, + }, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1Accounts(gomock.Any(), "", int64(60)).Return( + &accounts.GetV1AccountsOK{ + Payload: &accounts.GetV1AccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: sampleAccounts, + }, + }, + nil, + ) + + for _, acc := range sampleAccounts { + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), acc.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 40, + } + + m.EXPECT().GetV1Accounts(gomock.Any(), "", int64(40)).Return( + &accounts.GetV1AccountsOK{ + Payload: &accounts.GetV1AccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "1234", + }, + Items: sampleAccounts[:40], + }, + }, + nil, + ) + + for _, acc := range sampleAccounts[:40] { + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), acc.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.NextToken).To(Equal("1234")) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{"nextToken": "1234"}`), + PageSize: 40, + } + + m.EXPECT().GetV1Accounts(gomock.Any(), "1234", int64(40)).Return( + &accounts.GetV1AccountsOK{ + Payload: &accounts.GetV1AccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: sampleAccounts[40:], + }, + }, + nil, + ) + + for _, acc := range sampleAccounts[40:] { + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), acc.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/balances.go b/internal/connectors/plugins/public/atlar/balances.go new file mode 100644 index 00000000..133071b6 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/balances.go @@ -0,0 +1,43 @@ +package atlar + +import ( + "context" + "encoding/json" + "math/big" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + account, err := p.client.GetV1AccountsID(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balance := account.Payload.Balance + balanceTimestamp, err := ParseAtlarTimestamp(balance.Timestamp) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + res := models.PSPBalance{ + AccountReference: from.Reference, + CreatedAt: balanceTimestamp, + Amount: big.NewInt(*balance.Amount.Value), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, *balance.Amount.Currency), + } + + return models.FetchNextBalancesResponse{ + Balances: []models.PSPBalance{res}, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/atlar/balances_test.go b/internal/connectors/plugins/public/atlar/balances_test.go new file mode 100644 index 00000000..d334ffc9 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/balances_test.go @@ -0,0 +1,105 @@ +package atlar + +import ( + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/accounts" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Atlar Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next balances", func() { + var ( + m *client.MockClient + sampleAccount atlar_models.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccount = atlar_models.Account{ + Balance: &atlar_models.Balance{ + AccountID: "", + Amount: &atlar_models.Amount{ + Currency: pointer.For("EUR"), + StringValue: pointer.For("100"), + Value: pointer.For(int64(100)), + }, + Timestamp: now.Format(time.RFC3339Nano), + }, + } + _ = sampleAccount + }) + + It("should return an error - missing payload", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should return an error - get account error", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetV1AccountsID(gomock.Any(), "test").Return( + nil, + errors.New("test error"), + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should fetch all balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetV1AccountsID(gomock.Any(), "test").Return( + &accounts.GetV1AccountsIDOK{ + Payload: &sampleAccount, + }, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(1)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + Expect(resp.Balances[0].Amount).To(Equal(big.NewInt(100))) + Expect(resp.Balances[0].Asset).To(Equal("EUR/2")) + Expect(resp.Balances[0].AccountReference).To(Equal("test")) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/bank_account_creation.go b/internal/connectors/plugins/public/atlar/bank_account_creation.go new file mode 100644 index 00000000..41988469 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/bank_account_creation.go @@ -0,0 +1,52 @@ +package atlar + +import ( + "context" + "fmt" + + "github.com/formancehq/payments/internal/models" +) + +func validateExternalBankAccount(newExternalBankAccount models.BankAccount) error { + _, err := extractNamespacedMetadata(newExternalBankAccount.Metadata, "owner/name") + if err != nil { + return fmt.Errorf("required metadata field %sowner/name is missing", atlarMetadataSpecNamespace) + } + + ownerType, err := extractNamespacedMetadata(newExternalBankAccount.Metadata, "owner/type") + if err != nil { + return fmt.Errorf("required metadata field %sowner/type is missing", atlarMetadataSpecNamespace) + } + + if ownerType != "INDIVIDUAL" && ownerType != "COMPANY" { + return fmt.Errorf("metadata field %sowner/type needs to be one of [ INDIVIDUAL COMPANY ]", atlarMetadataSpecNamespace) + } + + return nil +} + +func (p *Plugin) createBankAccount(ctx context.Context, ba models.BankAccount) (models.CreateBankAccountResponse, error) { + err := validateExternalBankAccount(ba) + if err != nil { + return models.CreateBankAccountResponse{}, err + } + + resp, err := p.client.PostV1CounterParties(ctx, ba) + if err != nil { + return models.CreateBankAccountResponse{}, err + } + + if resp == nil { + return models.CreateBankAccountResponse{}, fmt.Errorf("unexpected empty response: %w", models.ErrFailedAccountCreation) + } + + newAccount, err := externalAccountFromAtlarData(resp.Payload.ExternalAccounts[0], resp.Payload) + if err != nil { + return models.CreateBankAccountResponse{}, err + } + + return models.CreateBankAccountResponse{ + RelatedAccount: newAccount, + }, nil + +} diff --git a/internal/connectors/plugins/public/atlar/bank_account_creation_test.go b/internal/connectors/plugins/public/atlar/bank_account_creation_test.go new file mode 100644 index 00000000..4a2e965f --- /dev/null +++ b/internal/connectors/plugins/public/atlar/bank_account_creation_test.go @@ -0,0 +1,181 @@ +package atlar + +import ( + "errors" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/counterparties" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Atlar Plugin Bank Account Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create bank account", func() { + var ( + m *client.MockClient + sampleBankAccount models.BankAccount + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBankAccount = models.BankAccount{ + ID: uuid.New(), + CreatedAt: now.UTC(), + Name: "test", + AccountNumber: pointer.For("12345678"), + IBAN: pointer.For("FR9412739000405993414979X56"), + SwiftBicCode: pointer.For("ERAHJP6BT1H"), + Country: pointer.For("FR"), + Metadata: map[string]string{ + "com.atlar.spec/owner/name": "test", + "com.atlar.spec/owner/type": "INDIVIDUAL", + }, + } + }) + + It("should return an error - missing owner name in bank account metadata", func(ctx SpecContext) { + ba := sampleBankAccount + delete(ba.Metadata, "com.atlar.spec/owner/name") + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("required metadata field com.atlar.spec/owner/name is missing")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - missing owner type in bank account metadata", func(ctx SpecContext) { + ba := sampleBankAccount + delete(ba.Metadata, "com.atlar.spec/owner/type") + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("required metadata field com.atlar.spec/owner/type is missing")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - wrong owner type in bank account metadata", func(ctx SpecContext) { + ba := sampleBankAccount + ba.Metadata["com.atlar.spec/owner/type"] = "WRONG" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("metadata field com.atlar.spec/owner/type needs to be one of [ INDIVIDUAL COMPANY ]")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - create account error", func(ctx SpecContext) { + ba := sampleBankAccount + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().PostV1CounterParties(ctx, ba).Return(nil, errors.New("test-error")) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test-error")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should work", func(ctx SpecContext) { + ba := sampleBankAccount + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().PostV1CounterParties(ctx, ba).Return( + &counterparties.PostV1CounterpartiesCreated{ + Payload: &atlar_models.Counterparty{ + ContactDetails: &atlar_models.ContactDetails{ + Address: &atlar_models.Address{ + City: "", + Country: "", + PostalCode: "", + RawAddressLines: []string{}, + StreetName: "", + StreetNumber: "", + }, + Email: "", + NationalID: "", + Phone: "", + }, + Created: new(string), + ExternalAccounts: []*atlar_models.ExternalAccount{ + { + Bank: &atlar_models.BankSlim{ + Bic: "", + ID: "", + Name: "", + }, + CounterpartyID: "", + Created: now.Format(time.RFC3339Nano), + ExternalID: "", + ExternalMetadata: map[string]string{}, + ID: "test", + Identifiers: []*atlar_models.AccountIdentifier{ + { + HolderName: pointer.For("test"), + Market: pointer.For("test"), + Number: pointer.For("test"), + Type: pointer.For("test"), + }, + }, + OrganizationID: "", + Updated: "", + }, + }, + ExternalID: "", + ExternalMetadata: map[string]string{}, + ID: pointer.For("test"), + Identifiers: []*atlar_models.AccountIdentifier{ + { + HolderName: pointer.For("test"), + Market: pointer.For("test"), + Number: pointer.For("test"), + Type: pointer.For("test"), + }, + }, + Name: "", + OrganizationID: new(string), + PartyType: "", + Updated: "", + Version: 0, + }, + }, + nil, + ) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res.RelatedAccount.Reference).To(Equal("test")) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/capabilities.go b/internal/connectors/plugins/public/atlar/capabilities.go new file mode 100644 index 00000000..5ab06550 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/capabilities.go @@ -0,0 +1,10 @@ +package atlar + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + models.CAPABILITY_FETCH_OTHERS, +} diff --git a/internal/connectors/plugins/public/atlar/client/accounts.go b/internal/connectors/plugins/public/atlar/client/accounts.go new file mode 100644 index 00000000..4eb98bb3 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/accounts.go @@ -0,0 +1,35 @@ +package client + +import ( + "context" + "time" + + "github.com/get-momo/atlar-v1-go-client/client/accounts" +) + +func (c *client) GetV1AccountsID(ctx context.Context, id string) (*accounts.GetV1AccountsIDOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "get_account") + + accountsParams := accounts.GetV1AccountsIDParams{ + Context: ctx, + ID: id, + } + + resp, err := c.client.Accounts.GetV1AccountsID(&accountsParams) + return resp, wrapSDKErr(err) +} + +func (c *client) GetV1Accounts(ctx context.Context, token string, pageSize int64) (*accounts.GetV1AccountsOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_accounts") + + accountsParams := accounts.GetV1AccountsParams{ + Limit: &pageSize, + Context: ctx, + Token: &token, + } + + resp, err := c.client.Accounts.GetV1Accounts(&accountsParams) + return resp, wrapSDKErr(err) +} diff --git a/internal/connectors/plugins/public/atlar/client/client.go b/internal/connectors/plugins/public/atlar/client/client.go new file mode 100644 index 00000000..e884e653 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/client.go @@ -0,0 +1,112 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/connectors/metrics" + "github.com/formancehq/payments/internal/models" + atlar_client "github.com/get-momo/atlar-v1-go-client/client" + "github.com/get-momo/atlar-v1-go-client/client/accounts" + "github.com/get-momo/atlar-v1-go-client/client/counterparties" + "github.com/get-momo/atlar-v1-go-client/client/credit_transfers" + "github.com/get-momo/atlar-v1-go-client/client/external_accounts" + "github.com/get-momo/atlar-v1-go-client/client/third_parties" + "github.com/get-momo/atlar-v1-go-client/client/transactions" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + "github.com/go-openapi/runtime" + httptransport "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetV1Accounts(ctx context.Context, token string, pageSize int64) (*accounts.GetV1AccountsOK, error) + GetV1AccountsID(ctx context.Context, id string) (*accounts.GetV1AccountsIDOK, error) + + PostV1CounterParties(ctx context.Context, newExternalBankAccount models.BankAccount) (*counterparties.PostV1CounterpartiesCreated, error) + GetV1CounterpartiesID(ctx context.Context, counterPartyID string) (*counterparties.GetV1CounterpartiesIDOK, error) + + GetV1ExternalAccounts(ctx context.Context, token string, pageSize int64) (*external_accounts.GetV1ExternalAccountsOK, error) + GetV1ExternalAccountsID(ctx context.Context, externalAccountID string) (*external_accounts.GetV1ExternalAccountsIDOK, error) + + GetV1BetaThirdPartiesID(ctx context.Context, id string) (*third_parties.GetV1betaThirdPartiesIDOK, error) + + GetV1Transactions(ctx context.Context, token string, pageSize int64) (*transactions.GetV1TransactionsOK, error) + GetV1TransactionsID(ctx context.Context, id string) (*transactions.GetV1TransactionsIDOK, error) + + PostV1CreditTransfers(ctx context.Context, req *atlar_models.CreatePaymentRequest) (*credit_transfers.PostV1CreditTransfersCreated, error) + GetV1CreditTransfersGetByExternalIDExternalID(ctx context.Context, externalID string) (*credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK, error) +} + +type client struct { + client *atlar_client.Rest + commonMetricAttributes []attribute.KeyValue +} + +func New(baseURL *url.URL, accessKey, secret string) Client { + c := &client{ + client: createAtlarClient(baseURL, accessKey, secret), + commonMetricAttributes: CommonMetricsAttributes(), + } + + return c +} + +func createAtlarClient(baseURL *url.URL, accessKey, secret string) *atlar_client.Rest { + transport := httptransport.New( + baseURL.Host, + baseURL.Path, + []string{baseURL.Scheme}, + ) + basicAuth := httptransport.BasicAuth(accessKey, secret) + transport.DefaultAuthentication = basicAuth + client := atlar_client.New(transport, strfmt.Default) + return client +} + +// recordMetrics is meant to be called in a defer +func (c *client) recordMetrics(ctx context.Context, start time.Time, operation string) { + registry := metrics.GetMetricsRegistry() + + attrs := c.commonMetricAttributes + attrs = append(attrs, attribute.String("operation", operation)) + opts := metric.WithAttributes(attrs...) + + registry.ConnectorPSPCalls().Add(ctx, 1, opts) + registry.ConnectorPSPCallLatencies().Record(ctx, time.Since(start).Milliseconds(), opts) +} + +func CommonMetricsAttributes() []attribute.KeyValue { + metricsAttributes := []attribute.KeyValue{ + attribute.String("connector", "generic"), + } + return metricsAttributes +} + +// wrap a public error for cases that we don't want to retry +// so that activities can classify this error for temporal +func wrapSDKErr(err error) error { + if err == nil { + return nil + } + + atlarErr, ok := err.(*runtime.APIError) + if !ok { + return err + } + + if atlarErr.Code >= http.StatusBadRequest && atlarErr.Code < http.StatusInternalServerError { + return fmt.Errorf("atlar error: %w: %w", err, httpwrapper.ErrStatusCodeClientError) + } else if atlarErr.Code >= http.StatusInternalServerError { + return fmt.Errorf("atlar error: %w: %w", err, httpwrapper.ErrStatusCodeServerError) + } + + return err +} diff --git a/internal/connectors/plugins/public/atlar/client/client_generated.go b/internal/connectors/plugins/public/atlar/client/client_generated.go new file mode 100644 index 00000000..a2c6cc04 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/client_generated.go @@ -0,0 +1,213 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + models "github.com/formancehq/payments/internal/models" + accounts "github.com/get-momo/atlar-v1-go-client/client/accounts" + counterparties "github.com/get-momo/atlar-v1-go-client/client/counterparties" + credit_transfers "github.com/get-momo/atlar-v1-go-client/client/credit_transfers" + external_accounts "github.com/get-momo/atlar-v1-go-client/client/external_accounts" + third_parties "github.com/get-momo/atlar-v1-go-client/client/third_parties" + transactions "github.com/get-momo/atlar-v1-go-client/client/transactions" + models0 "github.com/get-momo/atlar-v1-go-client/models" + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetV1Accounts mocks base method. +func (m *MockClient) GetV1Accounts(ctx context.Context, token string, pageSize int64) (*accounts.GetV1AccountsOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1Accounts", ctx, token, pageSize) + ret0, _ := ret[0].(*accounts.GetV1AccountsOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1Accounts indicates an expected call of GetV1Accounts. +func (mr *MockClientMockRecorder) GetV1Accounts(ctx, token, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1Accounts", reflect.TypeOf((*MockClient)(nil).GetV1Accounts), ctx, token, pageSize) +} + +// GetV1AccountsID mocks base method. +func (m *MockClient) GetV1AccountsID(ctx context.Context, id string) (*accounts.GetV1AccountsIDOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1AccountsID", ctx, id) + ret0, _ := ret[0].(*accounts.GetV1AccountsIDOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1AccountsID indicates an expected call of GetV1AccountsID. +func (mr *MockClientMockRecorder) GetV1AccountsID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1AccountsID", reflect.TypeOf((*MockClient)(nil).GetV1AccountsID), ctx, id) +} + +// GetV1BetaThirdPartiesID mocks base method. +func (m *MockClient) GetV1BetaThirdPartiesID(ctx context.Context, id string) (*third_parties.GetV1betaThirdPartiesIDOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1BetaThirdPartiesID", ctx, id) + ret0, _ := ret[0].(*third_parties.GetV1betaThirdPartiesIDOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1BetaThirdPartiesID indicates an expected call of GetV1BetaThirdPartiesID. +func (mr *MockClientMockRecorder) GetV1BetaThirdPartiesID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1BetaThirdPartiesID", reflect.TypeOf((*MockClient)(nil).GetV1BetaThirdPartiesID), ctx, id) +} + +// GetV1CounterpartiesID mocks base method. +func (m *MockClient) GetV1CounterpartiesID(ctx context.Context, counterPartyID string) (*counterparties.GetV1CounterpartiesIDOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1CounterpartiesID", ctx, counterPartyID) + ret0, _ := ret[0].(*counterparties.GetV1CounterpartiesIDOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1CounterpartiesID indicates an expected call of GetV1CounterpartiesID. +func (mr *MockClientMockRecorder) GetV1CounterpartiesID(ctx, counterPartyID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1CounterpartiesID", reflect.TypeOf((*MockClient)(nil).GetV1CounterpartiesID), ctx, counterPartyID) +} + +// GetV1CreditTransfersGetByExternalIDExternalID mocks base method. +func (m *MockClient) GetV1CreditTransfersGetByExternalIDExternalID(ctx context.Context, externalID string) (*credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1CreditTransfersGetByExternalIDExternalID", ctx, externalID) + ret0, _ := ret[0].(*credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1CreditTransfersGetByExternalIDExternalID indicates an expected call of GetV1CreditTransfersGetByExternalIDExternalID. +func (mr *MockClientMockRecorder) GetV1CreditTransfersGetByExternalIDExternalID(ctx, externalID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1CreditTransfersGetByExternalIDExternalID", reflect.TypeOf((*MockClient)(nil).GetV1CreditTransfersGetByExternalIDExternalID), ctx, externalID) +} + +// GetV1ExternalAccounts mocks base method. +func (m *MockClient) GetV1ExternalAccounts(ctx context.Context, token string, pageSize int64) (*external_accounts.GetV1ExternalAccountsOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1ExternalAccounts", ctx, token, pageSize) + ret0, _ := ret[0].(*external_accounts.GetV1ExternalAccountsOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1ExternalAccounts indicates an expected call of GetV1ExternalAccounts. +func (mr *MockClientMockRecorder) GetV1ExternalAccounts(ctx, token, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1ExternalAccounts", reflect.TypeOf((*MockClient)(nil).GetV1ExternalAccounts), ctx, token, pageSize) +} + +// GetV1ExternalAccountsID mocks base method. +func (m *MockClient) GetV1ExternalAccountsID(ctx context.Context, externalAccountID string) (*external_accounts.GetV1ExternalAccountsIDOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1ExternalAccountsID", ctx, externalAccountID) + ret0, _ := ret[0].(*external_accounts.GetV1ExternalAccountsIDOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1ExternalAccountsID indicates an expected call of GetV1ExternalAccountsID. +func (mr *MockClientMockRecorder) GetV1ExternalAccountsID(ctx, externalAccountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1ExternalAccountsID", reflect.TypeOf((*MockClient)(nil).GetV1ExternalAccountsID), ctx, externalAccountID) +} + +// GetV1Transactions mocks base method. +func (m *MockClient) GetV1Transactions(ctx context.Context, token string, pageSize int64) (*transactions.GetV1TransactionsOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1Transactions", ctx, token, pageSize) + ret0, _ := ret[0].(*transactions.GetV1TransactionsOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1Transactions indicates an expected call of GetV1Transactions. +func (mr *MockClientMockRecorder) GetV1Transactions(ctx, token, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1Transactions", reflect.TypeOf((*MockClient)(nil).GetV1Transactions), ctx, token, pageSize) +} + +// GetV1TransactionsID mocks base method. +func (m *MockClient) GetV1TransactionsID(ctx context.Context, id string) (*transactions.GetV1TransactionsIDOK, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetV1TransactionsID", ctx, id) + ret0, _ := ret[0].(*transactions.GetV1TransactionsIDOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetV1TransactionsID indicates an expected call of GetV1TransactionsID. +func (mr *MockClientMockRecorder) GetV1TransactionsID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetV1TransactionsID", reflect.TypeOf((*MockClient)(nil).GetV1TransactionsID), ctx, id) +} + +// PostV1CounterParties mocks base method. +func (m *MockClient) PostV1CounterParties(ctx context.Context, newExternalBankAccount models.BankAccount) (*counterparties.PostV1CounterpartiesCreated, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostV1CounterParties", ctx, newExternalBankAccount) + ret0, _ := ret[0].(*counterparties.PostV1CounterpartiesCreated) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostV1CounterParties indicates an expected call of PostV1CounterParties. +func (mr *MockClientMockRecorder) PostV1CounterParties(ctx, newExternalBankAccount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostV1CounterParties", reflect.TypeOf((*MockClient)(nil).PostV1CounterParties), ctx, newExternalBankAccount) +} + +// PostV1CreditTransfers mocks base method. +func (m *MockClient) PostV1CreditTransfers(ctx context.Context, req *models0.CreatePaymentRequest) (*credit_transfers.PostV1CreditTransfersCreated, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostV1CreditTransfers", ctx, req) + ret0, _ := ret[0].(*credit_transfers.PostV1CreditTransfersCreated) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostV1CreditTransfers indicates an expected call of PostV1CreditTransfers. +func (mr *MockClientMockRecorder) PostV1CreditTransfers(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostV1CreditTransfers", reflect.TypeOf((*MockClient)(nil).PostV1CreditTransfers), ctx, req) +} diff --git a/internal/connectors/plugins/public/atlar/client/counter_parties.go b/internal/connectors/plugins/public/atlar/client/counter_parties.go new file mode 100644 index 00000000..78a43363 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/counter_parties.go @@ -0,0 +1,107 @@ +package client + +import ( + "context" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/counterparties" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" +) + +func (c *client) GetV1CounterpartiesID(ctx context.Context, counterPartyID string) (*counterparties.GetV1CounterpartiesIDOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "get_counterparty") + + getCounterpartyParams := counterparties.GetV1CounterpartiesIDParams{ + Context: ctx, + ID: counterPartyID, + } + counterpartyResponse, err := c.client.Counterparties.GetV1CounterpartiesID(&getCounterpartyParams) + return counterpartyResponse, wrapSDKErr(err) +} + +func (c *client) PostV1CounterParties(ctx context.Context, newExternalBankAccount models.BankAccount) (*counterparties.PostV1CounterpartiesCreated, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "create_counter_party") + + // TODO: make sure an account with that IBAN does not already exist (Atlar API v2 needed, v1 lacks the filters) + // alternatively we could query the local DB + + createCounterpartyRequest := atlar_models.CreateCounterpartyRequest{ + Name: ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/name"), + PartyType: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/type"), + ContactDetails: &atlar_models.ContactDetails{ + Email: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/email"), + Phone: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/phone"), + Address: &atlar_models.Address{ + StreetName: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/streetName"), + StreetNumber: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/streetNumber"), + City: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/city"), + PostalCode: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/postalCode"), + Country: *ExtractNamespacedMetadataIgnoreEmpty(newExternalBankAccount.Metadata, "owner/contact/address/country"), + }, + }, + ExternalAccounts: []*atlar_models.CreateEmbeddedExternalAccountRequest{ + { + // ExternalID could cause problems when synchronizing with Accounts[type=external] + Bank: &atlar_models.UpdatableBank{ + Bic: func() string { + if newExternalBankAccount.SwiftBicCode == nil { + return "" + } + return *newExternalBankAccount.SwiftBicCode + }(), + }, + Identifiers: extractAtlarAccountIdentifiersFromBankAccount(newExternalBankAccount), + }, + }, + } + postCounterpartiesParams := counterparties.PostV1CounterpartiesParams{ + Context: ctx, + Counterparty: &createCounterpartyRequest, + } + postCounterpartiesResponse, err := c.client.Counterparties.PostV1Counterparties(&postCounterpartiesParams) + if err != nil { + return nil, wrapSDKErr(err) + } + + if len(postCounterpartiesResponse.Payload.ExternalAccounts) != 1 { + // should never occur, but when in case it happens it's nice to have an error to search for + return nil, fmt.Errorf("counterparty was not created with exactly one account: %w", httpwrapper.ErrStatusCodeUnexpected) + } + + return postCounterpartiesResponse, nil +} + +func extractAtlarAccountIdentifiersFromBankAccount(bankAccount models.BankAccount) []*atlar_models.AccountIdentifier { + ownerName := bankAccount.Metadata[atlarMetadataSpecNamespace+"owner/name"] + ibanType := "IBAN" + accountIdentifiers := []*atlar_models.AccountIdentifier{{ + HolderName: &ownerName, + Market: bankAccount.Country, + Type: &ibanType, + Number: bankAccount.IBAN, + }} + for k := range bankAccount.Metadata { + // check whether the key has format com.atlar.spec/identifier// + identifierData, err := metadataToIdentifierData(k, bankAccount.Metadata[k]) + if err != nil { + // matadata does not describe an identifier + continue + } + if bankAccount.Country != nil && identifierData.Market == *bankAccount.Country && identifierData.Type == "IBAN" { + // avoid duplicate identifiers + continue + } + accountIdentifiers = append(accountIdentifiers, &atlar_models.AccountIdentifier{ + HolderName: &ownerName, + Market: &identifierData.Market, + Type: &identifierData.Type, + Number: &identifierData.Number, + }) + } + return accountIdentifiers +} diff --git a/internal/connectors/plugins/public/atlar/client/external_accounts.go b/internal/connectors/plugins/public/atlar/client/external_accounts.go new file mode 100644 index 00000000..e51512b8 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/external_accounts.go @@ -0,0 +1,35 @@ +package client + +import ( + "context" + "time" + + "github.com/get-momo/atlar-v1-go-client/client/external_accounts" +) + +func (c *client) GetV1ExternalAccountsID(ctx context.Context, externalAccountID string) (*external_accounts.GetV1ExternalAccountsIDOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "get_external_account") + + getExternalAccountParams := external_accounts.GetV1ExternalAccountsIDParams{ + Context: ctx, + ID: externalAccountID, + } + + externalAccountResponse, err := c.client.ExternalAccounts.GetV1ExternalAccountsID(&getExternalAccountParams) + return externalAccountResponse, wrapSDKErr(err) +} + +func (c *client) GetV1ExternalAccounts(ctx context.Context, token string, pageSize int64) (*external_accounts.GetV1ExternalAccountsOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_external_accounts") + + externalAccountsParams := external_accounts.GetV1ExternalAccountsParams{ + Limit: &pageSize, + Context: ctx, + Token: &token, + } + + resp, err := c.client.ExternalAccounts.GetV1ExternalAccounts(&externalAccountsParams) + return resp, wrapSDKErr(err) +} diff --git a/internal/connectors/plugins/public/atlar/client/third_parties.go b/internal/connectors/plugins/public/atlar/client/third_parties.go new file mode 100644 index 00000000..ae068913 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/third_parties.go @@ -0,0 +1,21 @@ +package client + +import ( + "context" + "time" + + "github.com/get-momo/atlar-v1-go-client/client/third_parties" +) + +func (c *client) GetV1BetaThirdPartiesID(ctx context.Context, id string) (*third_parties.GetV1betaThirdPartiesIDOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "get_third_party") + + params := third_parties.GetV1betaThirdPartiesIDParams{ + Context: ctx, + ID: id, + } + + resp, err := c.client.ThirdParties.GetV1betaThirdPartiesID(¶ms) + return resp, wrapSDKErr(err) +} diff --git a/internal/connectors/plugins/public/atlar/client/transactions.go b/internal/connectors/plugins/public/atlar/client/transactions.go new file mode 100644 index 00000000..5e6745a8 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/transactions.go @@ -0,0 +1,35 @@ +package client + +import ( + "context" + "time" + + "github.com/get-momo/atlar-v1-go-client/client/transactions" +) + +func (c *client) GetV1Transactions(ctx context.Context, token string, pageSize int64) (*transactions.GetV1TransactionsOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_transactions") + + params := transactions.GetV1TransactionsParams{ + Limit: &pageSize, + Context: ctx, + Token: &token, + } + + resp, err := c.client.Transactions.GetV1Transactions(¶ms) + return resp, wrapSDKErr(err) +} + +func (c *client) GetV1TransactionsID(ctx context.Context, id string) (*transactions.GetV1TransactionsIDOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "get_transaction") + + params := transactions.GetV1TransactionsIDParams{ + Context: ctx, + ID: id, + } + + resp, err := c.client.Transactions.GetV1TransactionsID(¶ms) + return resp, wrapSDKErr(err) +} diff --git a/internal/connectors/plugins/public/atlar/client/transfers.go b/internal/connectors/plugins/public/atlar/client/transfers.go new file mode 100644 index 00000000..28a5d1e3 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/client/transfers.go @@ -0,0 +1,35 @@ +package client + +import ( + "context" + "time" + + "github.com/get-momo/atlar-v1-go-client/client/credit_transfers" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" +) + +func (c *client) PostV1CreditTransfers(ctx context.Context, req *atlar_models.CreatePaymentRequest) (*credit_transfers.PostV1CreditTransfersCreated, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "create_credit_transfer") + + postCreditTransfersParams := credit_transfers.PostV1CreditTransfersParams{ + Context: ctx, + CreditTransfer: req, + } + + resp, err := c.client.CreditTransfers.PostV1CreditTransfers(&postCreditTransfersParams) + return resp, wrapSDKErr(err) +} + +func (c *client) GetV1CreditTransfersGetByExternalIDExternalID(ctx context.Context, externalID string) (*credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "get_credit_transfer") + + getCreditTransferParams := credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDParams{ + Context: ctx, + ExternalID: externalID, + } + + resp, err := c.client.CreditTransfers.GetV1CreditTransfersGetByExternalIDExternalID(&getCreditTransferParams) + return resp, wrapSDKErr(err) +} diff --git a/cmd/connectors/internal/connectors/atlar/client/utils.go b/internal/connectors/plugins/public/atlar/client/utils.go similarity index 100% rename from cmd/connectors/internal/connectors/atlar/client/utils.go rename to internal/connectors/plugins/public/atlar/client/utils.go diff --git a/cmd/connectors/internal/connectors/atlar/client/utils_test.go b/internal/connectors/plugins/public/atlar/client/utils_test.go similarity index 100% rename from cmd/connectors/internal/connectors/atlar/client/utils_test.go rename to internal/connectors/plugins/public/atlar/client/utils_test.go diff --git a/internal/connectors/plugins/public/atlar/config.go b/internal/connectors/plugins/public/atlar/config.go new file mode 100644 index 00000000..82bff7d7 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/config.go @@ -0,0 +1,40 @@ +package atlar + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + BaseURL string `json:"baseURL"` + AccessKey string `json:"accessKey"` + Secret string `json:"secret"` +} + +func (c Config) validate() error { + if c.BaseURL == "" { + return fmt.Errorf("missing baseURL in config: %w", models.ErrInvalidConfig) + } + + if c.AccessKey == "" { + return fmt.Errorf("missing access key in config: %w", models.ErrInvalidConfig) + } + + if c.Secret == "" { + return fmt.Errorf("missing secret in config: %w", models.ErrInvalidConfig) + } + + return nil +} + +func unmarshalAndValidateConfig(payload []byte) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/atlar/config.json b/internal/connectors/plugins/public/atlar/config.json new file mode 100644 index 00000000..0f4d0739 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/config.json @@ -0,0 +1,17 @@ +{ + "baseURL": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "accessKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "secret": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/atlar/currencies.go b/internal/connectors/plugins/public/atlar/currencies.go new file mode 100644 index 00000000..75c2677c --- /dev/null +++ b/internal/connectors/plugins/public/atlar/currencies.go @@ -0,0 +1,10 @@ +package atlar + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + supportedCurrenciesWithDecimal = map[string]int{ + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "DKK": currency.ISO4217Currencies["DKK"], + } +) diff --git a/internal/connectors/plugins/public/atlar/external_accounts.go b/internal/connectors/plugins/public/atlar/external_accounts.go new file mode 100644 index 00000000..cec99cc5 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/external_accounts.go @@ -0,0 +1,131 @@ +package atlar + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/go-libs/v2/metadata" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/external_accounts" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" +) + +type externalAccountsState struct { + NextToken string `json:"nextToken"` +} + +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + + var externalAccounts []models.PSPAccount + nextToken := oldState.NextToken + for { + resp, err := p.client.GetV1ExternalAccounts(ctx, nextToken, int64(req.PageSize)) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + externalAccounts, err = p.fillExternalAccounts(ctx, resp, externalAccounts) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + nextToken = resp.Payload.NextToken + if resp.Payload.NextToken == "" || len(externalAccounts) >= req.PageSize { + break + } + } + + // If token is empty, this is perfect as the next polling task will refetch + // everything ! And that's what we want since Atlar doesn't provide any + // filters or sorting options. + newState := externalAccountsState{ + NextToken: nextToken, + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: externalAccounts, + NewState: payload, + HasMore: nextToken != "", + }, nil +} + +func (p *Plugin) fillExternalAccounts( + ctx context.Context, + pagedExternalAccounts *external_accounts.GetV1ExternalAccountsOK, + accounts []models.PSPAccount, +) ([]models.PSPAccount, error) { + for _, externalAccount := range pagedExternalAccounts.Payload.Items { + resp, err := p.client.GetV1CounterpartiesID(ctx, externalAccount.CounterpartyID) + if err != nil { + return nil, err + } + counterparty := resp.Payload + + newAccount, err := externalAccountFromAtlarData(externalAccount, counterparty) + if err != nil { + return nil, err + } + + accounts = append(accounts, newAccount) + } + + return accounts, nil +} + +type AtlarExternalAccountAndCounterparty struct { + ExternalAccount atlar_models.ExternalAccount `json:"externalAccount" yaml:"externalAccount" bson:"externalAccount"` + Counterparty atlar_models.Counterparty `json:"counterparty" yaml:"counterparty" bson:"counterparty"` +} + +func externalAccountFromAtlarData( + externalAccount *atlar_models.ExternalAccount, + counterparty *atlar_models.Counterparty, +) (models.PSPAccount, error) { + raw, err := json.Marshal(AtlarExternalAccountAndCounterparty{ExternalAccount: *externalAccount, Counterparty: *counterparty}) + if err != nil { + return models.PSPAccount{}, err + } + + createdAt, err := ParseAtlarTimestamp(externalAccount.Created) + if err != nil { + return models.PSPAccount{}, fmt.Errorf("failed to parse opening date: %w", err) + } + + return models.PSPAccount{ + Reference: externalAccount.ID, + CreatedAt: createdAt, + Name: &counterparty.Name, + Metadata: extractExternalAccountAndCounterpartyMetadata(externalAccount, counterparty), + Raw: raw, + }, nil +} + +func extractExternalAccountAndCounterpartyMetadata(externalAccount *atlar_models.ExternalAccount, counterparty *atlar_models.Counterparty) metadata.Metadata { + result := metadata.Metadata{} + result = result.Merge(computeMetadata("bank/id", externalAccount.Bank.ID)) + result = result.Merge(computeMetadata("bank/name", externalAccount.Bank.Name)) + result = result.Merge(computeMetadata("bank/bic", externalAccount.Bank.Bic)) + result = result.Merge(identifiersToMetadata(externalAccount.Identifiers)) + result = result.Merge(computeMetadata("owner/name", counterparty.Name)) + result = result.Merge(computeMetadata("owner/type", counterparty.PartyType)) + result = result.Merge(computeMetadata("owner/contact/email", counterparty.ContactDetails.Email)) + result = result.Merge(computeMetadata("owner/contact/phone", counterparty.ContactDetails.Phone)) + result = result.Merge(computeMetadata("owner/contact/address/streetName", counterparty.ContactDetails.Address.StreetName)) + result = result.Merge(computeMetadata("owner/contact/address/streetNumber", counterparty.ContactDetails.Address.StreetNumber)) + result = result.Merge(computeMetadata("owner/contact/address/city", counterparty.ContactDetails.Address.City)) + result = result.Merge(computeMetadata("owner/contact/address/postalCode", counterparty.ContactDetails.Address.PostalCode)) + result = result.Merge(computeMetadata("owner/contact/address/country", counterparty.ContactDetails.Address.Country)) + return result +} diff --git a/internal/connectors/plugins/public/atlar/external_accounts_test.go b/internal/connectors/plugins/public/atlar/external_accounts_test.go new file mode 100644 index 00000000..bf4a6de0 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/external_accounts_test.go @@ -0,0 +1,275 @@ +package atlar + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/counterparties" + "github.com/get-momo/atlar-v1-go-client/client/external_accounts" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Atlar Plugin External Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next external accounts", func() { + var ( + m *client.MockClient + sampleAccounts []*atlar_models.ExternalAccount + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]*atlar_models.ExternalAccount, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, &atlar_models.ExternalAccount{ + ID: fmt.Sprintf("%d", i), + Created: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format(time.RFC3339Nano), + Bank: &atlar_models.BankSlim{ + Bic: "1234", + ID: "testBank", + Name: "testBank", + }, + CounterpartyID: "1234", + }) + } + }) + + It("should return an error - get external accounts error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1ExternalAccounts(gomock.Any(), "", int64(60)).Return( + &external_accounts.GetV1ExternalAccountsOK{ + Payload: &external_accounts.GetV1ExternalAccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: []*atlar_models.ExternalAccount{}, + }, + }, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1ExternalAccounts(gomock.Any(), "", int64(60)).Return( + &external_accounts.GetV1ExternalAccountsOK{ + Payload: &external_accounts.GetV1ExternalAccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: []*atlar_models.ExternalAccount{}, + }, + }, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + + It("should fetch next external accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1ExternalAccounts(gomock.Any(), "", int64(60)).Return( + &external_accounts.GetV1ExternalAccountsOK{ + Payload: &external_accounts.GetV1ExternalAccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: sampleAccounts, + }, + }, + nil, + ) + + for _, acc := range sampleAccounts { + m.EXPECT().GetV1CounterpartiesID(gomock.Any(), acc.CounterpartyID).Return( + &counterparties.GetV1CounterpartiesIDOK{ + Payload: &atlar_models.Counterparty{ + ContactDetails: &atlar_models.ContactDetails{ + Address: &atlar_models.Address{ + City: "Paris", + Country: "France", + PostalCode: "75013", + StreetName: "test", + StreetNumber: "42", + }, + Email: "test@test.com", + NationalID: "1", + }, + Created: pointer.For(now.Format(time.RFC3339Nano)), + ExternalID: "123", + ID: pointer.For("123"), + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + + It("should fetch next external accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 40, + } + + m.EXPECT().GetV1ExternalAccounts(gomock.Any(), "", int64(40)).Return( + &external_accounts.GetV1ExternalAccountsOK{ + Payload: &external_accounts.GetV1ExternalAccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "1234", + }, + Items: sampleAccounts[:40], + }, + }, + nil, + ) + + for _, acc := range sampleAccounts[:40] { + m.EXPECT().GetV1CounterpartiesID(gomock.Any(), acc.CounterpartyID).Return( + &counterparties.GetV1CounterpartiesIDOK{ + Payload: &atlar_models.Counterparty{ + ContactDetails: &atlar_models.ContactDetails{ + Address: &atlar_models.Address{ + City: "Paris", + Country: "France", + PostalCode: "75013", + StreetName: "test", + StreetNumber: "42", + }, + Email: "test@test.com", + NationalID: "1", + }, + Created: pointer.For(now.Format(time.RFC3339Nano)), + ExternalID: "123", + ID: pointer.For("123"), + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.NextToken).To(Equal("1234")) + }) + + It("should fetch next external accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{"nextToken": "1234"}`), + PageSize: 40, + } + + m.EXPECT().GetV1ExternalAccounts(gomock.Any(), "1234", int64(40)).Return( + &external_accounts.GetV1ExternalAccountsOK{ + Payload: &external_accounts.GetV1ExternalAccountsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: sampleAccounts[40:], + }, + }, + nil, + ) + + for _, acc := range sampleAccounts[40:] { + m.EXPECT().GetV1CounterpartiesID(gomock.Any(), acc.CounterpartyID).Return( + &counterparties.GetV1CounterpartiesIDOK{ + Payload: &atlar_models.Counterparty{ + ContactDetails: &atlar_models.ContactDetails{ + Address: &atlar_models.Address{ + City: "Paris", + Country: "France", + PostalCode: "75013", + StreetName: "test", + StreetNumber: "42", + }, + Email: "test@test.com", + NationalID: "1", + }, + Created: pointer.For(now.Format(time.RFC3339Nano)), + ExternalID: "123", + ID: pointer.For("123"), + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/metadata.go b/internal/connectors/plugins/public/atlar/metadata.go new file mode 100644 index 00000000..610dcd22 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/metadata.go @@ -0,0 +1,34 @@ +package atlar + +import ( + "fmt" +) + +const ( + atlarMetadataSpecNamespace = "com.atlar.spec/" + valueTRUE = "TRUE" + valueFALSE = "FALSE" +) + +func computeMetadata(key, value string) map[string]string { + namespacedKey := fmt.Sprintf("%s%s", atlarMetadataSpecNamespace, key) + return map[string]string{ + namespacedKey: value, + } +} + +func computeMetadataBool(key string, value bool) map[string]string { + computedValue := valueFALSE + if value { + computedValue = valueTRUE + } + return computeMetadata(key, computedValue) +} + +func extractNamespacedMetadata(metadata map[string]string, key string) (string, error) { + value, ok := metadata[atlarMetadataSpecNamespace+key] + if !ok { + return "", fmt.Errorf("unable to find metadata with key %s%s", atlarMetadataSpecNamespace, key) + } + return value, nil +} diff --git a/internal/connectors/plugins/public/atlar/payments.go b/internal/connectors/plugins/public/atlar/payments.go new file mode 100644 index 00000000..dcd6fdc9 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/payments.go @@ -0,0 +1,251 @@ +package atlar + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/formancehq/go-libs/v2/metadata" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/transactions" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" +) + +type transactionsState struct { + NextToken string `json:"nextToken"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState transactionsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + var payments []models.PSPPayment + nextToken := oldState.NextToken + for { + resp, err := p.client.GetV1Transactions(ctx, nextToken, int64(req.PageSize)) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, err = p.fillPayments(ctx, resp, payments) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + nextToken = resp.Payload.NextToken + if resp.Payload.NextToken == "" || len(payments) >= req.PageSize { + break + } + } + + // If token is empty, this is perfect as the next polling task will refetch + // everything ! And that's what we want since Atlar doesn't provide any + // filters or sorting options. + newState := transactionsState{ + NextToken: nextToken, + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: nextToken != "", + }, nil +} + +func (p *Plugin) fillPayments( + ctx context.Context, + resp *transactions.GetV1TransactionsOK, + payments []models.PSPPayment, +) ([]models.PSPPayment, error) { + for _, item := range resp.Payload.Items { + payment, err := p.transactionToPayment(ctx, item) + if err != nil { + return nil, err + } + + if payment != nil { + payments = append(payments, *payment) + } + } + + return payments, nil +} + +func (p *Plugin) transactionToPayment( + ctx context.Context, + from *atlar_models.Transaction, +) (*models.PSPPayment, error) { + if _, ok := supportedCurrenciesWithDecimal[*from.Amount.Currency]; !ok { + // Discard transactions with unsupported currencies + return nil, nil + } + + raw, err := json.Marshal(from) + if err != nil { + return nil, err + } + + paymentType := determinePaymentType(from) + + itemAmount := from.Amount + amount, err := atlarTransactionAmountToPaymentAbsoluteAmount(*itemAmount.Value) + if err != nil { + return nil, err + } + + createdAt, err := ParseAtlarTimestamp(from.Created) + if err != nil { + return nil, err + } + + accountResponse, err := p.client.GetV1AccountsID(ctx, *from.Account.ID) + if err != nil { + return nil, err + } + + thirdPartyResponse, err := p.client.GetV1BetaThirdPartiesID(ctx, accountResponse.Payload.ThirdPartyID) + if err != nil { + return nil, err + } + + payment := models.PSPPayment{ + Reference: from.ID, + CreatedAt: createdAt, + Type: paymentType, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, *from.Amount.Currency), + Scheme: determinePaymentScheme(from), + Status: determinePaymentStatus(from), + Metadata: extractPaymentMetadata(from, accountResponse.Payload, thirdPartyResponse.Payload), + Raw: raw, + } + + if *itemAmount.Value >= 0 { + // DEBIT + payment.DestinationAccountReference = from.Account.ID + } else { + // CREDIT + payment.SourceAccountReference = from.Account.ID + } + + return &payment, nil +} + +func determinePaymentType(item *atlar_models.Transaction) models.PaymentType { + if *item.Amount.Value >= 0 { + return models.PAYMENT_TYPE_PAYIN + } else { + return models.PAYMENT_TYPE_PAYOUT + } +} + +func determinePaymentStatus(item *atlar_models.Transaction) models.PaymentStatus { + if item.Reconciliation.Status == atlar_models.ReconciliationDetailsStatusEXPECTED { + // A payment initiated by the owner of the accunt through the Atlar API, + // which was not yet reconciled with a payment from the statement + return models.PAYMENT_STATUS_PENDING + } + if item.Reconciliation.Status == atlar_models.ReconciliationDetailsStatusBOOKED { + // A payment comissioned with the bank, which was not yet reconciled with a + // payment from the statement + return models.PAYMENT_STATUS_SUCCEEDED + } + if item.Reconciliation.Status == atlar_models.ReconciliationDetailsStatusRECONCILED { + return models.PAYMENT_STATUS_SUCCEEDED + } + return models.PAYMENT_STATUS_OTHER +} + +func determinePaymentScheme(item *atlar_models.Transaction) models.PaymentScheme { + // item.Characteristics.BankTransactionCode.Domain + // item.Characteristics.BankTransactionCode.Family + // TODO: fees and interest -> models.PaymentSchemeOther with additional info on metadata. Will need example transactions for that + + if *item.Amount.Value > 0 { + return models.PAYMENT_SCHEME_SEPA_DEBIT + } else if *item.Amount.Value < 0 { + return models.PAYMENT_SCHEME_SEPA_CREDIT + } + return models.PAYMENT_SCHEME_SEPA +} + +func extractPaymentMetadata(transaction *atlar_models.Transaction, account *atlar_models.Account, bank *atlar_models.ThirdParty) metadata.Metadata { + result := metadata.Metadata{} + if transaction.Date != "" { + result = result.Merge(computeMetadata("date", transaction.Date)) + } + if transaction.ValueDate != "" { + result = result.Merge(computeMetadata("valueDate", transaction.ValueDate)) + } + result = result.Merge(computeMetadata("remittanceInformation/type", *transaction.RemittanceInformation.Type)) + result = result.Merge(computeMetadata("remittanceInformation/value", *transaction.RemittanceInformation.Value)) + result = result.Merge(computeMetadata("bank/id", bank.ID)) + result = result.Merge(computeMetadata("bank/name", bank.Name)) + result = result.Merge(computeMetadata("bank/bic", account.Bank.Bic)) + result = result.Merge(computeMetadata("btc/domain", transaction.Characteristics.BankTransactionCode.Domain)) + result = result.Merge(computeMetadata("btc/family", transaction.Characteristics.BankTransactionCode.Family)) + result = result.Merge(computeMetadata("btc/subfamily", transaction.Characteristics.BankTransactionCode.Subfamily)) + result = result.Merge(computeMetadata("btc/description", transaction.Characteristics.BankTransactionCode.Description)) + result = result.Merge(computeMetadataBool("returned", transaction.Characteristics.Returned)) + if transaction.CounterpartyDetails != nil && transaction.CounterpartyDetails.Name != "" { + result = result.Merge(computeMetadata("counterparty/name", transaction.CounterpartyDetails.Name)) + if transaction.CounterpartyDetails.ExternalAccount != nil && transaction.CounterpartyDetails.ExternalAccount.Identifier != nil { + result = result.Merge(computeMetadata("counterparty/bank/bic", transaction.CounterpartyDetails.ExternalAccount.Bank.Bic)) + result = result.Merge(computeMetadata("counterparty/bank/name", transaction.CounterpartyDetails.ExternalAccount.Bank.Name)) + result = result.Merge(computeMetadata( + fmt.Sprintf("counterparty/identifier/%s", transaction.CounterpartyDetails.ExternalAccount.Identifier.Type), + transaction.CounterpartyDetails.ExternalAccount.Identifier.Number)) + } + } + if transaction.Characteristics.Returned { + result = result.Merge(computeMetadata("returnReason/code", transaction.Characteristics.ReturnReason.Code)) + result = result.Merge(computeMetadata("returnReason/description", transaction.Characteristics.ReturnReason.Description)) + result = result.Merge(computeMetadata("returnReason/btc/domain", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Domain)) + result = result.Merge(computeMetadata("returnReason/btc/family", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Family)) + result = result.Merge(computeMetadata("returnReason/btc/subfamily", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Subfamily)) + result = result.Merge(computeMetadata("returnReason/btc/description", transaction.Characteristics.ReturnReason.OriginalBankTransactionCode.Description)) + } + if transaction.Characteristics.VirtualAccount != nil { + result = result.Merge(computeMetadata("virtualAccount/market", transaction.Characteristics.VirtualAccount.Market)) + result = result.Merge(computeMetadata("virtualAccount/rawIdentifier", transaction.Characteristics.VirtualAccount.RawIdentifier)) + result = result.Merge(computeMetadata("virtualAccount/bank/id", transaction.Characteristics.VirtualAccount.Bank.ID)) + result = result.Merge(computeMetadata("virtualAccount/bank/name", transaction.Characteristics.VirtualAccount.Bank.Name)) + result = result.Merge(computeMetadata("virtualAccount/bank/bic", transaction.Characteristics.VirtualAccount.Bank.Bic)) + result = result.Merge(computeMetadata("virtualAccount/identifier/holderName", *transaction.Characteristics.VirtualAccount.Identifier.HolderName)) + result = result.Merge(computeMetadata("virtualAccount/identifier/market", transaction.Characteristics.VirtualAccount.Identifier.Market)) + result = result.Merge(computeMetadata("virtualAccount/identifier/type", transaction.Characteristics.VirtualAccount.Identifier.Type)) + result = result.Merge(computeMetadata("virtualAccount/identifier/number", transaction.Characteristics.VirtualAccount.Identifier.Number)) + } + result = result.Merge(computeMetadata("reconciliation/status", transaction.Reconciliation.Status)) + result = result.Merge(computeMetadata("reconciliation/transactableId", transaction.Reconciliation.TransactableID)) + result = result.Merge(computeMetadata("reconciliation/transactableType", transaction.Reconciliation.TransactableType)) + if transaction.Characteristics.CurrencyExchange != nil { + result = result.Merge(computeMetadata("currencyExchange/sourceCurrency", transaction.Characteristics.CurrencyExchange.SourceCurrency)) + result = result.Merge(computeMetadata("currencyExchange/targetCurrency", transaction.Characteristics.CurrencyExchange.TargetCurrency)) + result = result.Merge(computeMetadata("currencyExchange/exchangeRate", transaction.Characteristics.CurrencyExchange.ExchangeRate)) + result = result.Merge(computeMetadata("currencyExchange/unitCurrency", transaction.Characteristics.CurrencyExchange.UnitCurrency)) + } + if transaction.CounterpartyDetails != nil && transaction.CounterpartyDetails.MandateReference != "" { + result = result.Merge(computeMetadata("mandateReference", transaction.CounterpartyDetails.MandateReference)) + } + + return result +} + +func atlarTransactionAmountToPaymentAbsoluteAmount(atlarAmount int64) (*big.Int, error) { + var amount big.Int + amountInt := amount.SetInt64(atlarAmount) + amountInt = amountInt.Abs(amountInt) + return amountInt, nil +} diff --git a/internal/connectors/plugins/public/atlar/payments_test.go b/internal/connectors/plugins/public/atlar/payments_test.go new file mode 100644 index 00000000..9bacd221 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/payments_test.go @@ -0,0 +1,289 @@ +package atlar + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/accounts" + "github.com/get-momo/atlar-v1-go-client/client/third_parties" + "github.com/get-momo/atlar-v1-go-client/client/transactions" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Atlar Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next payments", func() { + var ( + m *client.MockClient + samplePayments []*atlar_models.Transaction + sampleAccount atlar_models.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccount = atlar_models.Account{ + Bank: &atlar_models.BankSlim{ + Bic: "test", + }, + Bic: "test", + ThirdPartyID: "test", + } + + samplePayments = make([]*atlar_models.Transaction, 0) + for i := 0; i < 50; i++ { + samplePayments = append(samplePayments, &atlar_models.Transaction{ + ID: fmt.Sprintf("%d", i), + Amount: &atlar_models.Amount{ + Currency: pointer.For("EUR"), + StringValue: pointer.For("100"), + Value: pointer.For(int64(100)), + }, + Account: &atlar_models.AccountTrx{ + ID: pointer.For("test"), + }, + Characteristics: &atlar_models.TransactionCharacteristics{ + BankTransactionCode: &atlar_models.BankTransactionCode{ + Description: "test", + Domain: "test", + Family: "test", + Subfamily: "test", + }, + }, + Created: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format(time.RFC3339Nano), + Description: "test", + Reconciliation: &atlar_models.ReconciliationDetails{ + BookedTransactionID: "test", + ExpectedTransactionID: "test", + Status: "test", + TransactableID: "test", + TransactableType: "test", + }, + RemittanceInformation: &atlar_models.RemittanceInformation{ + Type: pointer.For("test"), + Value: pointer.For("test"), + }, + }) + } + }) + + It("should return an error - get payments error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1Transactions(gomock.Any(), "", int64(60)).Return( + &transactions.GetV1TransactionsOK{ + Payload: &transactions.GetV1TransactionsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: []*atlar_models.Transaction{}, + }, + }, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1Transactions(gomock.Any(), "", int64(60)).Return( + &transactions.GetV1TransactionsOK{ + Payload: &transactions.GetV1TransactionsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: []*atlar_models.Transaction{}, + }, + }, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + } + + m.EXPECT().GetV1Transactions(gomock.Any(), "", int64(60)).Return( + &transactions.GetV1TransactionsOK{ + Payload: &transactions.GetV1TransactionsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: samplePayments, + }, + }, + nil, + ) + + for _, acc := range samplePayments { + m.EXPECT().GetV1AccountsID(gomock.Any(), *acc.Account.ID).Return( + &accounts.GetV1AccountsIDOK{ + Payload: &sampleAccount, + }, + nil, + ) + + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), sampleAccount.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 40, + } + + m.EXPECT().GetV1Transactions(gomock.Any(), "", int64(40)).Return( + &transactions.GetV1TransactionsOK{ + Payload: &transactions.GetV1TransactionsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "1234", + }, + Items: samplePayments[:40], + }, + }, + nil, + ) + + for _, acc := range samplePayments[:40] { + m.EXPECT().GetV1AccountsID(gomock.Any(), *acc.Account.ID).Return( + &accounts.GetV1AccountsIDOK{ + Payload: &sampleAccount, + }, + nil, + ) + + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), sampleAccount.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.NextToken).To(Equal("1234")) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{"nextToken": "1234"}`), + PageSize: 40, + } + + m.EXPECT().GetV1Transactions(gomock.Any(), "1234", int64(40)).Return( + &transactions.GetV1TransactionsOK{ + Payload: &transactions.GetV1TransactionsOKBody{ + QueryResponse: atlar_models.QueryResponse{ + NextToken: "", + }, + Items: samplePayments[40:], + }, + }, + nil, + ) + + for _, acc := range samplePayments[40:] { + m.EXPECT().GetV1AccountsID(gomock.Any(), *acc.Account.ID).Return( + &accounts.GetV1AccountsIDOK{ + Payload: &sampleAccount, + }, + nil, + ) + + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), sampleAccount.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + } + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.NextToken).To(BeEmpty()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/payouts.go b/internal/connectors/plugins/public/atlar/payouts.go new file mode 100644 index 00000000..f7ac8460 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/payouts.go @@ -0,0 +1,121 @@ +package atlar + +import ( + "context" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" +) + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (string, error) { + if err := validateTransferPayoutRequest(pi); err != nil { + return "", err + } + + currency, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return "", fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + paymentSchemeType := "SCT" // SEPA Credit Transfer + remittanceInformationType := "UNSTRUCTURED" + remittanceInformationValue := pi.Description + amount := atlar_models.AmountInput{ + Currency: ¤cy, + Value: pi.Amount.Int64(), + StringValue: amountToString(*pi.Amount, precision), + } + date := pi.CreatedAt + if date.IsZero() { + date = time.Now() + } + dateString := date.Format(time.DateOnly) + + createPaymentRequest := atlar_models.CreatePaymentRequest{ + SourceAccountID: &pi.SourceAccount.Reference, + DestinationExternalAccountID: &pi.DestinationAccount.Reference, + Amount: &amount, + Date: &dateString, + ExternalID: pi.Reference, + PaymentSchemeType: &paymentSchemeType, + RemittanceInformation: &atlar_models.RemittanceInformation{ + Type: &remittanceInformationType, + Value: &remittanceInformationValue, + }, + } + + _, err = p.client.PostV1CreditTransfers(ctx, &createPaymentRequest) + if err != nil { + return "", err + } + + return pi.Reference, nil +} + +func (p *Plugin) pollPayoutStatus(ctx context.Context, payoutID string) (models.PollPayoutStatusResponse, error) { + resp, err := p.client.GetV1CreditTransfersGetByExternalIDExternalID( + ctx, + payoutID, + ) + if err != nil { + return models.PollPayoutStatusResponse{}, err + } + + status := resp.Payload.Status + // Status docs: https://docs.atlar.com/docs/payment-details#payment-states--events + switch status { + case "CREATED", "APPROVED", "PENDING_SUBMISSION", "SENT", "PENDING_AT_BANK", "ACCEPTED", "EXECUTED": + // By setting both payment and error to nil, the workflow will continue + // polling until the payment status is either RECONCILED or one of the + // terminal states. + return models.PollPayoutStatusResponse{ + Payment: nil, + Error: nil, + }, nil + + case "RECONCILED": + // The payment has been reconciled and the funds have been transferred. + transactionID := resp.Payload.Reconciliation.BookedTransactionID + payment, err := p.getAtlarTransaction(ctx, transactionID) + if err != nil { + return models.PollPayoutStatusResponse{}, fmt.Errorf("failed to get atlar transaction: %w", err) + } + + return models.PollPayoutStatusResponse{ + Payment: payment, + Error: nil, + }, nil + + case "REJECTED", "FAILED", "RETURNED": + return models.PollPayoutStatusResponse{ + Error: fmt.Errorf("payment failed: %s", status), + }, nil + + default: + return models.PollPayoutStatusResponse{}, fmt.Errorf( + "unknown status \"%s\" encountered while fetching payment initiation status of payment \"%s\"", + status, resp.Payload.ID, + ) + } +} + +func (p *Plugin) getAtlarTransaction(ctx context.Context, transactionID string) (*models.PSPPayment, error) { + resp, err := p.client.GetV1TransactionsID(ctx, transactionID) + if err != nil { + return nil, fmt.Errorf("failed to get atlar transaction: %w", err) + } + + payment, err := p.transactionToPayment(ctx, resp.Payload) + if err != nil { + return nil, err + } + + if payment == nil { + return nil, fmt.Errorf("failed to convert transaction to payment, invalid currency") + } + + return payment, nil +} diff --git a/internal/connectors/plugins/public/atlar/payouts_test.go b/internal/connectors/plugins/public/atlar/payouts_test.go new file mode 100644 index 00000000..db119385 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/payouts_test.go @@ -0,0 +1,319 @@ +package atlar + +import ( + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/models" + "github.com/get-momo/atlar-v1-go-client/client/accounts" + "github.com/get-momo/atlar-v1-go-client/client/credit_transfers" + "github.com/get-momo/atlar-v1-go-client/client/third_parties" + "github.com/get-momo/atlar-v1-go-client/client/transactions" + atlar_models "github.com/get-momo/atlar-v1-go-client/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Atlar Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: uuid.New().String(), + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "userID": "u1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - initiate payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().PostV1CreditTransfers(gomock.Any(), &atlar_models.CreatePaymentRequest{ + Amount: &atlar_models.AmountInput{ + Currency: pointer.For("EUR"), + StringValue: "1.00", + Value: 100, + }, + Date: pointer.For(samplePSPPaymentInitiation.CreatedAt.Format(time.DateOnly)), + DestinationExternalAccountID: &samplePSPPaymentInitiation.DestinationAccount.Reference, + ExternalID: samplePSPPaymentInitiation.Reference, + PaymentSchemeType: pointer.For("SCT"), + RemittanceInformation: &atlar_models.RemittanceInformation{ + Type: pointer.For("UNSTRUCTURED"), + Value: &samplePSPPaymentInitiation.Description, + }, + SourceAccountID: &samplePSPPaymentInitiation.SourceAccount.Reference, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().PostV1CreditTransfers(gomock.Any(), &atlar_models.CreatePaymentRequest{ + Amount: &atlar_models.AmountInput{ + Currency: pointer.For("EUR"), + StringValue: "1.00", + Value: 100, + }, + Date: pointer.For(samplePSPPaymentInitiation.CreatedAt.Format(time.DateOnly)), + DestinationExternalAccountID: &samplePSPPaymentInitiation.DestinationAccount.Reference, + ExternalID: samplePSPPaymentInitiation.Reference, + PaymentSchemeType: pointer.For("SCT"), + RemittanceInformation: &atlar_models.RemittanceInformation{ + Type: pointer.For("UNSTRUCTURED"), + Value: &samplePSPPaymentInitiation.Description, + }, + SourceAccountID: &samplePSPPaymentInitiation.SourceAccount.Reference, + }).Return(nil, nil) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + PollingPayoutID: &samplePSPPaymentInitiation.Reference, + })) + }) + }) + + Context("poll payout status", func() { + var ( + m *client.MockClient + payoutID string + creditTransferResponse credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK + now time.Time + sampleAccount atlar_models.Account + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + payoutID = "test" + + creditTransferResponse = credit_transfers.GetV1CreditTransfersGetByExternalIDExternalIDOK{ + Payload: &atlar_models.Payment{ + ID: payoutID, + Reconciliation: &atlar_models.ReconciliationDetails{ + BookedTransactionID: "test-transaction", + }, + Status: "CREATED", + }, + } + + sampleAccount = atlar_models.Account{ + Bank: &atlar_models.BankSlim{ + Bic: "test", + }, + Bic: "test", + ThirdPartyID: "test", + } + }) + + It("should return an error - get credit transfer error", func(ctx SpecContext) { + m.EXPECT().GetV1CreditTransfersGetByExternalIDExternalID(gomock.Any(), "test").Return(nil, errors.New("test error")) + + resp, err := plg.PollPayoutStatus(ctx, models.PollPayoutStatusRequest{ + PayoutID: payoutID, + }) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.PollPayoutStatusResponse{})) + }) + + It("should return nil - payment still pending", func(ctx SpecContext) { + m.EXPECT().GetV1CreditTransfersGetByExternalIDExternalID(gomock.Any(), "test").Return(&creditTransferResponse, nil) + + resp, err := plg.PollPayoutStatus(ctx, models.PollPayoutStatusRequest{ + PayoutID: payoutID, + }) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.PollPayoutStatusResponse{ + Payment: nil, + Error: nil, + })) + }) + + It("should return an error - payment failed", func(ctx SpecContext) { + c := creditTransferResponse + c.Payload.Status = "REJECTED" + m.EXPECT().GetV1CreditTransfersGetByExternalIDExternalID(gomock.Any(), "test").Return(&c, nil) + + resp, err := plg.PollPayoutStatus(ctx, models.PollPayoutStatusRequest{ + PayoutID: payoutID, + }) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.PollPayoutStatusResponse{ + Payment: nil, + Error: errors.New("payment failed: REJECTED"), + })) + }) + + It("should return an error - unknown status", func(ctx SpecContext) { + c := creditTransferResponse + c.Payload.Status = "UNKNOWN" + m.EXPECT().GetV1CreditTransfersGetByExternalIDExternalID(gomock.Any(), "test").Return(&c, nil) + + resp, err := plg.PollPayoutStatus(ctx, models.PollPayoutStatusRequest{ + PayoutID: payoutID, + }) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("unknown status \"UNKNOWN\" encountered while fetching payment initiation status of payment \"test\"")) + Expect(resp).To(Equal(models.PollPayoutStatusResponse{})) + }) + + It("should should be ok", func(ctx SpecContext) { + c := creditTransferResponse + c.Payload.Status = "RECONCILED" + m.EXPECT().GetV1CreditTransfersGetByExternalIDExternalID(gomock.Any(), "test").Return(&c, nil) + + m.EXPECT().GetV1TransactionsID(gomock.Any(), "test-transaction").Return(&transactions.GetV1TransactionsIDOK{ + Payload: &atlar_models.Transaction{ + ID: "test-transaction", + Amount: &atlar_models.Amount{ + Currency: pointer.For("EUR"), + StringValue: pointer.For("100"), + Value: pointer.For(int64(100)), + }, + Account: &atlar_models.AccountTrx{ + ID: pointer.For("test"), + }, + Characteristics: &atlar_models.TransactionCharacteristics{ + BankTransactionCode: &atlar_models.BankTransactionCode{ + Description: "test", + Domain: "test", + Family: "test", + Subfamily: "test", + }, + }, + Created: now.UTC().Format(time.RFC3339Nano), + Description: "test", + Reconciliation: &atlar_models.ReconciliationDetails{ + BookedTransactionID: "test", + ExpectedTransactionID: "test", + Status: "test", + TransactableID: "test", + TransactableType: "test", + }, + RemittanceInformation: &atlar_models.RemittanceInformation{ + Type: pointer.For("test"), + Value: pointer.For("test"), + }, + }, + }, nil) + + m.EXPECT().GetV1AccountsID(gomock.Any(), "test").Return( + &accounts.GetV1AccountsIDOK{ + Payload: &sampleAccount, + }, + nil, + ) + + m.EXPECT().GetV1BetaThirdPartiesID(gomock.Any(), sampleAccount.ThirdPartyID).Return( + &third_parties.GetV1betaThirdPartiesIDOK{ + Payload: &atlar_models.ThirdParty{ + ID: "test", + Name: "test", + }, + }, + nil, + ) + + resp, err := plg.PollPayoutStatus(ctx, models.PollPayoutStatusRequest{ + PayoutID: payoutID, + }) + Expect(err).To(BeNil()) + Expect(resp.Payment).ToNot(BeNil()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/plugin.go b/internal/connectors/plugins/public/atlar/plugin.go new file mode 100644 index 00000000..c6dee99e --- /dev/null +++ b/internal/connectors/plugins/public/atlar/plugin.go @@ -0,0 +1,143 @@ +package atlar + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/atlar/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("atlar", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + baseUrl, err := url.Parse(config.BaseURL) + if err != nil { + return nil, err + } + + return &Plugin{ + name: name, + client: client.New(baseUrl, config.AccessKey, config.Secret), + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + if p.client == nil { + return models.CreateBankAccountResponse{}, plugins.ErrNotYetInstalled + } + return p.createBankAccount(ctx, req.BankAccount) +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + return models.CreateTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + + payoutID, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + + return models.CreatePayoutResponse{ + PollingPayoutID: &payoutID, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + if p.client == nil { + return models.PollPayoutStatusResponse{}, plugins.ErrNotYetInstalled + } + + return p.pollPayoutStatus(ctx, req.PayoutID) +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/atlar/plugin_test.go b/internal/connectors/plugins/public/atlar/plugin_test.go new file mode 100644 index 00000000..29375285 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/plugin_test.go @@ -0,0 +1,190 @@ +package atlar + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Atlar Plugin Suite") +} + +var _ = Describe("Atlar Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - baseURL", func(ctx SpecContext) { + config := json.RawMessage(`{"accessKey": "test", "secret": "test"}`) + _, err := New("atlar", config) + Expect(err).To(MatchError("missing baseURL in config: invalid config")) + }) + + It("should report errors in config - accessKey", func(ctx SpecContext) { + config := json.RawMessage(`{"baseURL": "test", "secret": "test"}`) + _, err := New("atlar", config) + Expect(err).To(MatchError("missing access key in config: invalid config")) + }) + + It("should report errors in config - secret", func(ctx SpecContext) { + config := json.RawMessage(`{"baseURL": "test", "accessKey": "test"}`) + _, err := New("atlar", config) + Expect(err).To(MatchError("missing secret in config: invalid config")) + }) + + It("should return valid install response", func(ctx SpecContext) { + config := json.RawMessage(`{"baseURL": "http://localhost:8080/", "accessKey": "test", "secret": "test"}`) + _, err := New("atlar", config) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in external_accounts_test.go + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create bank account", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in bank_accounts_test.go + }) + + Context("create transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payouts_test.go + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) + + Context("create webhooks", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("translate webhook", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/atlar/utils.go b/internal/connectors/plugins/public/atlar/utils.go new file mode 100644 index 00000000..8f1adcd5 --- /dev/null +++ b/internal/connectors/plugins/public/atlar/utils.go @@ -0,0 +1,38 @@ +package atlar + +import ( + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/models" +) + +func ParseAtlarTimestamp(value string) (time.Time, error) { + return time.Parse(time.RFC3339Nano, value) +} + +func validateTransferPayoutRequest(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} + +func amountToString(amount big.Int, precision int) string { + raw := amount.String() + if precision < 0 { + precision = 0 + } + insertPosition := len(raw) - precision + if insertPosition <= 0 { + return "0." + strings.Repeat("0", -insertPosition) + raw + } + return raw[:insertPosition] + "." + raw[insertPosition:] +} diff --git a/internal/connectors/plugins/public/atlar/workflow.go b/internal/connectors/plugins/public/atlar/workflow.go new file mode 100644 index 00000000..378df23a --- /dev/null +++ b/internal/connectors/plugins/public/atlar/workflow.go @@ -0,0 +1,34 @@ +package atlar + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + NextTasks: []models.ConnectorTaskTree{}, + }, + + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_external_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/bankingcircle/accounts.go b/internal/connectors/plugins/public/bankingcircle/accounts.go new file mode 100644 index 00000000..931c65a7 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/accounts.go @@ -0,0 +1,128 @@ +package bankingcircle + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type accountsState struct { + LastAccountID string `json:"lastAccountID"` + FromOpeningDate time.Time `json:"fromOpeningDate"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + newState := accountsState{ + LastAccountID: oldState.LastAccountID, + FromOpeningDate: oldState.FromOpeningDate, + } + + var accounts []models.PSPAccount + needMore := false + hasMore := false + for page := 1; ; page++ { + pagedAccounts, err := p.client.GetAccounts(ctx, page, req.PageSize, oldState.FromOpeningDate) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + filteredAccounts := filterAccounts(pagedAccounts, oldState.LastAccountID) + accounts, err = fillAccounts(filteredAccounts, accounts) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastAccountID = accounts[len(accounts)-1].Reference + newState.FromOpeningDate = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillAccounts( + pagedAccounts []client.Account, + accounts []models.PSPAccount, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + openingDate, err := time.Parse("2006-01-02T15:04:05.999999999+00:00", account.OpeningDate) + if err != nil { + return nil, fmt.Errorf("failed to parse opening date: %w", err) + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, fmt.Errorf("failed to marshal account: %w", err) + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.AccountID, + CreatedAt: openingDate, + Name: &account.AccountDescription, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency)), + Raw: raw, + }) + } + + return accounts, nil +} + +func filterAccounts(pagedAccounts []client.Account, lastAccountID string) []client.Account { + if lastAccountID == "" { + return pagedAccounts + } + + var filteredAccounts []client.Account + found := false + for _, account := range pagedAccounts { + if !found && account.AccountID != lastAccountID { + continue + } + + if !found && account.AccountID == lastAccountID { + found = true + continue + } + + filteredAccounts = append(filteredAccounts, account) + } + + if !found { + return pagedAccounts + } + + return filteredAccounts +} diff --git a/internal/connectors/plugins/public/bankingcircle/accounts_test.go b/internal/connectors/plugins/public/bankingcircle/accounts_test.go new file mode 100644 index 00000000..12617138 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/accounts_test.go @@ -0,0 +1,170 @@ +package bankingcircle + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("BankingCircle Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []client.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]client.Account, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, client.Account{ + AccountID: fmt.Sprint(i), + AccountDescription: fmt.Sprintf("Account %d", i), + Currency: "EUR", + OpeningDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format("2006-01-02T15:04:05.999999999+00:00"), + }) + } + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 60, time.Time{}).Return( + []client.Account{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 60, time.Time{}).Return( + []client.Account{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastAccountID).To(BeEmpty()) + Expect(state.FromOpeningDate.IsZero()).To(BeTrue()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 60, time.Time{}).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastAccountID).To(Equal("49")) + Expect(state.FromOpeningDate).To(Equal(now.Add(-time.Duration(1) * time.Minute).UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 40, time.Time{}).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastAccountID).To(Equal("39")) + Expect(state.FromOpeningDate).To(Equal(now.Add(-time.Duration(11) * time.Minute).UTC())) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastAccountID": "%d", "fromOpeningDate": "%s"}`, 38, now.Add(-time.Duration(12)*time.Minute).UTC().Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 40, now.Add(-time.Duration(12)*time.Minute).UTC()).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().GetAccounts(gomock.Any(), 2, 40, now.Add(-time.Duration(12)*time.Minute).UTC()).Return( + sampleAccounts[40:], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastAccountID).To(Equal("49")) + Expect(state.FromOpeningDate).To(Equal(now.Add(-time.Duration(1) * time.Minute).UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/balances.go b/internal/connectors/plugins/public/bankingcircle/balances.go new file mode 100644 index 00000000..429500b6 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/balances.go @@ -0,0 +1,59 @@ +package bankingcircle + +import ( + "context" + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + account, err := p.client.GetAccount(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + var balances []models.PSPBalance + for _, balance := range account.Balances { + // Note(polo): the last transaction timestamp is wrong in the banking + // circle response. We will use the current time instead. + lastTransactionTimestamp := time.Now().UTC() + + precision := supportedCurrenciesWithDecimal[balance.Currency] + + beginOfDayAmount, err := currency.GetAmountWithPrecisionFromString(balance.BeginOfDayAmount.String(), precision) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + intraDayAmount, err := currency.GetAmountWithPrecisionFromString(balance.IntraDayAmount.String(), precision) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + amount := big.NewInt(0).Add(beginOfDayAmount, intraDayAmount) + + balances = append(balances, models.PSPBalance{ + AccountReference: from.Reference, + CreatedAt: lastTransactionTimestamp, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Currency), + }) + } + + return models.FetchNextBalancesResponse{ + Balances: balances, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/balances_test.go b/internal/connectors/plugins/public/bankingcircle/balances_test.go new file mode 100644 index 00000000..0c6d97ec --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/balances_test.go @@ -0,0 +1,123 @@ +package bankingcircle + +import ( + "errors" + + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("BankingCircle Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next balances", func() { + var ( + m *client.MockClient + sampleBalances []client.Balance + sampleAccount *client.Account + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + sampleBalances = []client.Balance{ + { + Currency: "EUR", + BeginOfDayAmount: "100", + IntraDayAmount: "150", + }, + { + Currency: "USD", + BeginOfDayAmount: "100", + IntraDayAmount: "-100", + }, + } + + sampleAccount = &client.Account{ + Balances: sampleBalances, + } + }) + + It("should return an error - get balances error", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "123"}`), + } + + m.EXPECT().GetAccount(gomock.Any(), "123").Return( + sampleAccount, + errors.New("test error"), + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should fetch next balances - no state no results", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "123"}`), + } + + m.EXPECT().GetAccount(gomock.Any(), "123").Return( + &client.Account{}, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + }) + + It("should fetch all balances - page size > sample balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "123"}`), + } + + m.EXPECT().GetAccount(gomock.Any(), "123").Return( + sampleAccount, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(2)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + }) + + It("should fetch all balances - page size < sample balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 1, + FromPayload: []byte(`{"reference": "123"}`), + } + + m.EXPECT().GetAccount(gomock.Any(), "123").Return( + sampleAccount, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(2)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/bank_account_creation.go b/internal/connectors/plugins/public/bankingcircle/bank_account_creation.go new file mode 100644 index 00000000..a2c31304 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/bank_account_creation.go @@ -0,0 +1,27 @@ +package bankingcircle + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createBankAccount(req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + // We can't create bank accounts in Banking Circle since they do not store + // the bank account information. We just have to return the related formance + // account in order to use it in the future. + raw, err := json.Marshal(req.BankAccount) + if err != nil { + return models.CreateBankAccountResponse{}, err + } + + return models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: req.BankAccount.ID.String(), + CreatedAt: req.BankAccount.CreatedAt, + Name: &req.BankAccount.Name, + Metadata: req.BankAccount.Metadata, + Raw: raw, + }, + }, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/bank_account_creation_test.go b/internal/connectors/plugins/public/bankingcircle/bank_account_creation_test.go new file mode 100644 index 00000000..2d8b558e --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/bank_account_creation_test.go @@ -0,0 +1,69 @@ +package bankingcircle + +import ( + "encoding/json" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("BankingCircle Plugin Bank Account Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleBankAccount models.BankAccount + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBankAccount = models.BankAccount{ + ID: uuid.New(), + CreatedAt: now.UTC(), + Name: "test", + AccountNumber: pointer.For("123456789"), + Country: pointer.For("US"), + Metadata: map[string]string{ + "test": "test", + }, + } + }) + + It("should create bank account", func(ctx SpecContext) { + resp, err := plg.CreateBankAccount(ctx, models.CreateBankAccountRequest{ + BankAccount: sampleBankAccount, + }) + + raw, _ := json.Marshal(sampleBankAccount) + + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: sampleBankAccount.ID.String(), + CreatedAt: now.UTC(), + Name: pointer.For("test"), + Metadata: sampleBankAccount.Metadata, + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/capabilities.go b/internal/connectors/plugins/public/bankingcircle/capabilities.go new file mode 100644 index 00000000..211f56ed --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/capabilities.go @@ -0,0 +1,13 @@ +package bankingcircle + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + models.CAPABILITY_FETCH_BALANCES, + + models.CAPABILITY_CREATE_BANK_ACCOUNT, + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, +} diff --git a/internal/connectors/plugins/public/bankingcircle/client/accounts.go b/internal/connectors/plugins/public/bankingcircle/client/accounts.go new file mode 100644 index 00000000..a784b412 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/client/accounts.go @@ -0,0 +1,97 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Balance struct { + Type string `json:"type"` + Currency string `json:"currency"` + BeginOfDayAmount json.Number `json:"beginOfDayAmount"` + FinancialDate string `json:"financialDate"` + IntraDayAmount json.Number `json:"intraDayAmount"` + LastTransactionTimestamp string `json:"lastTransactionTimestamp"` +} + +type AccountIdentifier struct { + Account string `json:"account"` + FinancialInstitution string `json:"financialInstitution"` + Country string `json:"country"` +} + +type Account struct { + AccountID string `json:"accountId"` + AccountDescription string `json:"accountDescription"` + AccountIdentifiers []AccountIdentifier `json:"accountIdentifiers"` + Status string `json:"status"` + Currency string `json:"currency"` + OpeningDate string `json:"openingDate"` + ClosingDate string `json:"closingDate"` + OwnedByCompanyID string `json:"ownedByCompanyId"` + ProtectionType string `json:"protectionType"` + Balances []Balance `json:"balances"` +} + +func (c *client) GetAccounts(ctx context.Context, page int, pageSize int, fromOpeningDate time.Time) ([]Account, error) { + if err := c.ensureAccessTokenIsValid(ctx); err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_accounts") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+"/api/v1/accounts", http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create account request: %w", err) + } + + q := req.URL.Query() + q.Add("PageSize", fmt.Sprint(pageSize)) + q.Add("PageNumber", fmt.Sprint(page)) + if !fromOpeningDate.IsZero() { + q.Add("OpeningDateFrom", fromOpeningDate.Format(time.DateOnly)) + } + req.URL.RawQuery = q.Encode() + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + type response struct { + Result []Account `json:"result"` + PageInfo struct { + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + } `json:"pageInfo"` + } + + res := response{Result: make([]Account, 0)} + statusCode, err := c.httpClient.Do(ctx, req, &res, nil) + if err != nil { + return nil, fmt.Errorf("failed to get accounts, status code %d: %w", statusCode, err) + } + return res.Result, nil +} + +func (c *client) GetAccount(ctx context.Context, accountID string) (*Account, error) { + if err := c.ensureAccessTokenIsValid(ctx); err != nil { + return nil, err + } + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_account") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/accounts/%s", c.endpoint, accountID), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create account request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + var account Account + statusCode, err := c.httpClient.Do(ctx, req, &account, nil) + if err != nil { + return nil, fmt.Errorf("failed to get account, status code %d: %w", statusCode, err) + } + return &account, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/client/auth.go b/internal/connectors/plugins/public/bankingcircle/client/auth.go new file mode 100644 index 00000000..58987fec --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/client/auth.go @@ -0,0 +1,64 @@ +package client + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +func (c *client) login(ctx context.Context) error { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "authenticate") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.authorizationEndpoint+"/api/v1/authorizations/authorize", http.NoBody) + if err != nil { + return fmt.Errorf("failed to create login request: %w", err) + } + + req.SetBasicAuth(c.username, c.password) + + //nolint:tagliatelle // allow for client-side structures + type response struct { + AccessToken string `json:"access_token"` + ExpiresIn string `json:"expires_in"` + } + type responseError struct { + ErrorCode string `json:"errorCode"` + ErrorText string `json:"errorText"` + } + + var res response + var errors []responseError + statusCode, err := c.httpClient.Do(ctx, req, &res, &errors) + if err != nil { + if len(errors) > 0 { + log.Printf("bankingcircle auth failed with code %s: %s", errors[0].ErrorCode, errors[0].ErrorText) + } + return fmt.Errorf("failed to login, status code %d: %w", statusCode, err) + } + + c.accessToken = res.AccessToken + expiresIn, err := strconv.Atoi(res.ExpiresIn) + if err != nil { + return fmt.Errorf("failed to convert expires_in to int: %w", err) + } + c.accessTokenExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + return nil +} + +func (c *client) ensureAccessTokenIsValid(ctx context.Context) error { + if c.accessToken == "" { + return c.login(ctx) + } + + if c.accessTokenExpiresAt.After(time.Now().Add(5 * time.Second)) { + return nil + } + + return c.login(ctx) +} diff --git a/internal/connectors/plugins/public/bankingcircle/client/client.go b/internal/connectors/plugins/public/bankingcircle/client/client.go new file mode 100644 index 00000000..9eaa4abf --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/client/client.go @@ -0,0 +1,67 @@ +package client + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/models" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetAccounts(ctx context.Context, page int, pageSize int, fromOpeningDate time.Time) ([]Account, error) + GetAccount(ctx context.Context, accountID string) (*Account, error) + GetPayments(ctx context.Context, page int, pageSize int) ([]Payment, error) + GetPayment(ctx context.Context, paymentID string) (*Payment, error) + GetPaymentStatus(ctx context.Context, paymentID string) (*StatusResponse, error) + InitiateTransferOrPayouts(ctx context.Context, transferRequest *PaymentRequest) (*PaymentResponse, error) +} + +type client struct { + httpClient httpwrapper.Client + + username string + password string + + endpoint string + authorizationEndpoint string + + accessToken string + accessTokenExpiresAt time.Time +} + +func New( + username, password, + endpoint, authorizationEndpoint, + uCertificate, uCertificateKey string, +) (Client, error) { + cert, err := tls.X509KeyPair([]byte(uCertificate), []byte(uCertificateKey)) + if err != nil { + return nil, fmt.Errorf("failed to load user certificate: %w: %w", err, models.ErrInvalidConfig) + } + + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("bankingcircle"), + Transport: tr, + } + + c := &client{ + httpClient: httpwrapper.NewClient(config), + + username: username, + password: password, + endpoint: endpoint, + authorizationEndpoint: authorizationEndpoint, + } + + return c, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/client/client_generated.go b/internal/connectors/plugins/public/bankingcircle/client/client_generated.go new file mode 100644 index 00000000..c800120a --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/client/client_generated.go @@ -0,0 +1,132 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetAccount mocks base method. +func (m *MockClient) GetAccount(ctx context.Context, accountID string) (*Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) + ret0, _ := ret[0].(*Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockClientMockRecorder) GetAccount(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockClient)(nil).GetAccount), ctx, accountID) +} + +// GetAccounts mocks base method. +func (m *MockClient) GetAccounts(ctx context.Context, page, pageSize int, fromOpeningDate time.Time) ([]Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccounts", ctx, page, pageSize, fromOpeningDate) + ret0, _ := ret[0].([]Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccounts indicates an expected call of GetAccounts. +func (mr *MockClientMockRecorder) GetAccounts(ctx, page, pageSize, fromOpeningDate any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccounts", reflect.TypeOf((*MockClient)(nil).GetAccounts), ctx, page, pageSize, fromOpeningDate) +} + +// GetPayment mocks base method. +func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayment", ctx, paymentID) + ret0, _ := ret[0].(*Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayment indicates an expected call of GetPayment. +func (mr *MockClientMockRecorder) GetPayment(ctx, paymentID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayment", reflect.TypeOf((*MockClient)(nil).GetPayment), ctx, paymentID) +} + +// GetPaymentStatus mocks base method. +func (m *MockClient) GetPaymentStatus(ctx context.Context, paymentID string) (*StatusResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPaymentStatus", ctx, paymentID) + ret0, _ := ret[0].(*StatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPaymentStatus indicates an expected call of GetPaymentStatus. +func (mr *MockClientMockRecorder) GetPaymentStatus(ctx, paymentID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPaymentStatus", reflect.TypeOf((*MockClient)(nil).GetPaymentStatus), ctx, paymentID) +} + +// GetPayments mocks base method. +func (m *MockClient) GetPayments(ctx context.Context, page, pageSize int) ([]Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayments", ctx, page, pageSize) + ret0, _ := ret[0].([]Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayments indicates an expected call of GetPayments. +func (mr *MockClientMockRecorder) GetPayments(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayments", reflect.TypeOf((*MockClient)(nil).GetPayments), ctx, page, pageSize) +} + +// InitiateTransferOrPayouts mocks base method. +func (m *MockClient) InitiateTransferOrPayouts(ctx context.Context, transferRequest *PaymentRequest) (*PaymentResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiateTransferOrPayouts", ctx, transferRequest) + ret0, _ := ret[0].(*PaymentResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiateTransferOrPayouts indicates an expected call of InitiateTransferOrPayouts. +func (mr *MockClientMockRecorder) InitiateTransferOrPayouts(ctx, transferRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateTransferOrPayouts", reflect.TypeOf((*MockClient)(nil).InitiateTransferOrPayouts), ctx, transferRequest) +} diff --git a/internal/connectors/plugins/public/bankingcircle/client/payments.go b/internal/connectors/plugins/public/bankingcircle/client/payments.go new file mode 100644 index 00000000..35c64de3 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/client/payments.go @@ -0,0 +1,170 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type DebtorInformation struct { + PaymentBulkID interface{} `json:"paymentBulkId"` + AccountID string `json:"accountId"` + Account struct { + Account string `json:"account"` + FinancialInstitution string `json:"financialInstitution"` + Country string `json:"country"` + } `json:"account"` + VibanID interface{} `json:"vibanId"` + Viban struct { + Account string `json:"account"` + FinancialInstitution string `json:"financialInstitution"` + Country string `json:"country"` + } `json:"viban"` + InstructedDate interface{} `json:"instructedDate"` + DebitAmount struct { + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + } `json:"debitAmount"` + DebitValueDate time.Time `json:"debitValueDate"` + FxRate interface{} `json:"fxRate"` + Instruction interface{} `json:"instruction"` +} + +type CreditorInformation struct { + AccountID string `json:"accountId"` + Account struct { + Account string `json:"account"` + FinancialInstitution string `json:"financialInstitution"` + Country string `json:"country"` + } `json:"account"` + VibanID interface{} `json:"vibanId"` + Viban struct { + Account string `json:"account"` + FinancialInstitution string `json:"financialInstitution"` + Country string `json:"country"` + } `json:"viban"` + CreditAmount struct { + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + } `json:"creditAmount"` + CreditValueDate time.Time `json:"creditValueDate"` + FxRate interface{} `json:"fxRate"` +} + +type Amount struct { + Currency string `json:"currency"` + Amount json.Number `json:"amount"` +} + +type Transfer struct { + DebtorAccount interface{} `json:"debtorAccount"` + DebtorName interface{} `json:"debtorName"` + DebtorAddress interface{} `json:"debtorAddress"` + Amount Amount `json:"amount"` + ValueDate interface{} `json:"valueDate"` + ChargeBearer interface{} `json:"chargeBearer"` + RemittanceInformation interface{} `json:"remittanceInformation"` + CreditorAccount interface{} `json:"creditorAccount"` + CreditorName interface{} `json:"creditorName"` + CreditorAddress interface{} `json:"creditorAddress"` +} + +//nolint:tagliatelle // allow for client-side structures +type Payment struct { + PaymentID string `json:"paymentId"` + TransactionReference string `json:"transactionReference"` + ConcurrencyToken string `json:"concurrencyToken"` + Classification string `json:"classification"` + Status string `json:"status"` + Errors interface{} `json:"errors"` + ProcessedTimestamp time.Time `json:"processedTimestamp"` + LatestStatusChangedTimestamp time.Time `json:"latestStatusChangedTimestamp"` + LastChangedTimestamp time.Time `json:"lastChangedTimestamp"` + DebtorInformation DebtorInformation `json:"debtorInformation"` + Transfer Transfer `json:"transfer"` + CreditorInformation CreditorInformation `json:"creditorInformation"` +} + +func (c *client) GetPayments(ctx context.Context, page int, pageSize int) ([]Payment, error) { + if err := c.ensureAccessTokenIsValid(ctx); err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_payments") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+"/api/v1/payments/singles", http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create payments request: %w", err) + } + + q := req.URL.Query() + q.Add("PageSize", fmt.Sprint(pageSize)) + q.Add("PageNumber", fmt.Sprint(page)) + req.URL.RawQuery = q.Encode() + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + type response struct { + Result []Payment `json:"result"` + PageInfo struct { + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + } `json:"pageInfo"` + } + + res := response{Result: make([]Payment, 0)} + statusCode, err := c.httpClient.Do(ctx, req, &res, nil) + if err != nil { + return nil, fmt.Errorf("failed to get payments, status code %d: %w", statusCode, err) + } + return res.Result, nil +} + +func (c *client) GetPayment(ctx context.Context, paymentID string) (*Payment, error) { + if err := c.ensureAccessTokenIsValid(ctx); err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_payment") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/payments/singles/%s", c.endpoint, paymentID), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create payments request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + var res Payment + statusCode, err := c.httpClient.Do(ctx, req, &res, nil) + if err != nil { + return nil, fmt.Errorf("failed to get payment, status code %d: %w", statusCode, err) + } + return &res, nil +} + +type StatusResponse struct { + Status string `json:"status"` +} + +func (c *client) GetPaymentStatus(ctx context.Context, paymentID string) (*StatusResponse, error) { + if err := c.ensureAccessTokenIsValid(ctx); err != nil { + return nil, err + } + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_payment_status") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/payments/singles/%s/status", c.endpoint, paymentID), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create payments request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + var res StatusResponse + statusCode, err := c.httpClient.Do(ctx, req, &res, nil) + if err != nil { + return nil, fmt.Errorf("failed to get payment status, status code %d: %w", statusCode, err) + } + return &res, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go b/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go new file mode 100644 index 00000000..4afb7847 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go @@ -0,0 +1,61 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type PaymentAccount struct { + Account string `json:"account"` + FinancialInstitution string `json:"financialInstitution"` + Country string `json:"country"` +} + +type PaymentRequest struct { + IdempotencyKey string `json:"idempotencyKey"` + RequestedExecutionDate time.Time `json:"requestedExecutionDate"` + DebtorAccount PaymentAccount `json:"debtorAccount"` + DebtorReference string `json:"debtorReference"` + CurrencyOfTransfer string `json:"currencyOfTransfer"` + Amount Amount `json:"amount"` + ChargeBearer string `json:"chargeBearer"` + CreditorAccount *PaymentAccount `json:"creditorAccount"` +} + +type PaymentResponse struct { + PaymentID string `json:"paymentId"` + Status string `json:"status"` +} + +func (c *client) InitiateTransferOrPayouts(ctx context.Context, transferRequest *PaymentRequest) (*PaymentResponse, error) { + if err := c.ensureAccessTokenIsValid(ctx); err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_transfers_payouts") + + body, err := json.Marshal(transferRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal transfer request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/api/v1/payments/singles", bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create payments request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + var res PaymentResponse + statusCode, err := c.httpClient.Do(ctx, req, &res, nil) + if err != nil { + return nil, fmt.Errorf("failed to make payout, status code %d: %w", statusCode, err) + } + return &res, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/config.go b/internal/connectors/plugins/public/bankingcircle/config.go new file mode 100644 index 00000000..ebdecef9 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/config.go @@ -0,0 +1,55 @@ +package bankingcircle + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + Username string `json:"username" yaml:"username" ` + Password string `json:"password" yaml:"password" ` + Endpoint string `json:"endpoint" yaml:"endpoint"` + AuthorizationEndpoint string `json:"authorizationEndpoint" yaml:"authorizationEndpoint" ` + UserCertificate string `json:"userCertificate" yaml:"userCertificate" ` + UserCertificateKey string `json:"userCertificateKey" yaml:"userCertificateKey"` +} + +func (c Config) validate() error { + if c.Username == "" { + return fmt.Errorf("missing username in config: %w", models.ErrInvalidConfig) + } + + if c.Password == "" { + return fmt.Errorf("missing password in config: %w", models.ErrInvalidConfig) + } + + if c.Endpoint == "" { + return fmt.Errorf("missing endpoint in config: %w", models.ErrInvalidConfig) + } + + if c.AuthorizationEndpoint == "" { + return fmt.Errorf("missing authorization endpoint in config: %w", models.ErrInvalidConfig) + } + + if c.UserCertificate == "" { + return fmt.Errorf("missing user certificate in config: %w", models.ErrInvalidConfig) + } + + if c.UserCertificateKey == "" { + return fmt.Errorf("missing user certificate key in config: %w", models.ErrInvalidConfig) + } + + return nil +} + +func unmarshalAndValidateConfig(payload []byte) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/bankingcircle/config.json b/internal/connectors/plugins/public/bankingcircle/config.json new file mode 100644 index 00000000..8dbaa540 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/config.json @@ -0,0 +1,32 @@ +{ + "username": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "password": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "endpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "authorizationEndpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "userCertificate": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "userCertificateKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/bankingcircle/currencies.go b/internal/connectors/plugins/public/bankingcircle/currencies.go new file mode 100644 index 00000000..c0a19966 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/currencies.go @@ -0,0 +1,38 @@ +package bankingcircle + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + // All supported BankingCircle currencies and decimal are on par with + // ISO4217Currencies. + supportedCurrenciesWithDecimal = map[string]int{ + "AED": currency.ISO4217Currencies["AED"], // UAE Dirham + "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar + "CAD": currency.ISO4217Currencies["CAD"], // Canadian Dollar + "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc + "CNY": currency.ISO4217Currencies["CNY"], // China Yuan Renminbi + "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna + "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling + "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar + "ILS": currency.ISO4217Currencies["ILS"], // Israeli Shekel + "JPY": currency.ISO4217Currencies["JPY"], // Japanese Yen + "MXN": currency.ISO4217Currencies["MXN"], // Mexican Peso + "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone + "NZD": currency.ISO4217Currencies["NZD"], // New Zealand Dollar + "PLN": currency.ISO4217Currencies["PLN"], // Polish Zloty + "RON": currency.ISO4217Currencies["RON"], // Romanian Leu + "SAR": currency.ISO4217Currencies["SAR"], // Saudi Riyal + "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona + "SGD": currency.ISO4217Currencies["SGD"], // Singapore Dollar + "TRY": currency.ISO4217Currencies["TRY"], // Turkish Lira + "USD": currency.ISO4217Currencies["USD"], // US Dollar + "ZAR": currency.ISO4217Currencies["ZAR"], // South African Rand + + // Unsupported currencies + // Since we're not sure about decimals for these currencies, we prefer + // to not support them for now. + // "HUF": 2, // Hungarian Forint + } +) diff --git a/internal/connectors/plugins/public/bankingcircle/payments.go b/internal/connectors/plugins/public/bankingcircle/payments.go new file mode 100644 index 00000000..2a9d0593 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/payments.go @@ -0,0 +1,165 @@ +package bankingcircle + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type paymentsState struct { + LatestStatusChangedTimestamp time.Time `json:"latestStatusChangedTimestamp"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + newState := paymentsState{ + LatestStatusChangedTimestamp: oldState.LatestStatusChangedTimestamp, + } + + var payments []models.PSPPayment + var latestStatusChangedTimestamps []time.Time + needMore := false + hasMore := false + for page := 1; ; page++ { + pagedPayments, err := p.client.GetPayments(ctx, page, req.PageSize) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, latestStatusChangedTimestamps, err = fillPayments(pagedPayments, payments, latestStatusChangedTimestamps, oldState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedPayments, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + payments = payments[:req.PageSize] + } + + if len(payments) > 0 { + newState.LatestStatusChangedTimestamp = latestStatusChangedTimestamps[len(latestStatusChangedTimestamps)-1] + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillPayments( + pagedPayments []client.Payment, + payments []models.PSPPayment, + latestStatusChangedTimestamps []time.Time, + oldState paymentsState, +) ([]models.PSPPayment, []time.Time, error) { + for _, payment := range pagedPayments { + switch payment.LatestStatusChangedTimestamp.Compare(oldState.LatestStatusChangedTimestamp) { + case -1, 0: + continue + default: + } + + p, err := translatePayment(payment) + if err != nil { + return nil, nil, err + } + + if p != nil { + payments = append(payments, *p) + latestStatusChangedTimestamps = append(latestStatusChangedTimestamps, payment.LatestStatusChangedTimestamp) + } + } + + return payments, latestStatusChangedTimestamps, nil +} + +func translatePayment(from client.Payment) (*models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return nil, err + } + + paymentType := matchPaymentType(from.Classification) + + precision, ok := supportedCurrenciesWithDecimal[from.Transfer.Amount.Currency] + if !ok { + return nil, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.Transfer.Amount.Amount.String(), precision) + if err != nil { + return nil, err + } + + payment := models.PSPPayment{ + Reference: from.PaymentID, + CreatedAt: from.ProcessedTimestamp, + Type: paymentType, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Transfer.Amount.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchPaymentStatus(from.Status), + Raw: raw, + } + + if from.DebtorInformation.AccountID != "" { + payment.SourceAccountReference = &from.DebtorInformation.AccountID + } + + if from.CreditorInformation.AccountID != "" { + payment.DestinationAccountReference = &from.CreditorInformation.AccountID + } + + return &payment, nil +} + +func matchPaymentStatus(paymentStatus string) models.PaymentStatus { + switch paymentStatus { + case "Processed": + return models.PAYMENT_STATUS_SUCCEEDED + // On MissingFunding - the payment is still in progress. + // If there will be funds available within 10 days - the payment will be processed. + // Otherwise - it will be cancelled. + case "PendingProcessing", "MissingFunding": + return models.PAYMENT_STATUS_PENDING + case "Rejected", "Cancelled", "Reversed", "Returned": + return models.PAYMENT_STATUS_FAILED + } + + return models.PAYMENT_STATUS_OTHER +} + +func matchPaymentType(paymentType string) models.PaymentType { + switch paymentType { + case "Incoming": + return models.PAYMENT_TYPE_PAYIN + case "Outgoing": + return models.PAYMENT_TYPE_PAYOUT + case "Own": + return models.PAYMENT_TYPE_TRANSFER + } + + return models.PAYMENT_TYPE_OTHER +} diff --git a/internal/connectors/plugins/public/bankingcircle/payments_test.go b/internal/connectors/plugins/public/bankingcircle/payments_test.go new file mode 100644 index 00000000..fe95744d --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/payments_test.go @@ -0,0 +1,181 @@ +package bankingcircle + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("BankingCircle Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + samplePayments []client.Payment + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePayments = make([]client.Payment, 0) + for i := 0; i < 50; i++ { + samplePayments = append(samplePayments, client.Payment{ + PaymentID: fmt.Sprint(i), + TransactionReference: fmt.Sprintf("transaction-%d", i), + ConcurrencyToken: "", + Classification: "", + Status: "Processed", + Errors: nil, + ProcessedTimestamp: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + LatestStatusChangedTimestamp: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + DebtorInformation: client.DebtorInformation{ + AccountID: "123", + }, + Transfer: client.Transfer{ + Amount: client.Amount{ + Currency: "EUR", + Amount: "120", + }, + }, + CreditorInformation: client.CreditorInformation{ + AccountID: "321", + }, + }) + } + }) + + It("should return an error - get payments error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetPayments(gomock.Any(), 1, 60).Return( + []client.Payment{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetPayments(gomock.Any(), 1, 60).Return( + []client.Payment{}, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LatestStatusChangedTimestamp.IsZero()).To(BeTrue()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetPayments(gomock.Any(), 1, 60).Return( + samplePayments, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LatestStatusChangedTimestamp.UTC()).To(Equal(samplePayments[49].LatestStatusChangedTimestamp.UTC())) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetPayments(gomock.Any(), 1, 40).Return( + samplePayments[:40], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LatestStatusChangedTimestamp.UTC()).To(Equal(samplePayments[39].LatestStatusChangedTimestamp.UTC())) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(fmt.Sprintf(`{"latestStatusChangedTimestamp": "%s"}`, samplePayments[38].LatestStatusChangedTimestamp.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetPayments(gomock.Any(), 1, 40).Return( + samplePayments[:40], + nil, + ) + + m.EXPECT().GetPayments(gomock.Any(), 2, 40).Return( + samplePayments[40:], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LatestStatusChangedTimestamp.UTC()).To(Equal(samplePayments[49].LatestStatusChangedTimestamp.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/payouts.go b/internal/connectors/plugins/public/bankingcircle/payouts.go new file mode 100644 index 00000000..ac7fa80b --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/payouts.go @@ -0,0 +1,94 @@ +package bankingcircle + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validatePayoutRequest(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (*models.PSPPayment, error) { + if err := p.validatePayoutRequest(pi); err != nil { + return nil, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return nil, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return nil, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + var sourceAccount *client.Account + sourceAccount, err = p.client.GetAccount(ctx, pi.SourceAccount.Reference) + if err != nil { + return nil, fmt.Errorf("failed to get source account: %v: %w", err, models.ErrInvalidRequest) + } + if len(sourceAccount.AccountIdentifiers) == 0 { + return nil, fmt.Errorf("no account identifiers provided for source account: %w", models.ErrInvalidRequest) + } + + var destinationAccount *client.Account + destinationAccount, err = p.client.GetAccount(ctx, pi.DestinationAccount.Reference) + if err != nil { + return nil, fmt.Errorf("failed to get destination account: %v: %w", err, models.ErrInvalidRequest) + } + if len(destinationAccount.AccountIdentifiers) == 0 { + return nil, fmt.Errorf("no account identifiers provided for destination account: %w", models.ErrInvalidRequest) + } + + resp, err := p.client.InitiateTransferOrPayouts(ctx, &client.PaymentRequest{ + IdempotencyKey: pi.Reference, + RequestedExecutionDate: pi.CreatedAt, + DebtorAccount: client.PaymentAccount{ + Account: sourceAccount.AccountIdentifiers[0].Account, + FinancialInstitution: sourceAccount.AccountIdentifiers[0].FinancialInstitution, + Country: sourceAccount.AccountIdentifiers[0].Country, + }, + DebtorReference: pi.Description, + CurrencyOfTransfer: curr, + Amount: client.Amount{ + Currency: curr, + Amount: json.Number(amount), + }, + ChargeBearer: "SHA", + CreditorAccount: &client.PaymentAccount{ + Account: destinationAccount.AccountIdentifiers[0].Account, + FinancialInstitution: destinationAccount.AccountIdentifiers[0].FinancialInstitution, + Country: destinationAccount.AccountIdentifiers[0].Country, + }, + }) + if err != nil { + return nil, err + } + + payment, err := p.client.GetPayment(ctx, resp.PaymentID) + if err != nil { + return nil, err + } + + res, err := translatePayment(*payment) + if err != nil { + return nil, err + } + + return res, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/payouts_test.go b/internal/connectors/plugins/public/bankingcircle/payouts_test.go new file mode 100644 index 00000000..86b0e8ca --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/payouts_test.go @@ -0,0 +1,315 @@ +package bankingcircle + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("BankingCircle Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - get account error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get source account: test error: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - missing source account identifiers error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{}, + }, nil) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("no account identifiers provided for source account: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - get account 2 error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{}}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get destination account: test error: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - missing destination account identifiers error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{}}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{}, + }, nil) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("no account identifiers provided for destination account: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - initiate payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }}, + }, nil) + + m.EXPECT().InitiateTransferOrPayouts(gomock.Any(), &client.PaymentRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + RequestedExecutionDate: samplePSPPaymentInitiation.CreatedAt, + DebtorAccount: client.PaymentAccount{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }, + DebtorReference: samplePSPPaymentInitiation.Description, + CurrencyOfTransfer: "EUR", + Amount: client.Amount{ + Currency: "EUR", + Amount: "1.00", + }, + ChargeBearer: "SHA", + CreditorAccount: &client.PaymentAccount{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + pr := client.PaymentResponse{ + PaymentID: "p1", + } + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }}, + }, nil) + + m.EXPECT().InitiateTransferOrPayouts(gomock.Any(), &client.PaymentRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + RequestedExecutionDate: samplePSPPaymentInitiation.CreatedAt, + DebtorAccount: client.PaymentAccount{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }, + DebtorReference: samplePSPPaymentInitiation.Description, + CurrencyOfTransfer: "EUR", + Amount: client.Amount{ + Currency: "EUR", + Amount: "1.00", + }, + ChargeBearer: "SHA", + CreditorAccount: &client.PaymentAccount{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }, + }).Return(&pr, nil) + + paymentResponse := client.Payment{ + PaymentID: "p1", + TransactionReference: "transaction-p1", + Status: "Processed", + Classification: "Outgoing", + ProcessedTimestamp: now.UTC(), + LatestStatusChangedTimestamp: now.UTC(), + DebtorInformation: client.DebtorInformation{ + AccountID: "123", + }, + Transfer: client.Transfer{ + Amount: client.Amount{ + Currency: "EUR", + Amount: "1.00", + }, + }, + CreditorInformation: client.CreditorInformation{ + AccountID: "321", + }, + } + + m.EXPECT().GetPayment(gomock.Any(), "p1").Return(&paymentResponse, nil) + + raw, err := json.Marshal(&paymentResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "p1", + CreatedAt: now.UTC(), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("123"), + DestinationAccountReference: pointer.For("321"), + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/plugin.go b/internal/connectors/plugins/public/bankingcircle/plugin.go new file mode 100644 index 00000000..2bb72e31 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/plugin.go @@ -0,0 +1,143 @@ +package bankingcircle + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("bankingcircle", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client, err := client.New(config.Username, config.Password, config.Endpoint, config.AuthorizationEndpoint, config.UserCertificate, config.UserCertificateKey) + if err != nil { + return nil, err + } + + return &Plugin{ + name: name, + client: client, + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + if p.client == nil { + return models.CreateBankAccountResponse{}, plugins.ErrNotYetInstalled + } + return p.createBankAccount(req) +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + return models.CreateTransferResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + return models.CreatePayoutResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/bankingcircle/plugin_test.go b/internal/connectors/plugins/public/bankingcircle/plugin_test.go new file mode 100644 index 00000000..f8d233a7 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/plugin_test.go @@ -0,0 +1,203 @@ +package bankingcircle + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "BankingCircle Plugin Suite") +} + +var _ = Describe("BankingCircle Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - username", func(ctx SpecContext) { + config := json.RawMessage(`{}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("missing username in config: invalid config")) + }) + + It("should report errors in config - password", func(ctx SpecContext) { + config := json.RawMessage(`{"username": "test"}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("missing password in config: invalid config")) + }) + + It("should report errors in config - endpoint", func(ctx SpecContext) { + config := json.RawMessage(`{"username": "test", "password": "test"}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("missing endpoint in config: invalid config")) + }) + + It("should report errors in config - authorization endpoint", func(ctx SpecContext) { + config := json.RawMessage(`{"username": "test", "password": "test", "endpoint": "test"}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("missing authorization endpoint in config: invalid config")) + }) + + It("should report errors in config - certificate", func(ctx SpecContext) { + config := json.RawMessage(`{"username": "test", "password": "test", "endpoint": "test", "authorizationEndpoint": "test"}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("missing user certificate in config: invalid config")) + }) + + It("should report errors in config - certificate key", func(ctx SpecContext) { + config := json.RawMessage(`{"username": "test", "password": "test", "endpoint": "test", "authorizationEndpoint": "test", "userCertificate": "test"}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("missing user certificate key in config: invalid config")) + }) + + It("should report errors in config - invalid certificate", func(ctx SpecContext) { + config := json.RawMessage(`{"username": "test", "password": "test", "endpoint": "test", "authorizationEndpoint": "test", "userCertificate": "test", "userCertificateKey": "test"}`) + _, err := New("bankingcircle", config) + Expect(err).To(MatchError("failed to load user certificate: tls: failed to find any PEM data in certificate input: invalid config")) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create bank account", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in bank_account_creation_test.go + }) + + Context("create transfer", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in transfers_test.go + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payouts_test.go + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create webhooks", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("translate webhook", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/transfers.go b/internal/connectors/plugins/public/bankingcircle/transfers.go new file mode 100644 index 00000000..d7e70c4d --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/transfers.go @@ -0,0 +1,100 @@ +package bankingcircle + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validateTransferRequest(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (*models.PSPPayment, error) { + if err := p.validateTransferRequest(pi); err != nil { + return nil, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return nil, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return nil, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + var sourceAccount *client.Account + sourceAccount, err = p.client.GetAccount(ctx, pi.SourceAccount.Reference) + if err != nil { + return nil, fmt.Errorf("failed to get source account: %v: %w", err, models.ErrInvalidRequest) + } + if len(sourceAccount.AccountIdentifiers) == 0 { + return nil, fmt.Errorf("no account identifiers provided for source account: %w", models.ErrInvalidRequest) + } + + var destinationAccount *client.Account + destinationAccount, err = p.client.GetAccount(ctx, pi.DestinationAccount.Reference) + if err != nil { + return nil, fmt.Errorf("failed to get destination account: %v: %w", err, models.ErrInvalidRequest) + } + if len(destinationAccount.AccountIdentifiers) == 0 { + return nil, fmt.Errorf("no account identifiers provided for destination account: %w", models.ErrInvalidRequest) + } + + resp, err := p.client.InitiateTransferOrPayouts( + ctx, + &client.PaymentRequest{ + IdempotencyKey: pi.Reference, + RequestedExecutionDate: pi.CreatedAt, + DebtorAccount: client.PaymentAccount{ + Account: sourceAccount.AccountIdentifiers[0].Account, + FinancialInstitution: sourceAccount.AccountIdentifiers[0].FinancialInstitution, + Country: sourceAccount.AccountIdentifiers[0].Country, + }, + DebtorReference: pi.Description, + CurrencyOfTransfer: curr, + Amount: struct { + Currency string "json:\"currency\"" + Amount json.Number "json:\"amount\"" + }{ + Currency: curr, + Amount: json.Number(amount), + }, + ChargeBearer: "SHA", + CreditorAccount: &client.PaymentAccount{ + Account: destinationAccount.AccountIdentifiers[0].Account, + FinancialInstitution: destinationAccount.AccountIdentifiers[0].FinancialInstitution, + Country: destinationAccount.AccountIdentifiers[0].Country, + }, + }, + ) + if err != nil { + return nil, err + } + + payment, err := p.client.GetPayment(ctx, resp.PaymentID) + if err != nil { + return nil, err + } + + res, err := translatePayment(*payment) + if err != nil { + return nil, err + } + + return res, nil +} diff --git a/internal/connectors/plugins/public/bankingcircle/transfers_test.go b/internal/connectors/plugins/public/bankingcircle/transfers_test.go new file mode 100644 index 00000000..e947a9ae --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/transfers_test.go @@ -0,0 +1,315 @@ +package bankingcircle + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/bankingcircle/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("BankingCircle Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - get account error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get source account: test error: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - missing source account identifiers error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{}, + }, nil) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("no account identifiers provided for source account: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - get account 2 error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{}}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get destination account: test error: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - missing destination account identifiers error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{}}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{}, + }, nil) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("no account identifiers provided for destination account: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - initiate transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }}, + }, nil) + + m.EXPECT().InitiateTransferOrPayouts(gomock.Any(), &client.PaymentRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + RequestedExecutionDate: samplePSPPaymentInitiation.CreatedAt, + DebtorAccount: client.PaymentAccount{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }, + DebtorReference: samplePSPPaymentInitiation.Description, + CurrencyOfTransfer: "EUR", + Amount: client.Amount{ + Currency: "EUR", + Amount: "1.00", + }, + ChargeBearer: "SHA", + CreditorAccount: &client.PaymentAccount{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + pr := client.PaymentResponse{ + PaymentID: "p1", + } + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }}, + }, nil) + + m.EXPECT().GetAccount(gomock.Any(), samplePSPPaymentInitiation.DestinationAccount.Reference). + Return(&client.Account{ + AccountIdentifiers: []client.AccountIdentifier{{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }}, + }, nil) + + m.EXPECT().InitiateTransferOrPayouts(gomock.Any(), &client.PaymentRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + RequestedExecutionDate: samplePSPPaymentInitiation.CreatedAt, + DebtorAccount: client.PaymentAccount{ + Account: "123456789", + FinancialInstitution: "test", + Country: "US", + }, + DebtorReference: samplePSPPaymentInitiation.Description, + CurrencyOfTransfer: "EUR", + Amount: client.Amount{ + Currency: "EUR", + Amount: "1.00", + }, + ChargeBearer: "SHA", + CreditorAccount: &client.PaymentAccount{ + Account: "987654321", + FinancialInstitution: "test 2", + Country: "US", + }, + }).Return(&pr, nil) + + paymentResponse := client.Payment{ + PaymentID: "p1", + TransactionReference: "transaction-p1", + Status: "Processed", + Classification: "Own", + ProcessedTimestamp: now.UTC(), + LatestStatusChangedTimestamp: now.UTC(), + DebtorInformation: client.DebtorInformation{ + AccountID: "123", + }, + Transfer: client.Transfer{ + Amount: client.Amount{ + Currency: "EUR", + Amount: "1.00", + }, + }, + CreditorInformation: client.CreditorInformation{ + AccountID: "321", + }, + } + + m.EXPECT().GetPayment(gomock.Any(), "p1").Return(&paymentResponse, nil) + + raw, err := json.Marshal(&paymentResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "p1", + CreatedAt: now.UTC(), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("123"), + DestinationAccountReference: pointer.For("321"), + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/bankingcircle/workflow.go b/internal/connectors/plugins/public/bankingcircle/workflow.go new file mode 100644 index 00000000..e2583ae6 --- /dev/null +++ b/internal/connectors/plugins/public/bankingcircle/workflow.go @@ -0,0 +1,27 @@ +package bankingcircle + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/currencycloud/accounts.go b/internal/connectors/plugins/public/currencycloud/accounts.go new file mode 100644 index 00000000..7d5eef35 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/accounts.go @@ -0,0 +1,105 @@ +package currencycloud + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" +) + +type accountsState struct { + LastPage int `json:"lastPage"` + LastCreatedAt time.Time `json:"lastCreatedAt"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + if oldState.LastPage == 0 { + oldState.LastPage = 1 + } + + newState := accountsState{ + LastPage: oldState.LastPage, + LastCreatedAt: oldState.LastCreatedAt, + } + + var accounts []models.PSPAccount + hasMore := false + page := oldState.LastPage + for { + pagedAccounts, nextPage, err := p.client.GetAccounts(ctx, page, req.PageSize) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + if len(pagedAccounts) == 0 { + break + } + + accounts, err = fillAccounts(accounts, pagedAccounts, oldState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore := true + needMore, hasMore, accounts = shouldFetchMore(accounts, nextPage, req.PageSize) + if !needMore { + break + } + + page = nextPage + } + + newState.LastPage = page + if len(accounts) > 0 { + newState.LastCreatedAt = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillAccounts( + accounts []models.PSPAccount, + pagedAccounts []*client.Account, + oldState accountsState, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + switch account.CreatedAt.Compare(oldState.LastCreatedAt) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.ID, + CreatedAt: account.CreatedAt, + Name: &account.AccountName, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/accounts_test.go b/internal/connectors/plugins/public/currencycloud/accounts_test.go new file mode 100644 index 00000000..600edc87 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/accounts_test.go @@ -0,0 +1,176 @@ +package currencycloud + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("CurrencyCloud Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []*client.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]*client.Account, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, &client.Account{ + ID: fmt.Sprintf("%d", i), + AccountName: fmt.Sprintf("Account %d", i), + CreatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + UpdatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + }) + } + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 60).Return( + []*client.Account{}, + -1, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 60).Return( + []*client.Account{}, + -1, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreatedAt.IsZero()).To(BeTrue()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 60).Return( + sampleAccounts, + -1, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreatedAt).To(Equal(sampleAccounts[49].CreatedAt)) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 40).Return( + sampleAccounts[:40], + 2, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreatedAt).To(Equal(sampleAccounts[39].CreatedAt)) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": %d, "lastCreatedAt": "%s"}`, 1, sampleAccounts[38].CreatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 1, 40).Return( + sampleAccounts[:40], + 2, + nil, + ) + + m.EXPECT().GetAccounts(gomock.Any(), 2, 40).Return( + sampleAccounts[41:], + -1, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(2)) + Expect(state.LastCreatedAt).To(Equal(sampleAccounts[49].CreatedAt)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/currencycloud/balances.go b/internal/connectors/plugins/public/currencycloud/balances.go new file mode 100644 index 00000000..1884fa76 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/balances.go @@ -0,0 +1,49 @@ +package currencycloud + +import ( + "context" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + page := 1 + balances := make([]models.PSPBalance, 0) + for { + if page < 0 { + break + } + + pagedBalances, nextPage, err := p.client.GetBalances(ctx, page, req.PageSize) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + page = nextPage + + for _, balance := range pagedBalances { + precision, ok := supportedCurrenciesWithDecimal[balance.Currency] + if !ok { + return models.FetchNextBalancesResponse{}, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(balance.Amount.String(), precision) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balances = append(balances, models.PSPBalance{ + AccountReference: balance.AccountID, + CreatedAt: balance.UpdatedAt, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Currency), + }) + } + } + + return models.FetchNextBalancesResponse{ + Balances: balances, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/balances_test.go b/internal/connectors/plugins/public/currencycloud/balances_test.go new file mode 100644 index 00000000..b4845de5 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/balances_test.go @@ -0,0 +1,141 @@ +package currencycloud + +import ( + "errors" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("CurrencyCloud Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next balances", func() { + var ( + m *client.MockClient + sampleBalances []*client.Balance + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBalances = []*client.Balance{ + { + ID: "test1", + AccountID: "test1", + Currency: "EUR", + Amount: "100", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + UpdatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + }, + { + ID: "test2", + AccountID: "test2", + Currency: "USD", + Amount: "200", + CreatedAt: now.Add(-time.Duration(40) * time.Minute).UTC(), + UpdatedAt: now.Add(-time.Duration(35) * time.Minute).UTC(), + }, + { + ID: "test3", + AccountID: "test1", + Currency: "DKK", + Amount: "150", + CreatedAt: now.Add(-time.Duration(30) * time.Minute).UTC(), + UpdatedAt: now.Add(-time.Duration(15) * time.Minute).UTC(), + }, + } + }) + + It("should return an error - get balances error", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + m.EXPECT().GetBalances(gomock.Any(), 1, 60).Return( + []*client.Balance{}, + -1, + errors.New("test error"), + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should fetch next balances - no state no results", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + m.EXPECT().GetBalances(gomock.Any(), 1, 60).Return( + []*client.Balance{}, + -1, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + }) + + It("should fetch all balances - page size > sample balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + m.EXPECT().GetBalances(gomock.Any(), 1, 60).Return( + sampleBalances, + -1, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(3)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + }) + + It("should fetch all balances - page size < sample balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 2, + } + + m.EXPECT().GetBalances(gomock.Any(), 1, 2).Return( + sampleBalances[:2], + 2, + nil, + ) + + m.EXPECT().GetBalances(gomock.Any(), 2, 2).Return( + sampleBalances[2:], + -1, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(3)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/currencycloud/capabilities.go b/internal/connectors/plugins/public/currencycloud/capabilities.go new file mode 100644 index 00000000..1c105267 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/capabilities.go @@ -0,0 +1,13 @@ +package currencycloud + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, +} diff --git a/internal/connectors/plugins/public/currencycloud/client/accounts.go b/internal/connectors/plugins/public/currencycloud/client/accounts.go new file mode 100644 index 00000000..8b9e7e9f --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/accounts.go @@ -0,0 +1,55 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Account struct { + ID string `json:"id"` + AccountName string `json:"account_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (c *client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, int, error) { + if err := c.ensureLogin(ctx); err != nil { + return nil, 0, err + } + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_accounts") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.buildEndpoint("v2/accounts/find"), http.NoBody) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", fmt.Sprint(pageSize)) + q.Add("page", fmt.Sprint(page)) + q.Add("order", "updated_at") + q.Add("order_asc_desc", "asc") + req.URL.RawQuery = q.Encode() + + req.Header.Add("Accept", "application/json") + + //nolint:tagliatelle // allow for client code + type response struct { + Accounts []*Account `json:"accounts"` + Pagination struct { + NextPage int `json:"next_page"` + } `json:"pagination"` + } + + res := response{Accounts: make([]*Account, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, 0, fmt.Errorf("failed to get accounts: %w, %w", err, errRes.Error()) + } + return res.Accounts, res.Pagination.NextPage, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/auth.go b/internal/connectors/plugins/public/currencycloud/client/auth.go new file mode 100644 index 00000000..8b502607 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/auth.go @@ -0,0 +1,55 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +func (c *client) authenticate(ctx context.Context) error { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "authenticate") + + form := make(url.Values) + + form.Add("login_id", c.loginID) + form.Add("api_key", c.apiKey) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.buildEndpoint("v2/authenticate/api"), strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Accept", "application/json") + + //nolint:tagliatelle // allow for client code + type response struct { + AuthToken string `json:"auth_token"` + } + + var res response + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return fmt.Errorf("failed to get authenticate: %w, %w", err, errRes.Error()) + } + + c.authToken = res.AuthToken + + return nil +} + +func (c *client) ensureLogin(ctx context.Context) error { + if c.authToken == "" { + _, err, _ := c.singleFlight.Do("authenticate", func() (interface{}, error) { + return nil, c.authenticate(ctx) + }) + return err + } + return nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/balances.go b/internal/connectors/plugins/public/currencycloud/client/balances.go new file mode 100644 index 00000000..b6851cd7 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/balances.go @@ -0,0 +1,59 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Balance struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (c *client) GetBalances(ctx context.Context, page int, pageSize int) ([]*Balance, int, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_balances") + + if err := c.ensureLogin(ctx); err != nil { + return nil, 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.buildEndpoint("v2/balances/find"), http.NoBody) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", fmt.Sprint(pageSize)) + q.Add("page", fmt.Sprint(page)) + q.Add("order", "created_at") + q.Add("order_asc_desc", "asc") + req.URL.RawQuery = q.Encode() + + req.Header.Add("Accept", "application/json") + + //nolint:tagliatelle // allow for client code + type response struct { + Balances []*Balance `json:"balances"` + Pagination struct { + NextPage int `json:"next_page"` + } `json:"pagination"` + } + + res := response{Balances: make([]*Balance, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, 0, fmt.Errorf("failed to get balances %w, %w", err, errRes.Error()) + } + return res.Balances, res.Pagination.NextPage, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go b/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go new file mode 100644 index 00000000..e4994da4 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go @@ -0,0 +1,58 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Beneficiary struct { + ID string `json:"id"` + BankAccountHolderName string `json:"bank_account_holder_name"` + Name string `json:"name"` + Currency string `json:"currency"` + CreatedAt time.Time `json:"created_at"` + // Contains a lot more fields that will be not used on our side for now +} + +func (c *client) GetBeneficiaries(ctx context.Context, page int, pageSize int) ([]*Beneficiary, int, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_beneficiaries") + + if err := c.ensureLogin(ctx); err != nil { + return nil, 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.buildEndpoint("v2/beneficiaries/find"), http.NoBody) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + q.Add("page", fmt.Sprint(page)) + q.Add("per_page", fmt.Sprint(pageSize)) + q.Add("order", "created_at") + q.Add("order_asc_desc", "asc") + req.URL.RawQuery = q.Encode() + + req.Header.Add("Accept", "application/json") + + //nolint:tagliatelle // allow for client code + type response struct { + Beneficiaries []*Beneficiary `json:"beneficiaries"` + Pagination struct { + NextPage int `json:"next_page"` + } `json:"pagination"` + } + + res := response{Beneficiaries: make([]*Beneficiary, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, 0, fmt.Errorf("failed to get beneficiaries %w, %w", err, errRes.Error()) + } + return res.Beneficiaries, res.Pagination.NextPage, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/client.go b/internal/connectors/plugins/public/currencycloud/client/client.go new file mode 100644 index 00000000..0aad30a7 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/client.go @@ -0,0 +1,75 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "golang.org/x/sync/singleflight" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, int, error) + GetBalances(ctx context.Context, page int, pageSize int) ([]*Balance, int, error) + GetBeneficiaries(ctx context.Context, page int, pageSize int) ([]*Beneficiary, int, error) + GetContactID(ctx context.Context, accountID string) (*Contact, error) + GetTransactions(ctx context.Context, page int, pageSize int, updatedAtFrom time.Time) ([]Transaction, int, error) + InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) + InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) +} + +type apiTransport struct { + c *client + underlying *otelhttp.Transport +} + +func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.c.authToken != "" { + req.Header.Add("X-Auth-Token", t.c.authToken) + } + + return t.underlying.RoundTrip(req) +} + +type client struct { + httpClient httpwrapper.Client + endpoint string + loginID string + apiKey string + + singleFlight singleflight.Group + authToken string +} + +func (c *client) buildEndpoint(path string, args ...interface{}) string { + return fmt.Sprintf("%s/%s", c.endpoint, fmt.Sprintf(path, args...)) +} + +const DevAPIEndpoint = "https://devapi.currencycloud.com" + +// New creates a new client for the CurrencyCloud API. +func New(loginID, apiKey, endpoint string) Client { + if endpoint == "" { + endpoint = DevAPIEndpoint + } + + c := &client{ + endpoint: endpoint, + loginID: loginID, + apiKey: apiKey, + } + + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("currencycloud"), + Transport: &apiTransport{ + c: c, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + } + c.httpClient = httpwrapper.NewClient(config) + return c +} diff --git a/internal/connectors/plugins/public/currencycloud/client/client_generated.go b/internal/connectors/plugins/public/currencycloud/client/client_generated.go new file mode 100644 index 00000000..ae9d80d5 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/client_generated.go @@ -0,0 +1,151 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetAccounts mocks base method. +func (m *MockClient) GetAccounts(ctx context.Context, page, pageSize int) ([]*Account, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccounts", ctx, page, pageSize) + ret0, _ := ret[0].([]*Account) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccounts indicates an expected call of GetAccounts. +func (mr *MockClientMockRecorder) GetAccounts(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccounts", reflect.TypeOf((*MockClient)(nil).GetAccounts), ctx, page, pageSize) +} + +// GetBalances mocks base method. +func (m *MockClient) GetBalances(ctx context.Context, page, pageSize int) ([]*Balance, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBalances", ctx, page, pageSize) + ret0, _ := ret[0].([]*Balance) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBalances indicates an expected call of GetBalances. +func (mr *MockClientMockRecorder) GetBalances(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBalances", reflect.TypeOf((*MockClient)(nil).GetBalances), ctx, page, pageSize) +} + +// GetBeneficiaries mocks base method. +func (m *MockClient) GetBeneficiaries(ctx context.Context, page, pageSize int) ([]*Beneficiary, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBeneficiaries", ctx, page, pageSize) + ret0, _ := ret[0].([]*Beneficiary) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBeneficiaries indicates an expected call of GetBeneficiaries. +func (mr *MockClientMockRecorder) GetBeneficiaries(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBeneficiaries", reflect.TypeOf((*MockClient)(nil).GetBeneficiaries), ctx, page, pageSize) +} + +// GetContactID mocks base method. +func (m *MockClient) GetContactID(ctx context.Context, accountID string) (*Contact, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContactID", ctx, accountID) + ret0, _ := ret[0].(*Contact) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetContactID indicates an expected call of GetContactID. +func (mr *MockClientMockRecorder) GetContactID(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactID", reflect.TypeOf((*MockClient)(nil).GetContactID), ctx, accountID) +} + +// GetTransactions mocks base method. +func (m *MockClient) GetTransactions(ctx context.Context, page, pageSize int, updatedAtFrom time.Time) ([]Transaction, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransactions", ctx, page, pageSize, updatedAtFrom) + ret0, _ := ret[0].([]Transaction) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetTransactions indicates an expected call of GetTransactions. +func (mr *MockClientMockRecorder) GetTransactions(ctx, page, pageSize, updatedAtFrom any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactions", reflect.TypeOf((*MockClient)(nil).GetTransactions), ctx, page, pageSize, updatedAtFrom) +} + +// InitiatePayout mocks base method. +func (m *MockClient) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiatePayout", ctx, payoutRequest) + ret0, _ := ret[0].(*PayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiatePayout indicates an expected call of InitiatePayout. +func (mr *MockClientMockRecorder) InitiatePayout(ctx, payoutRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiatePayout", reflect.TypeOf((*MockClient)(nil).InitiatePayout), ctx, payoutRequest) +} + +// InitiateTransfer mocks base method. +func (m *MockClient) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiateTransfer", ctx, transferRequest) + ret0, _ := ret[0].(*TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiateTransfer indicates an expected call of InitiateTransfer. +func (mr *MockClientMockRecorder) InitiateTransfer(ctx, transferRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateTransfer", reflect.TypeOf((*MockClient)(nil).InitiateTransfer), ctx, transferRequest) +} diff --git a/internal/connectors/plugins/public/currencycloud/client/contacts.go b/internal/connectors/plugins/public/currencycloud/client/contacts.go new file mode 100644 index 00000000..1bdf621d --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/contacts.go @@ -0,0 +1,50 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/models" +) + +type Contact struct { + ID string `json:"id"` +} + +func (c *client) GetContactID(ctx context.Context, accountID string) (*Contact, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_contacts") + + if err := c.ensureLogin(ctx); err != nil { + return nil, err + } + + form := url.Values{} + form.Set("account_id", accountID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.buildEndpoint("v2/contacts/find"), strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + type Contacts struct { + Contacts []*Contact `json:"contacts"` + } + + res := Contacts{Contacts: make([]*Contact, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get contacts %w, %w", err, errRes.Error()) + } + + if len(res.Contacts) == 0 { + return nil, fmt.Errorf("no contact found for account %s: %w", accountID, models.ErrInvalidRequest) + } + + return res.Contacts[0], nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/error.go b/internal/connectors/plugins/public/currencycloud/client/error.go new file mode 100644 index 00000000..197f7df0 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/error.go @@ -0,0 +1,34 @@ +package client + +import ( + "fmt" +) + +type currencyCloudError struct { + StatusCode int `json:"status_code"` + ErrorCode string `json:"error_code"` + ErrorMessages map[string][]*errorMessage `json:"error_messages"` +} + +type errorMessage struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (ce *currencyCloudError) Error() error { + var errorMessage string + if len(ce.ErrorMessages) > 0 { + for _, message := range ce.ErrorMessages { + if len(message) > 0 { + errorMessage = message[0].Message + break + } + } + } + + if errorMessage == "" { + return fmt.Errorf("unexpected status code: %d", ce.StatusCode) + } + + return fmt.Errorf("%s: %s", ce.ErrorCode, errorMessage) +} diff --git a/internal/connectors/plugins/public/currencycloud/client/payouts.go b/internal/connectors/plugins/public/currencycloud/client/payouts.go new file mode 100644 index 00000000..1bc10241 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/payouts.go @@ -0,0 +1,80 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type PayoutRequest struct { + OnBehalfOf string `json:"on_behalf_of"` + BeneficiaryID string `json:"beneficiary_id"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + Reference string `json:"reference"` + UniqueRequestID string `json:"unique_request_id"` +} + +func (pr *PayoutRequest) ToFormData() url.Values { + form := url.Values{} + form.Set("on_behalf_of", pr.OnBehalfOf) + form.Set("beneficiary_id", pr.BeneficiaryID) + form.Set("currency", pr.Currency) + form.Set("amount", pr.Amount.String()) + form.Set("reference", pr.Reference) + if pr.UniqueRequestID != "" { + form.Set("unique_request_id", pr.UniqueRequestID) + } + + return form +} + +type PayoutResponse struct { + ID string `json:"id"` + Amount json.Number `json:"amount"` + BeneficiaryID string `json:"beneficiary_id"` + Currency string `json:"currency"` + Reference string `json:"reference"` + Status string `json:"status"` + Reason string `json:"reason"` + CreatorContactID string `json:"creator_contact_id"` + PaymentType string `json:"payment_type"` + TransferredAt string `json:"transferred_at"` + PaymentDate string `json:"payment_date"` + FailureReason string `json:"failure_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UniqueRequestID string `json:"unique_request_id"` +} + +func (c *client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_payout") + + if err := c.ensureLogin(ctx); err != nil { + return nil, err + } + + form := payoutRequest.ToFormData() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.buildEndpoint("v2/payments/create"), strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var payoutResponse PayoutResponse + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &payoutResponse, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to create payout: %w, %w", err, errRes.Error()) + } + + return &payoutResponse, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/transactions.go b/internal/connectors/plugins/public/currencycloud/client/transactions.go new file mode 100644 index 00000000..1ebf3f0b --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/transactions.go @@ -0,0 +1,71 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +//nolint:tagliatelle // allow different styled tags in client +type Transaction struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + Currency string `json:"currency"` + Type string `json:"type"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Action string `json:"action"` + + Amount json.Number `json:"amount"` +} + +func (c *client) GetTransactions(ctx context.Context, page int, pageSize int, updatedAtFrom time.Time) ([]Transaction, int, error) { + if page < 1 { + return nil, 0, fmt.Errorf("page must be greater than 0") + } + + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_transactions") + + if err := c.ensureLogin(ctx); err != nil { + return nil, 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.buildEndpoint("v2/transactions/find"), http.NoBody) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + q.Add("page", fmt.Sprint(page)) + q.Add("per_page", fmt.Sprint(pageSize)) + if !updatedAtFrom.IsZero() { + q.Add("updated_at_from", updatedAtFrom.Format(time.DateOnly)) + } + q.Add("order", "updated_at") + q.Add("order_asc_desc", "asc") + req.URL.RawQuery = q.Encode() + + req.Header.Add("Accept", "application/json") + + //nolint:tagliatelle // allow for client code + type response struct { + Transactions []Transaction `json:"transactions"` + Pagination struct { + NextPage int `json:"next_page"` + } `json:"pagination"` + } + + res := response{Transactions: make([]Transaction, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, 0, fmt.Errorf("failed to get transactions: %w, %w", err, errRes.Error()) + } + return res.Transactions, res.Pagination.NextPage, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/client/transfers.go b/internal/connectors/plugins/public/currencycloud/client/transfers.go new file mode 100644 index 00000000..4f5199f9 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/client/transfers.go @@ -0,0 +1,80 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type TransferRequest struct { + SourceAccountID string `json:"source_account_id"` + DestinationAccountID string `json:"destination_account_id"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + Reason string `json:"reason,omitempty"` + UniqueRequestID string `json:"unique_request_id,omitempty"` +} + +func (tr *TransferRequest) ToFormData() url.Values { + form := url.Values{} + form.Set("source_account_id", tr.SourceAccountID) + form.Set("destination_account_id", tr.DestinationAccountID) + form.Set("currency", tr.Currency) + form.Set("amount", fmt.Sprintf("%v", tr.Amount)) + if tr.Reason != "" { + form.Set("reason", tr.Reason) + } + if tr.UniqueRequestID != "" { + form.Set("unique_request_id", tr.UniqueRequestID) + } + + return form +} + +type TransferResponse struct { + ID string `json:"id"` + ShortReference string `json:"short_reference"` + SourceAccountID string `json:"source_account_id"` + DestinationAccountID string `json:"destination_account_id"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt time.Time `json:"completed_at"` + CreatorAccountID string `json:"creator_account_id"` + CreatorContactID string `json:"creator_contact_id"` + Reason string `json:"reason"` + UniqueRequestID string `json:"unique_request_id"` +} + +func (c *client) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_transfer") + + if err := c.ensureLogin(ctx); err != nil { + return nil, err + } + + form := transferRequest.ToFormData() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.buildEndpoint("v2/transfers/create"), strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var res TransferResponse + var errRes currencyCloudError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to create transfer: %w, %w", err, errRes.Error()) + } + + return &res, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/config.go b/internal/connectors/plugins/public/currencycloud/config.go new file mode 100644 index 00000000..7b5c0f86 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/config.go @@ -0,0 +1,39 @@ +package currencycloud + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + LoginID string `json:"loginID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +func (c Config) validate() error { + if c.LoginID == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing clientID in config") + } + + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api key in config") + } + + if c.Endpoint == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing endpoint in config") + } + + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/currencycloud/config.json b/internal/connectors/plugins/public/currencycloud/config.json new file mode 100644 index 00000000..ba46c4ee --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/config.json @@ -0,0 +1,17 @@ +{ + "loginID": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "endpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/currencycloud/currencies.go b/internal/connectors/plugins/public/currencycloud/currencies.go new file mode 100644 index 00000000..0af408f8 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/currencies.go @@ -0,0 +1,51 @@ +package currencycloud + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + // c.f.: https://support.currencycloud.com/hc/en-gb/articles/7840216562972-Currency-Decimal-Places + supportedCurrenciesWithDecimal = map[string]int{ + "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar + "CAD": currency.ISO4217Currencies["CAD"], // Canadian Dollar + "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna + "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar + "INR": currency.ISO4217Currencies["INR"], // Indian Rupee + "IDR": currency.ISO4217Currencies["IDR"], // Indonesia, Rupiah + "ILS": currency.ISO4217Currencies["ILS"], // New Israeli Shekel + "JPY": currency.ISO4217Currencies["JPY"], // Japan, Yen + "KES": currency.ISO4217Currencies["KES"], // Kenyan Shilling + "MYR": currency.ISO4217Currencies["MYR"], // Malaysian Ringgit + "MXN": currency.ISO4217Currencies["MXN"], // Mexican Peso + "NZD": currency.ISO4217Currencies["NZD"], // New Zealand Dollar + "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone + "PHP": currency.ISO4217Currencies["PHP"], // Philippine Peso + "PLN": currency.ISO4217Currencies["PLN"], // Poland, Zloty + "RON": currency.ISO4217Currencies["RON"], // Romania, New Leu + "SAR": currency.ISO4217Currencies["SAR"], // Saudi Riyal + "SGD": currency.ISO4217Currencies["SGD"], // Singapore Dollar + "ZAR": currency.ISO4217Currencies["ZAR"], // South Africa, Rand + "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona + "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc + "THB": currency.ISO4217Currencies["THB"], // Thailand, Baht + "TRY": currency.ISO4217Currencies["TRY"], // New Turkish Lira + "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling + "AED": currency.ISO4217Currencies["AED"], // UAE Dirham + "USD": currency.ISO4217Currencies["USD"], // US Dollar + "UGX": currency.ISO4217Currencies["UGX"], // Uganda Shilling + "QAR": currency.ISO4217Currencies["QAR"], // Qatari Riyal + + // Unsupported currencies + // the following currencies are not existing in ISO 4217, so we prefer + // not to support them for now. + // "CNH": 2, // Chinese Yuan + + // The following currencies have a different value in ISO 4217, so we + // prefer to not support them for now. + // "HUF": 0, // Hungarian Forint + // "KWD": 2, // Kuwaiti Dinar + // "OMR": 2, // Rial Omani + // "BHD": 2, // Bahraini Dinar + } +) diff --git a/internal/connectors/plugins/public/currencycloud/external_accounts.go b/internal/connectors/plugins/public/currencycloud/external_accounts.go new file mode 100644 index 00000000..c3b80969 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/external_accounts.go @@ -0,0 +1,106 @@ +package currencycloud + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" +) + +type externalAccountsState struct { + LastPage int `json:"lastPage"` + LastCreatedAt time.Time `json:"lastCreatedAt"` +} + +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + + if oldState.LastPage == 0 { + oldState.LastPage = 1 + } + + newState := externalAccountsState{ + LastPage: oldState.LastPage, + LastCreatedAt: oldState.LastCreatedAt, + } + + var accounts []models.PSPAccount + hasMore := false + page := oldState.LastPage + for { + pagedBeneficiaries, nextPage, err := p.client.GetBeneficiaries(ctx, page, req.PageSize) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + if len(pagedBeneficiaries) == 0 { + break + } + + accounts, err = fillBeneficiaries(accounts, pagedBeneficiaries, oldState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + needMore := true + needMore, hasMore, accounts = shouldFetchMore(accounts, nextPage, req.PageSize) + if !needMore { + break + } + + page = nextPage + } + + newState.LastPage = page + if len(accounts) > 0 { + newState.LastCreatedAt = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillBeneficiaries( + accounts []models.PSPAccount, + pagedBeneficiaries []*client.Beneficiary, + oldState externalAccountsState, +) ([]models.PSPAccount, error) { + for _, beneficiary := range pagedBeneficiaries { + switch beneficiary.CreatedAt.Compare(oldState.LastCreatedAt) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + raw, err := json.Marshal(beneficiary) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: beneficiary.ID, + CreatedAt: beneficiary.CreatedAt, + Name: &beneficiary.Name, + DefaultAsset: &beneficiary.Currency, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/external_accounts_test.go b/internal/connectors/plugins/public/currencycloud/external_accounts_test.go new file mode 100644 index 00000000..21209b44 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/external_accounts_test.go @@ -0,0 +1,177 @@ +package currencycloud + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("CurrencyCloud Plugin External Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next external accounts", func() { + var ( + m *client.MockClient + sampleBeneficiaries []*client.Beneficiary + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBeneficiaries = make([]*client.Beneficiary, 0) + for i := 0; i < 50; i++ { + sampleBeneficiaries = append(sampleBeneficiaries, &client.Beneficiary{ + ID: fmt.Sprintf("%d", i), + BankAccountHolderName: fmt.Sprintf("Account %d", i), + Name: fmt.Sprintf("Account %d", i), + Currency: "EUR", + CreatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + }) + } + }) + + It("should return an error - get beneficiaries error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 1, 60).Return( + []*client.Beneficiary{}, + -1, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 1, 60).Return( + []*client.Beneficiary{}, + -1, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreatedAt.IsZero()).To(BeTrue()) + }) + + It("should fetch next external accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 1, 60).Return( + sampleBeneficiaries, + -1, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreatedAt).To(Equal(sampleBeneficiaries[49].CreatedAt)) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 1, 40).Return( + sampleBeneficiaries[:40], + 2, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreatedAt).To(Equal(sampleBeneficiaries[39].CreatedAt)) + }) + + It("should fetch next external accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": %d, "lastCreatedAt": "%s"}`, 1, sampleBeneficiaries[38].CreatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 1, 40).Return( + sampleBeneficiaries[:40], + 2, + nil, + ) + + m.EXPECT().GetBeneficiaries(gomock.Any(), 2, 40).Return( + sampleBeneficiaries[41:], + -1, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(2)) + Expect(state.LastCreatedAt).To(Equal(sampleBeneficiaries[49].CreatedAt)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/currencycloud/payments.go b/internal/connectors/plugins/public/currencycloud/payments.go new file mode 100644 index 00000000..415901d6 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/payments.go @@ -0,0 +1,166 @@ +package currencycloud + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" +) + +type paymentsState struct { + LastUpdatedAt time.Time `json:"lastUpdatedAt"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + newState := paymentsState{ + LastUpdatedAt: oldState.LastUpdatedAt, + } + + var payments []models.PSPPayment + var updatedAts []time.Time + hasMore := false + page := 1 + for { + pagedTransactions, nextPage, err := p.client.GetTransactions(ctx, page, req.PageSize, newState.LastUpdatedAt) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + if len(pagedTransactions) == 0 { + break + } + + payments, updatedAts, err = fillPayments(payments, updatedAts, pagedTransactions, newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore := true + needMore, hasMore, payments = shouldFetchMore(payments, nextPage, req.PageSize) + + if len(payments) > 0 { + newState.LastUpdatedAt = updatedAts[len(payments)-1] + } + + if !needMore { + break + } + + if len(payments) > 0 { + newState.LastUpdatedAt = updatedAts[len(payments)-1] + } + + page = nextPage + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillPayments( + payments []models.PSPPayment, + updatedAts []time.Time, + pagedTransactions []client.Transaction, + newState paymentsState, +) ([]models.PSPPayment, []time.Time, error) { + for _, transaction := range pagedTransactions { + switch transaction.UpdatedAt.Compare(newState.LastUpdatedAt) { + case -1, 0: + continue + default: + } + + payment, err := transactionToPayment(transaction) + if err != nil { + return nil, nil, err + } + + if payment != nil { + payments = append(payments, *payment) + updatedAts = append(updatedAts, transaction.UpdatedAt) + } + } + + return payments, updatedAts, nil +} + +func transactionToPayment(transaction client.Transaction) (*models.PSPPayment, error) { + raw, err := json.Marshal(transaction) + if err != nil { + return nil, err + } + + precision, ok := supportedCurrenciesWithDecimal[transaction.Currency] + if !ok { + return nil, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(transaction.Amount.String(), precision) + if err != nil { + return nil, err + } + + paymentType := matchTransactionType(transaction.Type) + + payment := &models.PSPPayment{ + Reference: transaction.ID, + CreatedAt: transaction.CreatedAt, + Type: paymentType, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchTransactionStatus(transaction.Status), + Raw: raw, + } + + switch paymentType { + case models.PAYMENT_TYPE_PAYOUT: + payment.SourceAccountReference = &transaction.AccountID + case models.PAYMENT_TYPE_PAYIN: + payment.DestinationAccountReference = &transaction.AccountID + } + + return payment, nil +} + +func matchTransactionType(transactionType string) models.PaymentType { + switch transactionType { + case "credit": + return models.PAYMENT_TYPE_PAYIN + case "debit": + return models.PAYMENT_TYPE_PAYOUT + } + return models.PAYMENT_TYPE_OTHER +} + +func matchTransactionStatus(transactionStatus string) models.PaymentStatus { + switch transactionStatus { + case "completed": + return models.PAYMENT_STATUS_SUCCEEDED + case "pending", "ready_to_send": + return models.PAYMENT_STATUS_PENDING + case "deleted": + return models.PAYMENT_STATUS_FAILED + case "cancelled": + return models.PAYMENT_STATUS_CANCELLED + } + return models.PAYMENT_STATUS_OTHER +} diff --git a/internal/connectors/plugins/public/currencycloud/payments_test.go b/internal/connectors/plugins/public/currencycloud/payments_test.go new file mode 100644 index 00000000..91a02faa --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/payments_test.go @@ -0,0 +1,387 @@ +package currencycloud + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +var _ = Describe("CurrencyCloud Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next payments", func() { + var ( + m *client.MockClient + sampleTransactions []client.Transaction + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleTransactions = make([]client.Transaction, 0) + for i := 0; i < 50; i++ { + sampleTransactions = append(sampleTransactions, client.Transaction{ + ID: fmt.Sprintf("%d", i), + AccountID: fmt.Sprintf("Account-%d", i), + Currency: "EUR", + Type: "credit", + Status: "completed", + CreatedAt: now.Add(-time.Duration(60-i) * time.Minute).UTC(), + UpdatedAt: now.Add(-time.Duration(60-i-1) * time.Minute).UTC(), + Amount: "100", + }) + } + }) + + It("should return an error - get transactions error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetTransactions(gomock.Any(), 1, 60, time.Time{}).Return( + []client.Transaction{}, + -1, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetTransactions(gomock.Any(), 1, 60, time.Time{}).Return( + []client.Transaction{}, + -1, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastUpdatedAt.IsZero()).To(BeTrue()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetTransactions(gomock.Any(), 1, 60, time.Time{}).Return( + sampleTransactions, + -1, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastUpdatedAt).To(Equal(sampleTransactions[49].UpdatedAt)) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetTransactions(gomock.Any(), 1, 40, time.Time{}).Return( + sampleTransactions[:40], + 2, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastUpdatedAt).To(Equal(sampleTransactions[39].UpdatedAt)) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(fmt.Sprintf(`{"lastUpdatedAt": "%s"}`, sampleTransactions[38].UpdatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetTransactions(gomock.Any(), 1, 40, sampleTransactions[38].UpdatedAt.UTC()).Return( + sampleTransactions[:40], + 2, + nil, + ) + + m.EXPECT().GetTransactions(gomock.Any(), 2, 40, sampleTransactions[39].UpdatedAt.UTC()).Return( + sampleTransactions[41:], + -1, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastUpdatedAt).To(Equal(sampleTransactions[49].UpdatedAt)) + }) + }) +}) + +func TestTransactionToPayment(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + + t.Run("unsupported currencies", func(t *testing.T) { + t.Parallel() + + transaction := client.Transaction{ + ID: "test", + AccountID: "test", + Currency: "HUF", + Type: "credit", + Status: "completed", + CreatedAt: now, + UpdatedAt: now, + Amount: "100", + } + + p, err := transactionToPayment(transaction) + require.NoError(t, err) + require.Nil(t, p) + }) + + t.Run("wrong amount string", func(t *testing.T) { + t.Parallel() + + transaction := client.Transaction{ + ID: "test", + AccountID: "test", + Currency: "EUR", + Type: "credit", + Status: "completed", + CreatedAt: now, + UpdatedAt: now, + Amount: "100,fdv", + } + + p, err := transactionToPayment(transaction) + require.Error(t, err) + require.Nil(t, p) + }) + + t.Run("credit payment type - status completed", func(t *testing.T) { + t.Parallel() + + transaction := client.Transaction{ + ID: "test", + AccountID: "test", + Currency: "EUR", + Type: "credit", + Status: "completed", + CreatedAt: now, + UpdatedAt: now, + Amount: "100", + } + + p, err := transactionToPayment(transaction) + require.NoError(t, err) + require.NotNil(t, p) + + expected := models.PSPPayment{ + ParentReference: "", + Reference: transaction.ID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(10000), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + DestinationAccountReference: pointer.For("test"), + } + + comparePSPPayments(t, expected, *p) + }) + + t.Run("debit payment type - status pending", func(t *testing.T) { + t.Parallel() + + transaction := client.Transaction{ + ID: "test", + AccountID: "test", + Currency: "EUR", + Type: "debit", + Status: "pending", + CreatedAt: now, + UpdatedAt: now, + Amount: "100", + } + + p, err := transactionToPayment(transaction) + require.NoError(t, err) + require.NotNil(t, p) + + expected := models.PSPPayment{ + ParentReference: "", + Reference: transaction.ID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(10000), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_PENDING, + SourceAccountReference: pointer.For("test"), + } + + comparePSPPayments(t, expected, *p) + }) + + t.Run("other payment type - deleted status", func(t *testing.T) { + t.Parallel() + + transaction := client.Transaction{ + ID: "test", + AccountID: "test", + Currency: "EUR", + Type: "unknown", + Status: "deleted", + CreatedAt: now, + UpdatedAt: now, + Amount: "100", + } + + p, err := transactionToPayment(transaction) + require.NoError(t, err) + require.NotNil(t, p) + + expected := models.PSPPayment{ + ParentReference: "", + Reference: transaction.ID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_OTHER, + Amount: big.NewInt(10000), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_FAILED, + } + + comparePSPPayments(t, expected, *p) + }) + + t.Run("credit payment type - status unknown", func(t *testing.T) { + t.Parallel() + + transaction := client.Transaction{ + ID: "test", + AccountID: "test", + Currency: "EUR", + Type: "credit", + Status: "unknown", + CreatedAt: now, + UpdatedAt: now, + Amount: "100", + } + + p, err := transactionToPayment(transaction) + require.NoError(t, err) + require.NotNil(t, p) + + expected := models.PSPPayment{ + ParentReference: "", + Reference: transaction.ID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(10000), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_OTHER, + DestinationAccountReference: pointer.For("test"), + } + + comparePSPPayments(t, expected, *p) + }) +} + +func comparePSPPayments(t *testing.T, a, b models.PSPPayment) { + require.Equal(t, a.ParentReference, b.ParentReference) + require.Equal(t, a.Reference, b.Reference) + require.Equal(t, a.CreatedAt, b.CreatedAt) + require.Equal(t, a.Type, b.Type) + require.Equal(t, a.Amount, b.Amount) + require.Equal(t, a.Asset, b.Asset) + require.Equal(t, a.Scheme, b.Scheme) + require.Equal(t, a.Status, b.Status) + + switch { + case a.SourceAccountReference != nil && b.SourceAccountReference != nil: + require.Equal(t, *a.SourceAccountReference, *b.SourceAccountReference) + case a.SourceAccountReference == nil && b.SourceAccountReference == nil: + default: + t.Fatalf("SourceAccountReference mismatch: %v != %v", a.SourceAccountReference, b.SourceAccountReference) + } + + switch { + case a.DestinationAccountReference != nil && b.DestinationAccountReference != nil: + require.Equal(t, *a.DestinationAccountReference, *b.DestinationAccountReference) + case a.DestinationAccountReference == nil && b.DestinationAccountReference == nil: + default: + t.Fatalf("DestinationAccountReference mismatch: %v != %v", a.DestinationAccountReference, b.DestinationAccountReference) + } + + require.Equal(t, len(a.Metadata), len(b.Metadata)) + for k, v := range a.Metadata { + require.Equal(t, v, b.Metadata[k]) + } +} diff --git a/internal/connectors/plugins/public/currencycloud/payouts.go b/internal/connectors/plugins/public/currencycloud/payouts.go new file mode 100644 index 00000000..4b3b7e41 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/payouts.go @@ -0,0 +1,88 @@ +package currencycloud + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validatePayoutRequest(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validatePayoutRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + contact, err := p.client.GetContactID(ctx, pi.SourceAccount.Reference) + if err != nil { + return models.PSPPayment{}, err + } + + resp, err := p.client.InitiatePayout(ctx, &client.PayoutRequest{ + OnBehalfOf: contact.ID, + BeneficiaryID: pi.DestinationAccount.Reference, + Currency: curr, + Amount: json.Number(amount), + Reference: pi.Description, + UniqueRequestID: pi.Reference, + }) + if err != nil { + return models.PSPPayment{}, err + } + + return translatePayoutToPayment(resp, pi.SourceAccount.Reference) +} + +func translatePayoutToPayment(from *client.PayoutResponse, sourceAccountReference string) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + precision, ok := supportedCurrenciesWithDecimal[from.Currency] + if !ok { + return models.PSPPayment{}, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.Amount.String(), precision) + if err != nil { + return models.PSPPayment{}, err + } + + return models.PSPPayment{ + Reference: from.ID, + CreatedAt: from.CreatedAt, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchTransactionStatus(from.Status), + SourceAccountReference: &sourceAccountReference, + DestinationAccountReference: &from.BeneficiaryID, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/payouts_test.go b/internal/connectors/plugins/public/currencycloud/payouts_test.go new file mode 100644 index 00000000..91ff3a5a --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/payouts_test.go @@ -0,0 +1,187 @@ +package currencycloud + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("CurrencyCloud Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - get contactID error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetContactID(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - initiate payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetContactID(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Contact{ID: "1"}, nil) + + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + OnBehalfOf: "1", + BeneficiaryID: samplePSPPaymentInitiation.DestinationAccount.Reference, + Currency: "EUR", + Amount: "1.00", + Reference: samplePSPPaymentInitiation.Description, + UniqueRequestID: samplePSPPaymentInitiation.Reference, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().GetContactID(gomock.Any(), samplePSPPaymentInitiation.SourceAccount.Reference). + Return(&client.Contact{ID: "1"}, nil) + + trResponse := client.PayoutResponse{ + ID: "test1", + Amount: "1.00", + BeneficiaryID: "acc2", + Currency: "EUR", + Reference: samplePSPPaymentInitiation.Description, + Status: "ready_to_send", + CreatedAt: now, + } + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + OnBehalfOf: "1", + BeneficiaryID: samplePSPPaymentInitiation.DestinationAccount.Reference, + Currency: "EUR", + Amount: "1.00", + Reference: samplePSPPaymentInitiation.Description, + UniqueRequestID: samplePSPPaymentInitiation.Reference, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "test1", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_PENDING, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + })) + }) + + }) +}) diff --git a/internal/connectors/plugins/public/currencycloud/plugin.go b/internal/connectors/plugins/public/currencycloud/plugin.go new file mode 100644 index 00000000..eac3099d --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/plugin.go @@ -0,0 +1,139 @@ +package currencycloud + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("currencycloud", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New(config.LoginID, config.APIKey, config.Endpoint) + + return &Plugin{ + name: name, + client: client, + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + return models.CreateTransferResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + return models.CreatePayoutResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/currencycloud/plugin_test.go b/internal/connectors/plugins/public/currencycloud/plugin_test.go new file mode 100644 index 00000000..db98eb66 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/plugin_test.go @@ -0,0 +1,190 @@ +package currencycloud + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CurrencyCloud Plugin Suite") +} + +var _ = Describe("CurrencyCloud Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - loginID", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey": "test", "endpoint": "test"}`) + _, err := New("currencycloud", config) + Expect(err).To(MatchError("missing clientID in config: invalid config")) + }) + + It("should report errors in config - apiKey", func(ctx SpecContext) { + config := json.RawMessage(`{"loginID": "test", "endpoint": "test"}`) + _, err := New("currencycloud", config) + Expect(err).To(MatchError("missing api key in config: invalid config")) + }) + + It("should report errors in config - endpoint", func(ctx SpecContext) { + config := json.RawMessage(`{"loginID": "test", "apiKey": "test"}`) + _, err := New("currencycloud", config) + Expect(err).To(MatchError("missing endpoint in config: invalid config")) + }) + + It("should return valid install response", func(ctx SpecContext) { + config := json.RawMessage(`{"loginID": "test", "apiKey": "test", "endpoint": "test"}`) + _, err := New("currencycloud", config) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in external_accounts_test.go + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create bank account", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create transfer", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in transfers_test.go + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payouts_test.go + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create webhooks", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("translate webhook", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/currencycloud/transfers.go b/internal/connectors/plugins/public/currencycloud/transfers.go new file mode 100644 index 00000000..3a492967 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/transfers.go @@ -0,0 +1,86 @@ +package currencycloud + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validateTransferRequest(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validateTransferRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + resp, err := p.client.InitiateTransfer( + ctx, + &client.TransferRequest{ + SourceAccountID: pi.SourceAccount.Reference, + DestinationAccountID: pi.DestinationAccount.Reference, + Currency: curr, + Amount: json.Number(amount), + Reason: pi.Description, + UniqueRequestID: pi.Reference, + }, + ) + if err != nil { + return models.PSPPayment{}, err + } + + return translateTransferToPayment(resp) +} + +func translateTransferToPayment(from *client.TransferResponse) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + precision, ok := supportedCurrenciesWithDecimal[from.Currency] + if !ok { + return models.PSPPayment{}, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.Amount.String(), precision) + if err != nil { + return models.PSPPayment{}, err + } + + return models.PSPPayment{ + Reference: from.ID, + CreatedAt: from.CreatedAt, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchTransactionStatus(from.Status), + SourceAccountReference: &from.SourceAccountID, + DestinationAccountReference: &from.DestinationAccountID, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/currencycloud/transfers_test.go b/internal/connectors/plugins/public/currencycloud/transfers_test.go new file mode 100644 index 00000000..c975c2d8 --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/transfers_test.go @@ -0,0 +1,167 @@ +package currencycloud + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("CurrencyCloud Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - initiate transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiateTransfer(gomock.Any(), &client.TransferRequest{ + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + DestinationAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + Currency: "EUR", + Amount: "1.00", + Reason: samplePSPPaymentInitiation.Description, + UniqueRequestID: samplePSPPaymentInitiation.Reference, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.TransferResponse{ + ID: "test1", + SourceAccountID: "acc1", + DestinationAccountID: "acc2", + Currency: "EUR", + Amount: "1.00", + Status: "completed", + CreatedAt: now, + } + m.EXPECT().InitiateTransfer(gomock.Any(), &client.TransferRequest{ + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + DestinationAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + Currency: "EUR", + Amount: "1.00", + Reason: samplePSPPaymentInitiation.Description, + UniqueRequestID: samplePSPPaymentInitiation.Reference, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "test1", + CreatedAt: now, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + })) + }) + + }) +}) diff --git a/internal/connectors/plugins/public/currencycloud/utils.go b/internal/connectors/plugins/public/currencycloud/utils.go new file mode 100644 index 00000000..7cb8adbf --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/utils.go @@ -0,0 +1,18 @@ +package currencycloud + +func shouldFetchMore[T any](ret []T, nextPage, pageSize int) (bool, bool, []T) { + switch { + case len(ret) > pageSize: + return false, true, ret[:pageSize] + case len(ret) == pageSize: + if nextPage != -1 { + // more pages, but we have enough + return false, true, ret + } + return false, false, ret + case nextPage == -1: + // No more pages + return false, false, ret + } + return true, true, ret +} diff --git a/internal/connectors/plugins/public/currencycloud/workflow.go b/internal/connectors/plugins/public/currencycloud/workflow.go new file mode 100644 index 00000000..bbf13a1b --- /dev/null +++ b/internal/connectors/plugins/public/currencycloud/workflow.go @@ -0,0 +1,33 @@ +package currencycloud + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_beneficiaries", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/dummypay/capabilities.go b/internal/connectors/plugins/public/dummypay/capabilities.go new file mode 100644 index 00000000..2b9e283f --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/capabilities.go @@ -0,0 +1,15 @@ +package dummypay + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + + models.CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION, + models.CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION, + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, +} diff --git a/internal/connectors/plugins/public/dummypay/client/account.go b/internal/connectors/plugins/public/dummypay/client/account.go new file mode 100644 index 00000000..2ee8a167 --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/client/account.go @@ -0,0 +1,10 @@ +package client + +import "time" + +type Account struct { + ID string `json:"id"` + Name string `json:"name"` + OpeningDate time.Time `json:"opening_date"` + Currency string `json:"currency"` +} diff --git a/internal/connectors/plugins/public/dummypay/client/balance.go b/internal/connectors/plugins/public/dummypay/client/balance.go new file mode 100644 index 00000000..54580660 --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/client/balance.go @@ -0,0 +1,7 @@ +package client + +type Balance struct { + AccountID string `json:"account_id"` + AmountInMinors int64 `json:"amount_in_minors"` + Currency string `json:"currency"` +} diff --git a/internal/connectors/plugins/public/dummypay/client/client.go b/internal/connectors/plugins/public/dummypay/client/client.go new file mode 100644 index 00000000..e1fb086d --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/client/client.go @@ -0,0 +1,230 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "path" + "time" + + "github.com/formancehq/payments/internal/models" +) + +type Client interface { + FetchAccounts(ctx context.Context, startToken int, pageSize int) ([]models.PSPAccount, int, error) + FetchBalance(ctx context.Context, accountID string) (*models.PSPBalance, error) + CreatePayment(ctx context.Context, paymentType models.PaymentType, paymentInit models.PSPPaymentInitiation) (*models.PSPPayment, error) + ReversePayment(ctx context.Context, paymentType models.PaymentType, reversal models.PSPPaymentInitiationReversal) (models.PSPPayment, error) +} + +type client struct { + directory string +} + +func New(dir string) Client { + return &client{ + directory: dir, + } +} + +func (c *client) FetchAccounts(ctx context.Context, startToken int, pageSize int) ([]models.PSPAccount, int, error) { + b, err := c.readFile("accounts.json") + if err != nil { + return []models.PSPAccount{}, 0, fmt.Errorf("failed to fetch accounts: %w", err) + } + + pspAccounts := make([]models.PSPAccount, 0, pageSize) + if len(b) == 0 { + return pspAccounts, -1, nil + } + + accounts := make([]Account, 0) + err = json.Unmarshal(b, &accounts) + if err != nil { + return []models.PSPAccount{}, 0, fmt.Errorf("failed to unmarshal accounts: %w", err) + } + + next := -1 + for i := startToken; i < len(accounts); i++ { + if len(pspAccounts) >= pageSize { + if len(accounts)-startToken > len(pspAccounts) { + next = i + } + break + } + + account := accounts[i] + pspAccounts = append(pspAccounts, models.PSPAccount{ + Reference: account.ID, + CreatedAt: account.OpeningDate, + Name: &account.Name, + DefaultAsset: &account.Currency, + }) + } + return pspAccounts, next, nil +} + +func (c *client) FetchBalance(ctx context.Context, accountID string) (*models.PSPBalance, error) { + b, err := c.readFile("balances.json") + if err != nil { + return &models.PSPBalance{}, fmt.Errorf("failed to fetch balances: %w", err) + } + + balances := make([]Balance, 0) + if len(b) == 0 { + return nil, nil + } + + err = json.Unmarshal(b, &balances) + if err != nil { + return &models.PSPBalance{}, fmt.Errorf("failed to unmarshal balances: %w", err) + } + + for _, balance := range balances { + if balance.AccountID != accountID { + continue + } + return &models.PSPBalance{ + AccountReference: balance.AccountID, + CreatedAt: time.Now().Truncate(time.Second), + Asset: balance.Currency, + Amount: big.NewInt(balance.AmountInMinors), + }, nil + } + return &models.PSPBalance{}, nil +} + +func (c *client) CreatePayment( + ctx context.Context, + paymentType models.PaymentType, + paymentInit models.PSPPaymentInitiation, +) (*models.PSPPayment, error) { + balances := []Balance{ + { + AccountID: paymentInit.SourceAccount.Reference, + AmountInMinors: paymentInit.Amount.Int64(), + Currency: paymentInit.Asset, + }, + } + b, err := json.Marshal(&balances) + if err != nil { + return &models.PSPPayment{}, fmt.Errorf("failed to marshal new balance: %w", err) + } + + err = c.writeFile("balances.json", b) + if err != nil { + return &models.PSPPayment{}, fmt.Errorf("failed to write balance: %w", err) + } + + return &models.PSPPayment{ + Reference: paymentInit.Reference, + CreatedAt: paymentInit.CreatedAt, + Amount: paymentInit.Amount, + Asset: paymentInit.Asset, + Type: paymentType, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Scheme: models.PAYMENT_SCHEME_OTHER, + SourceAccountReference: &paymentInit.SourceAccount.Reference, + DestinationAccountReference: &paymentInit.DestinationAccount.Reference, + }, nil +} + +func (c *client) ReversePayment( + ctx context.Context, + paymentType models.PaymentType, + reversal models.PSPPaymentInitiationReversal, +) (models.PSPPayment, error) { + b, err := c.readFile("balances.json") + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to fetch balances: %w", err) + } + + balances := make([]Balance, 0) + if len(b) == 0 { + return models.PSPPayment{}, fmt.Errorf("no balance data found") + } + + err = json.Unmarshal(b, &balances) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to unmarshal balances: %w", err) + } + + var balanceUpdated bool + for _, balance := range balances { + if balance.AccountID == reversal.RelatedPaymentInitiation.SourceAccount.Reference { + if balance.AmountInMinors-reversal.Amount.Int64() < 0 { + return models.PSPPayment{}, fmt.Errorf("balance will be negative if %d is subtracted", reversal.Amount.Int64()) + } + balance.AmountInMinors = balance.AmountInMinors - reversal.Amount.Int64() + balanceUpdated = true + break + } + } + + if !balanceUpdated { + return models.PSPPayment{}, fmt.Errorf("no reversable balance found") + } + + b, err = json.Marshal(&balances) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to marshal new balance: %w", err) + } + + err = c.writeFile("balances.json", b) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to write balance: %w", err) + } + + return models.PSPPayment{ + Reference: reversal.Reference, + CreatedAt: reversal.CreatedAt, + Amount: reversal.Amount, + Asset: reversal.Asset, + Type: paymentType, + Status: models.PAYMENT_STATUS_REFUNDED, + Scheme: models.PAYMENT_SCHEME_OTHER, + SourceAccountReference: &reversal.RelatedPaymentInitiation.SourceAccount.Reference, + DestinationAccountReference: &reversal.RelatedPaymentInitiation.DestinationAccount.Reference, + }, nil +} + +func (c *client) writeFile(filename string, b []byte) error { + filePath := path.Join(c.directory, filename) + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to open %q for write: %w", filePath, err) + } + defer file.Close() + + _, err = file.Write(b) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filePath, err) + } + return nil +} + +func (c *client) readFile(filename string) (b []byte, err error) { + filePath := path.Join(c.directory, filename) + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + return b, nil + } + return b, fmt.Errorf("failed to open %q for read: %w", filePath, err) + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return b, fmt.Errorf("failed to stat file %q: %w", filePath, err) + } + + buf := make([]byte, fileInfo.Size()) + _, err = file.Read(buf) + if err != nil { + return b, fmt.Errorf("failed to read file %q: %w", filePath, err) + } + return buf, nil +} diff --git a/internal/connectors/plugins/public/dummypay/config.go b/internal/connectors/plugins/public/dummypay/config.go new file mode 100644 index 00000000..92eedec8 --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/config.go @@ -0,0 +1,28 @@ +package dummypay + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/models" +) + +type Config struct { + Directory string `json:"directory"` +} + +func (c Config) validate() error { + if c.Directory == "" { + return fmt.Errorf("missing directory in config: %w", models.ErrInvalidConfig) + } + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/dummypay/config.json b/internal/connectors/plugins/public/dummypay/config.json new file mode 100644 index 00000000..6aaff24a --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/config.json @@ -0,0 +1,7 @@ +{ + "directory": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} diff --git a/internal/connectors/plugins/public/dummypay/install.go b/internal/connectors/plugins/public/dummypay/install.go new file mode 100644 index 00000000..7fc82936 --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/install.go @@ -0,0 +1,14 @@ +//go:build !it + +package dummypay + +import ( + "context" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) install(_ context.Context, _ models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{}, plugins.ErrNotImplemented +} diff --git a/internal/connectors/plugins/public/dummypay/install_integration_testing.go b/internal/connectors/plugins/public/dummypay/install_integration_testing.go new file mode 100644 index 00000000..b05e3d8a --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/install_integration_testing.go @@ -0,0 +1,15 @@ +//go:build it + +package dummypay + +import ( + "context" + + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) install(_ context.Context, _ models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} diff --git a/internal/connectors/plugins/public/dummypay/plugin.go b/internal/connectors/plugins/public/dummypay/plugin.go new file mode 100644 index 00000000..f29d717e --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/plugin.go @@ -0,0 +1,183 @@ +package dummypay + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/dummypay/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("dummypay", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + conf, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + return &Plugin{ + name: name, + client: client.New(conf.Directory), + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return p.install(ctx, req) +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +type accountsState struct { + NextToken int `json:"nextToken"` +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + accounts, next, err := p.client.FetchAccounts(ctx, oldState.NextToken, req.PageSize) + if err != nil { + return models.FetchNextAccountsResponse{}, fmt.Errorf("failed to fetch accounts from client: %w", err) + } + + newState := accountsState{ + NextToken: next, + } + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: next > 0, + }, nil +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balance, err := p.client.FetchBalance(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, fmt.Errorf("failed to fetch balance from client: %w", err) + } + + balances := make([]models.PSPBalance, 0, 1) + if balance != nil { + balances = append(balances, *balance) + } + + return models.FetchNextBalancesResponse{ + Balances: balances, + HasMore: false, + }, nil +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + return models.FetchNextPaymentsResponse{ + Payments: []models.PSPPayment{}, + NewState: json.RawMessage(`{}`), + HasMore: false, + }, nil +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + name := "dummypay-account" + bankAccount := models.PSPAccount{ + Reference: fmt.Sprintf("dummypay-%s", req.BankAccount.ID.String()), + CreatedAt: req.BankAccount.CreatedAt, + Name: &name, + Raw: json.RawMessage(`{}`), + } + return models.CreateBankAccountResponse{ + RelatedAccount: bankAccount, + }, nil +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + pspPayment, err := p.client.CreatePayment(ctx, models.PAYMENT_TYPE_TRANSFER, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, fmt.Errorf("failed to create transfer using client: %w", err) + } + return models.CreateTransferResponse{Payment: pspPayment}, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + pspPayment, err := p.client.ReversePayment(ctx, models.PAYMENT_TYPE_TRANSFER, req.PaymentInitiationReversal) + if err != nil { + return models.ReverseTransferResponse{}, fmt.Errorf("failed to reverse transfer using client: %w", err) + } + return models.ReverseTransferResponse{Payment: pspPayment}, nil +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + pspPayment, err := p.client.CreatePayment(ctx, models.PAYMENT_TYPE_PAYOUT, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, fmt.Errorf("failed to create transfer using client: %w", err) + } + return models.CreatePayoutResponse{Payment: pspPayment}, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + pspPayment, err := p.client.ReversePayment(ctx, models.PAYMENT_TYPE_PAYOUT, req.PaymentInitiationReversal) + if err != nil { + return models.ReversePayoutResponse{}, fmt.Errorf("failed to reverse payout using client: %w", err) + } + return models.ReversePayoutResponse{Payment: pspPayment}, nil +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/dummypay/workflow.go b/internal/connectors/plugins/public/dummypay/workflow.go new file mode 100644 index 00000000..805be4d8 --- /dev/null +++ b/internal/connectors/plugins/public/dummypay/workflow.go @@ -0,0 +1,28 @@ +package dummypay + +import "github.com/formancehq/payments/internal/models" + +//nolint:unused +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/generic/accounts.go b/internal/connectors/plugins/public/generic/accounts.go new file mode 100644 index 00000000..784d1711 --- /dev/null +++ b/internal/connectors/plugins/public/generic/accounts.go @@ -0,0 +1,97 @@ +package generic + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type accountsState struct { + LastCreatedAtFrom time.Time `json:"lastCreatedAtFrom"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + newState := accountsState{ + LastCreatedAtFrom: oldState.LastCreatedAtFrom, + } + + accounts := make([]models.PSPAccount, 0) + needMore := false + hasMore := false + for page := 1; ; page++ { + pagedAccounts, err := p.client.ListAccounts(ctx, int64(page), int64(req.PageSize), oldState.LastCreatedAtFrom) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + accounts, err = fillAccounts(pagedAccounts, accounts, oldState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastCreatedAtFrom = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillAccounts( + pagedAccounts []genericclient.Account, + accounts []models.PSPAccount, + oldState accountsState, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + switch account.CreatedAt.Compare(oldState.LastCreatedAtFrom) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.Id, + CreatedAt: account.CreatedAt, + Name: &account.AccountName, + Metadata: account.Metadata, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/generic/accounts_test.go b/internal/connectors/plugins/public/generic/accounts_test.go new file mode 100644 index 00000000..1434be98 --- /dev/null +++ b/internal/connectors/plugins/public/generic/accounts_test.go @@ -0,0 +1,169 @@ +package generic + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/connectors/plugins/public/generic/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Generic Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []genericclient.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]genericclient.Account, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, genericclient.Account{ + Id: fmt.Sprint(i), + AccountName: fmt.Sprintf("account-%d", i), + CreatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + Metadata: map[string]string{ + "foo": "bar", + }, + }) + } + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListAccounts(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + []genericclient.Account{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListAccounts(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + []genericclient.Account{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAtFrom.IsZero()).To(BeTrue()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListAccounts(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAtFrom.UTC()).To(Equal(sampleAccounts[49].CreatedAt.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().ListAccounts(gomock.Any(), int64(1), int64(40), time.Time{}).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastCreatedAtFrom.UTC()).To(Equal(sampleAccounts[39].CreatedAt.UTC())) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastCreatedAtFrom": "%s"}`, sampleAccounts[38].CreatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().ListAccounts(gomock.Any(), int64(1), int64(40), sampleAccounts[38].CreatedAt.UTC()).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().ListAccounts(gomock.Any(), int64(2), int64(40), sampleAccounts[38].CreatedAt.UTC()).Return( + sampleAccounts[40:], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAtFrom.UTC()).To(Equal(sampleAccounts[49].CreatedAt.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/generic/balances.go b/internal/connectors/plugins/public/generic/balances.go new file mode 100644 index 00000000..ba4c915b --- /dev/null +++ b/internal/connectors/plugins/public/generic/balances.go @@ -0,0 +1,47 @@ +package generic + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, fmt.Errorf("from payload is required: %w", models.ErrInvalidRequest) + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balances, err := p.client.GetBalances(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + var res []models.PSPBalance + for _, balance := range balances.Balances { + var amount big.Int + _, ok := amount.SetString(balance.Amount, 10) + if !ok { + return models.FetchNextBalancesResponse{}, fmt.Errorf("failed to parse amount: %s", balance.Amount) + } + + res = append(res, models.PSPBalance{ + AccountReference: balances.AccountID, + CreatedAt: balances.At, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Currency), + }) + } + + return models.FetchNextBalancesResponse{ + Balances: res, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/generic/balances_test.go b/internal/connectors/plugins/public/generic/balances_test.go new file mode 100644 index 00000000..fe7ccb14 --- /dev/null +++ b/internal/connectors/plugins/public/generic/balances_test.go @@ -0,0 +1,105 @@ +package generic + +import ( + "errors" + "math/big" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/connectors/plugins/public/generic/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Generic Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next balances", func() { + var ( + m *client.MockClient + sampleBalance genericclient.Balances + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBalance = genericclient.Balances{ + Id: "1", + AccountID: "123", + At: now, + Balances: []genericclient.Balance{ + { + Amount: "100", + Currency: "USD", + }, + { + Amount: "15001", + Currency: "EUR", + }, + }, + } + }) + + It("should return an error - missing payload", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("from payload is required: invalid request")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should return an error - get balances error", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetBalances(gomock.Any(), "test").Return( + &sampleBalance, + errors.New("test error"), + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should fetch all balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetBalances(gomock.Any(), "test").Return( + &sampleBalance, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(2)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + Expect(resp.Balances[0].Amount).To(Equal(big.NewInt(100))) + Expect(resp.Balances[0].Asset).To(Equal("USD/2")) + Expect(resp.Balances[1].Amount).To(Equal(big.NewInt(15001))) + Expect(resp.Balances[1].Asset).To(Equal("EUR/2")) + }) + }) +}) diff --git a/internal/connectors/plugins/public/generic/capabilities.go b/internal/connectors/plugins/public/generic/capabilities.go new file mode 100644 index 00000000..95bfd597 --- /dev/null +++ b/internal/connectors/plugins/public/generic/capabilities.go @@ -0,0 +1,13 @@ +package generic + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + + models.CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION, + models.CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION, +} diff --git a/internal/connectors/plugins/public/generic/client/accounts.go b/internal/connectors/plugins/public/generic/client/accounts.go new file mode 100644 index 00000000..53a2f8a1 --- /dev/null +++ b/internal/connectors/plugins/public/generic/client/accounts.go @@ -0,0 +1,29 @@ +package client + +import ( + "context" + "time" + + "github.com/formancehq/payments/genericclient" +) + +func (c *client) ListAccounts(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Account, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_accounts") + + req := c.apiClient.DefaultApi. + GetAccounts(ctx). + Page(page). + PageSize(pageSize) + + if !createdAtFrom.IsZero() { + req = req.CreatedAtFrom(createdAtFrom) + } + + accounts, _, err := req.Execute() + if err != nil { + return nil, err + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/generic/client/balances.go b/internal/connectors/plugins/public/generic/client/balances.go new file mode 100644 index 00000000..b16fe63c --- /dev/null +++ b/internal/connectors/plugins/public/generic/client/balances.go @@ -0,0 +1,22 @@ +package client + +import ( + "context" + "time" + + "github.com/formancehq/payments/genericclient" +) + +func (c *client) GetBalances(ctx context.Context, accountID string) (*genericclient.Balances, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_balances") + + req := c.apiClient.DefaultApi.GetAccountBalances(ctx, accountID) + + balances, _, err := req.Execute() + if err != nil { + return nil, err + } + + return balances, nil +} diff --git a/internal/connectors/plugins/public/generic/client/beneficiaries.go b/internal/connectors/plugins/public/generic/client/beneficiaries.go new file mode 100644 index 00000000..674462f1 --- /dev/null +++ b/internal/connectors/plugins/public/generic/client/beneficiaries.go @@ -0,0 +1,29 @@ +package client + +import ( + "context" + "time" + + "github.com/formancehq/payments/genericclient" +) + +func (c *client) ListBeneficiaries(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Beneficiary, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_beneficiaries") + + req := c.apiClient.DefaultApi. + GetBeneficiaries(ctx). + Page(page). + PageSize(pageSize) + + if !createdAtFrom.IsZero() { + req = req.CreatedAtFrom(createdAtFrom) + } + + beneficiaries, _, err := req.Execute() + if err != nil { + return nil, err + } + + return beneficiaries, nil +} diff --git a/internal/connectors/plugins/public/generic/client/client.go b/internal/connectors/plugins/public/generic/client/client.go new file mode 100644 index 00000000..c5993c8e --- /dev/null +++ b/internal/connectors/plugins/public/generic/client/client.go @@ -0,0 +1,77 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/connectors/metrics" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + ListAccounts(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Account, error) + GetBalances(ctx context.Context, accountID string) (*genericclient.Balances, error) + ListBeneficiaries(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Beneficiary, error) + ListTransactions(ctx context.Context, page, pageSize int64, updatedAtFrom time.Time) ([]genericclient.Transaction, error) +} + +type apiTransport struct { + APIKey string + underlying http.RoundTripper +} + +func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", t.APIKey)) + + return t.underlying.RoundTrip(req) +} + +type client struct { + apiClient *genericclient.APIClient + commonMetricAttributes []attribute.KeyValue +} + +func New(apiKey, baseURL string) Client { + httpClient := &http.Client{ + Transport: &apiTransport{ + APIKey: apiKey, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + } + + configuration := genericclient.NewConfiguration() + configuration.HTTPClient = httpClient + configuration.Servers[0].URL = baseURL + + genericClient := genericclient.NewAPIClient(configuration) + + return &client{ + apiClient: genericClient, + commonMetricAttributes: CommonMetricsAttributes(), + } +} + +// recordMetrics is meant to be called in a defer +func (c *client) recordMetrics(ctx context.Context, start time.Time, operation string) { + registry := metrics.GetMetricsRegistry() + + attrs := c.commonMetricAttributes + attrs = append(attrs, attribute.String("operation", operation)) + opts := metric.WithAttributes(attrs...) + + registry.ConnectorPSPCalls().Add(ctx, 1, opts) + registry.ConnectorPSPCallLatencies().Record(ctx, time.Since(start).Milliseconds(), opts) +} + +func CommonMetricsAttributes() []attribute.KeyValue { + metricsAttributes := []attribute.KeyValue{ + attribute.String("connector", "generic"), + } + return metricsAttributes +} diff --git a/internal/connectors/plugins/public/generic/client/client_generated.go b/internal/connectors/plugins/public/generic/client/client_generated.go new file mode 100644 index 00000000..287c1463 --- /dev/null +++ b/internal/connectors/plugins/public/generic/client/client_generated.go @@ -0,0 +1,103 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + genericclient "github.com/formancehq/payments/genericclient" + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetBalances mocks base method. +func (m *MockClient) GetBalances(ctx context.Context, accountID string) (*genericclient.Balances, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBalances", ctx, accountID) + ret0, _ := ret[0].(*genericclient.Balances) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBalances indicates an expected call of GetBalances. +func (mr *MockClientMockRecorder) GetBalances(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBalances", reflect.TypeOf((*MockClient)(nil).GetBalances), ctx, accountID) +} + +// ListAccounts mocks base method. +func (m *MockClient) ListAccounts(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAccounts", ctx, page, pageSize, createdAtFrom) + ret0, _ := ret[0].([]genericclient.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAccounts indicates an expected call of ListAccounts. +func (mr *MockClientMockRecorder) ListAccounts(ctx, page, pageSize, createdAtFrom any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAccounts", reflect.TypeOf((*MockClient)(nil).ListAccounts), ctx, page, pageSize, createdAtFrom) +} + +// ListBeneficiaries mocks base method. +func (m *MockClient) ListBeneficiaries(ctx context.Context, page, pageSize int64, createdAtFrom time.Time) ([]genericclient.Beneficiary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListBeneficiaries", ctx, page, pageSize, createdAtFrom) + ret0, _ := ret[0].([]genericclient.Beneficiary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListBeneficiaries indicates an expected call of ListBeneficiaries. +func (mr *MockClientMockRecorder) ListBeneficiaries(ctx, page, pageSize, createdAtFrom any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBeneficiaries", reflect.TypeOf((*MockClient)(nil).ListBeneficiaries), ctx, page, pageSize, createdAtFrom) +} + +// ListTransactions mocks base method. +func (m *MockClient) ListTransactions(ctx context.Context, page, pageSize int64, updatedAtFrom time.Time) ([]genericclient.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTransactions", ctx, page, pageSize, updatedAtFrom) + ret0, _ := ret[0].([]genericclient.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListTransactions indicates an expected call of ListTransactions. +func (mr *MockClientMockRecorder) ListTransactions(ctx, page, pageSize, updatedAtFrom any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTransactions", reflect.TypeOf((*MockClient)(nil).ListTransactions), ctx, page, pageSize, updatedAtFrom) +} diff --git a/cmd/connectors/internal/connectors/generic/client/generated/.gitignore b/internal/connectors/plugins/public/generic/client/generated/.gitignore similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/.gitignore rename to internal/connectors/plugins/public/generic/client/generated/.gitignore diff --git a/cmd/connectors/internal/connectors/generic/client/generated/.openapi-generator-ignore b/internal/connectors/plugins/public/generic/client/generated/.openapi-generator-ignore similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/.openapi-generator-ignore rename to internal/connectors/plugins/public/generic/client/generated/.openapi-generator-ignore diff --git a/cmd/connectors/internal/connectors/generic/client/generated/.openapi-generator/FILES b/internal/connectors/plugins/public/generic/client/generated/.openapi-generator/FILES similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/.openapi-generator/FILES rename to internal/connectors/plugins/public/generic/client/generated/.openapi-generator/FILES diff --git a/cmd/connectors/internal/connectors/generic/client/generated/.openapi-generator/VERSION b/internal/connectors/plugins/public/generic/client/generated/.openapi-generator/VERSION similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/.openapi-generator/VERSION rename to internal/connectors/plugins/public/generic/client/generated/.openapi-generator/VERSION diff --git a/cmd/connectors/internal/connectors/generic/client/generated/.travis.yml b/internal/connectors/plugins/public/generic/client/generated/.travis.yml similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/.travis.yml rename to internal/connectors/plugins/public/generic/client/generated/.travis.yml diff --git a/cmd/connectors/internal/connectors/generic/client/generated/README.md b/internal/connectors/plugins/public/generic/client/generated/README.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/README.md rename to internal/connectors/plugins/public/generic/client/generated/README.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/api/openapi.yaml b/internal/connectors/plugins/public/generic/client/generated/api/openapi.yaml similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/api/openapi.yaml rename to internal/connectors/plugins/public/generic/client/generated/api/openapi.yaml diff --git a/cmd/connectors/internal/connectors/generic/client/generated/api_default.go b/internal/connectors/plugins/public/generic/client/generated/api_default.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/api_default.go rename to internal/connectors/plugins/public/generic/client/generated/api_default.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/client.go b/internal/connectors/plugins/public/generic/client/generated/client.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/client.go rename to internal/connectors/plugins/public/generic/client/generated/client.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/configuration.go b/internal/connectors/plugins/public/generic/client/generated/configuration.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/configuration.go rename to internal/connectors/plugins/public/generic/client/generated/configuration.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/Account.md b/internal/connectors/plugins/public/generic/client/generated/docs/Account.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/Account.md rename to internal/connectors/plugins/public/generic/client/generated/docs/Account.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/Balance.md b/internal/connectors/plugins/public/generic/client/generated/docs/Balance.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/Balance.md rename to internal/connectors/plugins/public/generic/client/generated/docs/Balance.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/Balances.md b/internal/connectors/plugins/public/generic/client/generated/docs/Balances.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/Balances.md rename to internal/connectors/plugins/public/generic/client/generated/docs/Balances.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/Beneficiary.md b/internal/connectors/plugins/public/generic/client/generated/docs/Beneficiary.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/Beneficiary.md rename to internal/connectors/plugins/public/generic/client/generated/docs/Beneficiary.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/DefaultApi.md b/internal/connectors/plugins/public/generic/client/generated/docs/DefaultApi.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/DefaultApi.md rename to internal/connectors/plugins/public/generic/client/generated/docs/DefaultApi.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/Error.md b/internal/connectors/plugins/public/generic/client/generated/docs/Error.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/Error.md rename to internal/connectors/plugins/public/generic/client/generated/docs/Error.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/Transaction.md b/internal/connectors/plugins/public/generic/client/generated/docs/Transaction.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/Transaction.md rename to internal/connectors/plugins/public/generic/client/generated/docs/Transaction.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/TransactionStatus.md b/internal/connectors/plugins/public/generic/client/generated/docs/TransactionStatus.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/TransactionStatus.md rename to internal/connectors/plugins/public/generic/client/generated/docs/TransactionStatus.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/docs/TransactionType.md b/internal/connectors/plugins/public/generic/client/generated/docs/TransactionType.md similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/docs/TransactionType.md rename to internal/connectors/plugins/public/generic/client/generated/docs/TransactionType.md diff --git a/cmd/connectors/internal/connectors/generic/client/generated/git_push.sh b/internal/connectors/plugins/public/generic/client/generated/git_push.sh similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/git_push.sh rename to internal/connectors/plugins/public/generic/client/generated/git_push.sh diff --git a/cmd/connectors/internal/connectors/generic/client/generated/go.mod b/internal/connectors/plugins/public/generic/client/generated/go.mod similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/go.mod rename to internal/connectors/plugins/public/generic/client/generated/go.mod diff --git a/internal/connectors/plugins/public/generic/client/generated/go.sum b/internal/connectors/plugins/public/generic/client/generated/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_account.go b/internal/connectors/plugins/public/generic/client/generated/model_account.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_account.go rename to internal/connectors/plugins/public/generic/client/generated/model_account.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_balance.go b/internal/connectors/plugins/public/generic/client/generated/model_balance.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_balance.go rename to internal/connectors/plugins/public/generic/client/generated/model_balance.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_balances.go b/internal/connectors/plugins/public/generic/client/generated/model_balances.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_balances.go rename to internal/connectors/plugins/public/generic/client/generated/model_balances.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_beneficiary.go b/internal/connectors/plugins/public/generic/client/generated/model_beneficiary.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_beneficiary.go rename to internal/connectors/plugins/public/generic/client/generated/model_beneficiary.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_error.go b/internal/connectors/plugins/public/generic/client/generated/model_error.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_error.go rename to internal/connectors/plugins/public/generic/client/generated/model_error.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_transaction.go b/internal/connectors/plugins/public/generic/client/generated/model_transaction.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_transaction.go rename to internal/connectors/plugins/public/generic/client/generated/model_transaction.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_transaction_status.go b/internal/connectors/plugins/public/generic/client/generated/model_transaction_status.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_transaction_status.go rename to internal/connectors/plugins/public/generic/client/generated/model_transaction_status.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/model_transaction_type.go b/internal/connectors/plugins/public/generic/client/generated/model_transaction_type.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/model_transaction_type.go rename to internal/connectors/plugins/public/generic/client/generated/model_transaction_type.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/response.go b/internal/connectors/plugins/public/generic/client/generated/response.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/response.go rename to internal/connectors/plugins/public/generic/client/generated/response.go diff --git a/cmd/connectors/internal/connectors/generic/client/generated/utils.go b/internal/connectors/plugins/public/generic/client/generated/utils.go similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generated/utils.go rename to internal/connectors/plugins/public/generic/client/generated/utils.go diff --git a/cmd/connectors/internal/connectors/generic/client/generic-openapi.yaml b/internal/connectors/plugins/public/generic/client/generic-openapi.yaml similarity index 100% rename from cmd/connectors/internal/connectors/generic/client/generic-openapi.yaml rename to internal/connectors/plugins/public/generic/client/generic-openapi.yaml diff --git a/internal/connectors/plugins/public/generic/client/transactions.go b/internal/connectors/plugins/public/generic/client/transactions.go new file mode 100644 index 00000000..7d04a62d --- /dev/null +++ b/internal/connectors/plugins/public/generic/client/transactions.go @@ -0,0 +1,28 @@ +package client + +import ( + "context" + "time" + + "github.com/formancehq/payments/genericclient" +) + +func (c *client) ListTransactions(ctx context.Context, page, pageSize int64, updatedAtFrom time.Time) ([]genericclient.Transaction, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_transactions") + + req := c.apiClient.DefaultApi.GetTransactions(ctx). + Page(page). + PageSize(pageSize) + + if !updatedAtFrom.IsZero() { + req = req.UpdatedAtFrom(updatedAtFrom) + } + + transactions, _, err := req.Execute() + if err != nil { + return nil, err + } + + return transactions, nil +} diff --git a/internal/connectors/plugins/public/generic/config.go b/internal/connectors/plugins/public/generic/config.go new file mode 100644 index 00000000..471210f6 --- /dev/null +++ b/internal/connectors/plugins/public/generic/config.go @@ -0,0 +1,35 @@ +package generic + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +func (c Config) validate() error { + if c.APIKey == "" { + return fmt.Errorf("missing api key in config: %w", models.ErrInvalidConfig) + } + + if c.Endpoint == "" { + return fmt.Errorf("missing endpoint in config: %w", models.ErrInvalidConfig) + } + + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/generic/config.json b/internal/connectors/plugins/public/generic/config.json new file mode 100644 index 00000000..c68daecf --- /dev/null +++ b/internal/connectors/plugins/public/generic/config.json @@ -0,0 +1,12 @@ +{ + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "endpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/generic/currencies.go b/internal/connectors/plugins/public/generic/currencies.go new file mode 100644 index 00000000..d10e6518 --- /dev/null +++ b/internal/connectors/plugins/public/generic/currencies.go @@ -0,0 +1,7 @@ +package generic + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + supportedCurrenciesWithDecimal = currency.ISO4217Currencies +) diff --git a/internal/connectors/plugins/public/generic/external_accounts.go b/internal/connectors/plugins/public/generic/external_accounts.go new file mode 100644 index 00000000..731e8001 --- /dev/null +++ b/internal/connectors/plugins/public/generic/external_accounts.go @@ -0,0 +1,97 @@ +package generic + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type externalAccountsState struct { + LastCreatedAtFrom time.Time `json:"lastCreatedAtFrom"` +} + +func (p *Plugin) fetchExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + + newState := externalAccountsState{ + LastCreatedAtFrom: oldState.LastCreatedAtFrom, + } + + accounts := make([]models.PSPAccount, 0) + needMore := false + hasMore := false + for page := 1; ; page++ { + pagedExternalAccounts, err := p.client.ListBeneficiaries(ctx, int64(page), int64(req.PageSize), oldState.LastCreatedAtFrom) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + accounts, err = fillExternalAccounts(pagedExternalAccounts, accounts, oldState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedExternalAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastCreatedAtFrom = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillExternalAccounts( + pagedExternalAccounts []genericclient.Beneficiary, + accounts []models.PSPAccount, + oldState externalAccountsState, +) ([]models.PSPAccount, error) { + for _, account := range pagedExternalAccounts { + switch account.CreatedAt.Compare(oldState.LastCreatedAtFrom) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.Id, + CreatedAt: account.CreatedAt, + Name: &account.OwnerName, + Metadata: account.Metadata, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/generic/external_accounts_test.go b/internal/connectors/plugins/public/generic/external_accounts_test.go new file mode 100644 index 00000000..40f107bb --- /dev/null +++ b/internal/connectors/plugins/public/generic/external_accounts_test.go @@ -0,0 +1,167 @@ +package generic + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/connectors/plugins/public/generic/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Generic Plugin External Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next external accounts", func() { + var ( + m *client.MockClient + sampleAccounts []genericclient.Beneficiary + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]genericclient.Beneficiary, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, genericclient.Beneficiary{ + Id: fmt.Sprint(i), + CreatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + OwnerName: fmt.Sprintf("owner-%d", i), + Metadata: map[string]string{"foo": "bar"}, + }) + } + }) + + It("should return an error - get external accounts error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListBeneficiaries(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + []genericclient.Beneficiary{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListBeneficiaries(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + []genericclient.Beneficiary{}, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAtFrom.IsZero()).To(BeTrue()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListBeneficiaries(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAtFrom.UTC()).To(Equal(sampleAccounts[49].CreatedAt.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().ListBeneficiaries(gomock.Any(), int64(1), int64(40), time.Time{}).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastCreatedAtFrom.UTC()).To(Equal(sampleAccounts[39].CreatedAt.UTC())) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastCreatedAtFrom": "%s"}`, sampleAccounts[38].CreatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().ListBeneficiaries(gomock.Any(), int64(1), int64(40), sampleAccounts[38].CreatedAt.UTC()).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().ListBeneficiaries(gomock.Any(), int64(2), int64(40), sampleAccounts[38].CreatedAt.UTC()).Return( + sampleAccounts[40:], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAtFrom.UTC()).To(Equal(sampleAccounts[49].CreatedAt.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/generic/payments.go b/internal/connectors/plugins/public/generic/payments.go new file mode 100644 index 00000000..f4510a96 --- /dev/null +++ b/internal/connectors/plugins/public/generic/payments.go @@ -0,0 +1,161 @@ +package generic + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "time" + + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type paymentsState struct { + LastUpdatedAtFrom time.Time `json:"lastUpdatedAtFrom"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + newState := paymentsState{ + LastUpdatedAtFrom: oldState.LastUpdatedAtFrom, + } + + payments := make([]models.PSPPayment, 0) + updatedAts := make([]time.Time, 0) + needMore := false + hasMore := false + for page := 1; ; page++ { + pagedPayments, err := p.client.ListTransactions(ctx, int64(page), int64(req.PageSize), oldState.LastUpdatedAtFrom) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, updatedAts, err = fillPayments(pagedPayments, payments, updatedAts, oldState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedPayments, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + payments = payments[:req.PageSize] + updatedAts = updatedAts[:req.PageSize] + } + + if len(updatedAts) > 0 { + newState.LastUpdatedAtFrom = updatedAts[len(payments)-1] + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillPayments( + pagedPayments []genericclient.Transaction, + payments []models.PSPPayment, + updatedAts []time.Time, + oldState paymentsState, +) ([]models.PSPPayment, []time.Time, error) { + for _, payment := range pagedPayments { + switch payment.UpdatedAt.Compare(oldState.LastUpdatedAtFrom) { + case -1, 0: + // Payment already ingested, skip + continue + default: + } + + raw, err := json.Marshal(payment) + if err != nil { + return nil, nil, err + } + + paymentType := matchPaymentType(payment.Type) + paymentStatus := matchPaymentStatus(payment.Status) + + var amount big.Int + _, ok := amount.SetString(payment.Amount, 10) + if !ok { + return nil, nil, fmt.Errorf("failed to parse amount: %s", payment.Amount) + } + + p := models.PSPPayment{ + Reference: payment.Id, + CreatedAt: payment.CreatedAt, + Type: paymentType, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, payment.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: paymentStatus, + Metadata: payment.Metadata, + Raw: raw, + } + + if payment.RelatedTransactionID != nil { + p.Reference = *payment.RelatedTransactionID + } + + if payment.SourceAccountID != nil { + p.SourceAccountReference = payment.SourceAccountID + } + + if payment.DestinationAccountID != nil { + p.DestinationAccountReference = payment.DestinationAccountID + } + + payments = append(payments, p) + updatedAts = append(updatedAts, payment.UpdatedAt) + } + + return payments, updatedAts, nil +} + +func matchPaymentType( + transactionType genericclient.TransactionType, +) models.PaymentType { + switch transactionType { + case genericclient.PAYIN: + return models.PAYMENT_TYPE_PAYIN + case genericclient.PAYOUT: + return models.PAYMENT_TYPE_PAYOUT + case genericclient.TRANSFER: + return models.PAYMENT_TYPE_TRANSFER + default: + return models.PAYMENT_TYPE_OTHER + } +} + +func matchPaymentStatus( + status genericclient.TransactionStatus, +) models.PaymentStatus { + switch status { + case genericclient.PENDING: + return models.PAYMENT_STATUS_PENDING + case genericclient.FAILED: + return models.PAYMENT_STATUS_FAILED + case genericclient.SUCCEEDED: + return models.PAYMENT_STATUS_SUCCEEDED + default: + return models.PAYMENT_STATUS_OTHER + } +} diff --git a/internal/connectors/plugins/public/generic/payments_test.go b/internal/connectors/plugins/public/generic/payments_test.go new file mode 100644 index 00000000..f177ac4b --- /dev/null +++ b/internal/connectors/plugins/public/generic/payments_test.go @@ -0,0 +1,174 @@ +package generic + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/genericclient" + "github.com/formancehq/payments/internal/connectors/plugins/public/generic/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Generic Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next payments", func() { + var ( + m *client.MockClient + samplePayments []genericclient.Transaction + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePayments = make([]genericclient.Transaction, 0) + for i := 0; i < 50; i++ { + samplePayments = append(samplePayments, genericclient.Transaction{ + Id: fmt.Sprint(i), + CreatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + UpdatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + Currency: "EUR", + Type: genericclient.PAYIN, + Status: genericclient.SUCCEEDED, + Amount: "1000", + SourceAccountID: pointer.For("acc1"), + DestinationAccountID: pointer.For("acc2"), + Metadata: map[string]string{"foo": "bar"}, + }) + } + }) + + It("should return an error - get payments error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListTransactions(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + []genericclient.Transaction{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListTransactions(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + []genericclient.Transaction{}, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastUpdatedAtFrom.IsZero()).To(BeTrue()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().ListTransactions(gomock.Any(), int64(1), int64(60), time.Time{}).Return( + samplePayments, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastUpdatedAtFrom.UTC()).To(Equal(samplePayments[49].UpdatedAt.UTC())) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().ListTransactions(gomock.Any(), int64(1), int64(40), time.Time{}).Return( + samplePayments[:40], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastUpdatedAtFrom.UTC()).To(Equal(samplePayments[39].UpdatedAt.UTC())) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(fmt.Sprintf(`{"lastUpdatedAtFrom": "%s"}`, samplePayments[38].UpdatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().ListTransactions(gomock.Any(), int64(1), int64(40), samplePayments[38].UpdatedAt.UTC()).Return( + samplePayments[:40], + nil, + ) + + m.EXPECT().ListTransactions(gomock.Any(), int64(2), int64(40), samplePayments[38].UpdatedAt.UTC()).Return( + samplePayments[40:], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastUpdatedAtFrom.UTC()).To(Equal(samplePayments[49].UpdatedAt.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/generic/plugin.go b/internal/connectors/plugins/public/generic/plugin.go new file mode 100644 index 00000000..f973f5f3 --- /dev/null +++ b/internal/connectors/plugins/public/generic/plugin.go @@ -0,0 +1,121 @@ +package generic + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/generic/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("generic", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New(config.APIKey, config.Endpoint) + + return &Plugin{ + name: name, + client: client, + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + return models.CreateTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + return models.CreatePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/generic/plugin_test.go b/internal/connectors/plugins/public/generic/plugin_test.go new file mode 100644 index 00000000..652c112b --- /dev/null +++ b/internal/connectors/plugins/public/generic/plugin_test.go @@ -0,0 +1,180 @@ +package generic + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Generic Plugin Suite") +} + +var _ = Describe("Generic Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - apiKey", func(ctx SpecContext) { + config := json.RawMessage(`{}`) + _, err := New("generic", config) + Expect(err).To(MatchError("missing api key in config: invalid config")) + }) + + It("should report errors in config - endpoint", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey": "test"}`) + _, err := New("generic", config) + Expect(err).To(MatchError("missing endpoint in config: invalid config")) + }) + + It("should return valid install response", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey": "test", "endpoint": "test"}`) + _, err := New("generic", config) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in external_accounts_test.go + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create bank account", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create webhooks", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("translate webhook", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/generic/workflow.go b/internal/connectors/plugins/public/generic/workflow.go new file mode 100644 index 00000000..fb83094e --- /dev/null +++ b/internal/connectors/plugins/public/generic/workflow.go @@ -0,0 +1,33 @@ +package generic + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_external_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/list.go b/internal/connectors/plugins/public/list.go new file mode 100644 index 00000000..9fd24e5c --- /dev/null +++ b/internal/connectors/plugins/public/list.go @@ -0,0 +1 @@ +package public diff --git a/internal/connectors/plugins/public/mangopay/accounts.go b/internal/connectors/plugins/public/mangopay/accounts.go new file mode 100644 index 00000000..766ad1dd --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/accounts.go @@ -0,0 +1,124 @@ +package mangopay + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type accountsState struct { + LastPage int `json:"lastPage"` + LastCreationDate time.Time `json:"lastCreationDate"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } else { + oldState = accountsState{ + LastPage: 1, + } + } + + var from client.User + if req.FromPayload == nil { + return models.FetchNextAccountsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextAccountsResponse{}, err + } + + newState := accountsState{ + LastPage: oldState.LastPage, + LastCreationDate: oldState.LastCreationDate, + } + + var accounts []models.PSPAccount + needMore := false + hasMore := false + page := oldState.LastPage + for { + pagedAccounts, err := p.client.GetWallets(ctx, from.ID, page, req.PageSize) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + accounts, err = fillAccounts(pagedAccounts, accounts, from, oldState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + + page++ + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastCreationDate = accounts[len(accounts)-1].CreatedAt + } + + newState.LastPage = page + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillAccounts( + pagedAccounts []client.Wallet, + accounts []models.PSPAccount, + from client.User, + oldState accountsState, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + accountCreationDate := time.Unix(account.CreationDate, 0) + switch accountCreationDate.Compare(oldState.LastCreationDate) { + case -1, 0: + // creationDate <= state.LastCreationDate, nothing to do, + // we already processed this account. + continue + default: + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.ID, + CreatedAt: accountCreationDate, + Name: &account.Description, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency)), + Metadata: map[string]string{ + userIDMetadataKey: from.ID, + }, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/mangopay/accounts_test.go b/internal/connectors/plugins/public/mangopay/accounts_test.go new file mode 100644 index 00000000..e5f6c0f4 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/accounts_test.go @@ -0,0 +1,186 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []client.Wallet + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]client.Wallet, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, client.Wallet{ + ID: fmt.Sprintf("%d", i), + Description: fmt.Sprintf("Account %d", i), + CreationDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Unix(), + Currency: "USD", + }) + } + }) + + It("should return an error - missing from payload", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + } + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetWallets(gomock.Any(), "test", 1, 60).Return( + []client.Wallet{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetWallets(gomock.Any(), "test", 1, 60).Return( + []client.Wallet{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.IsZero()).To(BeTrue()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetWallets(gomock.Any(), "test", 1, 60).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime := time.Unix(sampleAccounts[49].CreationDate, 0) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 40, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetWallets(gomock.Any(), "test", 1, 40).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + createdTime := time.Unix(sampleAccounts[39].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + lastCreatedAt := time.Unix(sampleAccounts[38].CreationDate, 0) + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": 1, "lastCreationDate": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetWallets(gomock.Any(), "test", 1, 40).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().GetWallets(gomock.Any(), "test", 2, 40).Return( + sampleAccounts[41:], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(2)) + createdTime := time.Unix(sampleAccounts[49].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/balances.go b/internal/connectors/plugins/public/mangopay/balances.go new file mode 100644 index 00000000..7983e02c --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/balances.go @@ -0,0 +1,45 @@ +package mangopay + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + wallet, err := p.client.GetWallet(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + var amount big.Int + _, ok := amount.SetString(wallet.Balance.Amount.String(), 10) + if !ok { + return models.FetchNextBalancesResponse{}, fmt.Errorf("failed to parse amount: %s", wallet.Balance.Amount.String()) + } + + balance := models.PSPBalance{ + AccountReference: from.Reference, + CreatedAt: time.Now().UTC(), + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, wallet.Balance.Currency), + } + + return models.FetchNextBalancesResponse{ + Balances: []models.PSPBalance{balance}, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/mangopay/balances_test.go b/internal/connectors/plugins/public/mangopay/balances_test.go new file mode 100644 index 00000000..6cd6bae2 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/balances_test.go @@ -0,0 +1,95 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "math/big" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next balances", func() { + var ( + m *client.MockClient + sampleBalance client.Wallet + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + sampleBalance = client.Wallet{ + Balance: struct { + Currency string `json:"Currency"` + Amount json.Number `json:"Amount"` + }{ + Currency: "EUR", + Amount: "100", + }, + } + }) + + It("should return an error - missing payload", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should return an error - get wallet error", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetWallet(gomock.Any(), "test").Return( + &sampleBalance, + errors.New("test error"), + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should fetch all balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetWallet(gomock.Any(), "test").Return( + &sampleBalance, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(1)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + Expect(resp.Balances[0].Amount).To(Equal(big.NewInt(100))) + Expect(resp.Balances[0].Asset).To(Equal("EUR/2")) + Expect(resp.Balances[0].AccountReference).To(Equal("test")) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/bank_account_creation.go b/internal/connectors/plugins/public/mangopay/bank_account_creation.go new file mode 100644 index 00000000..0ee38788 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/bank_account_creation.go @@ -0,0 +1,167 @@ +package mangopay + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createBankAccount(ctx context.Context, ba models.BankAccount) (models.CreateBankAccountResponse, error) { + userID := models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayUserIDMetadataKey) + if userID == "" { + return models.CreateBankAccountResponse{}, fmt.Errorf("missing userID in bank account metadata") + } + + ownerAddress := client.OwnerAddress{ + AddressLine1: models.ExtractNamespacedMetadata(ba.Metadata, models.BankAccountOwnerAddressLine1MetadataKey), + AddressLine2: models.ExtractNamespacedMetadata(ba.Metadata, models.BankAccountOwnerAddressLine2MetadataKey), + City: models.ExtractNamespacedMetadata(ba.Metadata, models.BankAccountOwnerCityMetadataKey), + Region: models.ExtractNamespacedMetadata(ba.Metadata, models.BankAccountOwnerRegionMetadataKey), + PostalCode: models.ExtractNamespacedMetadata(ba.Metadata, models.BankAccountOwnerPostalCodeMetadataKey), + Country: func() string { + if ba.Country == nil { + return "" + } + return *ba.Country + }(), + } + + var mangopayBankAccount *client.BankAccount + if ba.IBAN != nil { + req := &client.CreateIBANBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &ownerAddress, + IBAN: *ba.IBAN, + BIC: func() string { + if ba.SwiftBicCode == nil { + return "" + } + return *ba.SwiftBicCode + }(), + Tag: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayTagMetadataKey), + } + + var err error + mangopayBankAccount, err = p.client.CreateIBANBankAccount(ctx, userID, req) + if err != nil { + return models.CreateBankAccountResponse{}, fmt.Errorf("%w: %w", models.ErrFailedAccountCreation, err) + } + } else { + if ba.Country == nil { + ba.Country = pointer.For("") + } + switch *ba.Country { + case "US": + if ba.AccountNumber == nil { + return models.CreateBankAccountResponse{}, models.ErrMissingAccountInRequest + } + + req := &client.CreateUSBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &ownerAddress, + AccountNumber: *ba.AccountNumber, + ABA: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayABAMetadataKey), + DepositAccountType: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayDepositAccountTypeMetadataKey), + Tag: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayTagMetadataKey), + } + + var err error + mangopayBankAccount, err = p.client.CreateUSBankAccount(ctx, userID, req) + if err != nil { + return models.CreateBankAccountResponse{}, fmt.Errorf("%w: %w", models.ErrFailedAccountCreation, err) + } + + case "CA": + if ba.AccountNumber == nil { + return models.CreateBankAccountResponse{}, models.ErrMissingAccountInRequest + } + req := &client.CreateCABankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &ownerAddress, + AccountNumber: *ba.AccountNumber, + InstitutionNumber: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayInstitutionNumberMetadataKey), + BranchCode: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayBranchCodeMetadataKey), + BankName: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayBankNameMetadataKey), + Tag: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayTagMetadataKey), + } + + var err error + mangopayBankAccount, err = p.client.CreateCABankAccount(ctx, userID, req) + if err != nil { + return models.CreateBankAccountResponse{}, fmt.Errorf("%w: %w", models.ErrFailedAccountCreation, err) + } + + case "GB": + if ba.AccountNumber == nil { + return models.CreateBankAccountResponse{}, models.ErrMissingAccountInRequest + } + + req := &client.CreateGBBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &ownerAddress, + AccountNumber: *ba.AccountNumber, + SortCode: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopaySortCodeMetadataKey), + Tag: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayTagMetadataKey), + } + + var err error + mangopayBankAccount, err = p.client.CreateGBBankAccount(ctx, userID, req) + if err != nil { + return models.CreateBankAccountResponse{}, fmt.Errorf("%w: %w", models.ErrFailedAccountCreation, err) + } + + default: + if ba.AccountNumber == nil { + return models.CreateBankAccountResponse{}, models.ErrMissingAccountInRequest + } + + req := &client.CreateOtherBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &ownerAddress, + AccountNumber: *ba.AccountNumber, + BIC: func() string { + if ba.SwiftBicCode == nil { + return "" + } + return *ba.SwiftBicCode + }(), + Country: *ba.Country, + Tag: models.ExtractNamespacedMetadata(ba.Metadata, client.MangopayTagMetadataKey), + } + + var err error + mangopayBankAccount, err = p.client.CreateOtherBankAccount(ctx, userID, req) + if err != nil { + return models.CreateBankAccountResponse{}, fmt.Errorf("%w: %w", models.ErrFailedAccountCreation, err) + } + } + } + + var account models.PSPAccount + if mangopayBankAccount != nil { + raw, err := json.Marshal(mangopayBankAccount) + if err != nil { + return models.CreateBankAccountResponse{}, err + } + + account = models.PSPAccount{ + Reference: mangopayBankAccount.ID, + CreatedAt: time.Unix(mangopayBankAccount.CreationDate, 0), + Name: &mangopayBankAccount.OwnerName, + Metadata: map[string]string{ + "user_id": userID, + }, + Raw: raw, + } + + } + + return models.CreateBankAccountResponse{ + RelatedAccount: account, + }, nil +} diff --git a/internal/connectors/plugins/public/mangopay/bank_account_creation_test.go b/internal/connectors/plugins/public/mangopay/bank_account_creation_test.go new file mode 100644 index 00000000..d6da12dd --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/bank_account_creation_test.go @@ -0,0 +1,470 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Bank Account Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create bank account", func() { + var ( + m *client.MockClient + sampleBankAccount models.BankAccount + sampleClientAddress client.OwnerAddress + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBankAccount = models.BankAccount{ + ID: uuid.New(), + CreatedAt: now.UTC(), + Name: "test", + AccountNumber: pointer.For("12345678"), + IBAN: pointer.For("FR9412739000405993414979X56"), + SwiftBicCode: pointer.For("ERAHJP6BT1H"), + Country: pointer.For("FR"), + Metadata: map[string]string{ + models.BankAccountOwnerAddressLine1MetadataKey: "address1", + models.BankAccountOwnerAddressLine2MetadataKey: "address2", + models.BankAccountOwnerCityMetadataKey: "city", + models.BankAccountOwnerRegionMetadataKey: "region", + models.BankAccountOwnerPostalCodeMetadataKey: "postal_code", + client.MangopayUserIDMetadataKey: "u1", + client.MangopayTagMetadataKey: "foo=bar", + client.MangopayABAMetadataKey: "aba", + client.MangopayDepositAccountTypeMetadataKey: "deposit_test", + client.MangopayInstitutionNumberMetadataKey: "institution_number_test", + client.MangopayBranchCodeMetadataKey: "branch_code", + client.MangopayBankNameMetadataKey: "bank_name", + client.MangopaySortCodeMetadataKey: "sort_code", + }, + } + + sampleClientAddress = client.OwnerAddress{ + AddressLine1: "address1", + AddressLine2: "address2", + City: "city", + Region: "region", + PostalCode: "postal_code", + Country: "FR", + } + }) + + It("should return an error - missing userID in bank account metadata", func(ctx SpecContext) { + ba := sampleBankAccount + ba.Metadata = map[string]string{} + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing userID in bank account metadata")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - create IBAN bank account error", func(ctx SpecContext) { + ba := sampleBankAccount + ba.AccountNumber = nil + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().CreateIBANBankAccount(gomock.Any(), "u1", &client.CreateIBANBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sampleClientAddress, + IBAN: *ba.IBAN, + BIC: *ba.SwiftBicCode, + Tag: "foo=bar", + }).Return(nil, errors.New("test error")) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to create account: test error")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should be ok - create IBAN bank account", func(ctx SpecContext) { + ba := sampleBankAccount + ba.AccountNumber = nil + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + expectedBA := &client.BankAccount{ + ID: "id", + OwnerName: ba.Name, + CreationDate: now.UTC().Unix(), + } + m.EXPECT().CreateIBANBankAccount(gomock.Any(), "u1", &client.CreateIBANBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sampleClientAddress, + IBAN: *ba.IBAN, + BIC: *ba.SwiftBicCode, + Tag: "foo=bar", + }).Return(expectedBA, nil) + + raw, _ := json.Marshal(expectedBA) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res).To(Equal(models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: "id", + CreatedAt: time.Unix(expectedBA.CreationDate, 0), + Name: &ba.Name, + Metadata: map[string]string{ + "user_id": "u1", + }, + Raw: raw, + }, + })) + }) + + It("should return an error - create us bank account with missing account number", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.AccountNumber = nil + ba.Country = pointer.For("US") + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing account number in request")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - createUSBankAccount error", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("US") + sca := sampleClientAddress + sca.Country = "US" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().CreateUSBankAccount(gomock.Any(), "u1", &client.CreateUSBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + ABA: "aba", + DepositAccountType: "deposit_test", + Tag: "foo=bar", + }).Return(nil, errors.New("test error")) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to create account: test error")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should be ok - create US bank account", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("US") + sca := sampleClientAddress + sca.Country = "US" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + expectedBA := &client.BankAccount{ + ID: "id", + OwnerName: ba.Name, + CreationDate: now.UTC().Unix(), + } + m.EXPECT().CreateUSBankAccount(gomock.Any(), "u1", &client.CreateUSBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + ABA: "aba", + DepositAccountType: "deposit_test", + Tag: "foo=bar", + }).Return(expectedBA, nil) + + raw, _ := json.Marshal(expectedBA) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res).To(Equal(models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: "id", + CreatedAt: time.Unix(expectedBA.CreationDate, 0), + Name: &ba.Name, + Metadata: map[string]string{ + "user_id": "u1", + }, + Raw: raw, + }, + })) + }) + + It("should return an error - create CA bank account with missing account number", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.AccountNumber = nil + ba.Country = pointer.For("CA") + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing account number in request")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - createCABankAccount error", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("CA") + sca := sampleClientAddress + sca.Country = "CA" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().CreateCABankAccount(gomock.Any(), "u1", &client.CreateCABankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + InstitutionNumber: "institution_number_test", + BranchCode: "branch_code", + BankName: "bank_name", + Tag: "foo=bar", + }).Return(nil, errors.New("test error")) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to create account: test error")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should be ok - create CA bank account", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("CA") + sca := sampleClientAddress + sca.Country = "CA" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + expectedBA := &client.BankAccount{ + ID: "id", + OwnerName: ba.Name, + CreationDate: now.UTC().Unix(), + } + m.EXPECT().CreateCABankAccount(gomock.Any(), "u1", &client.CreateCABankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + InstitutionNumber: "institution_number_test", + BranchCode: "branch_code", + BankName: "bank_name", + Tag: "foo=bar", + }).Return(expectedBA, nil) + + raw, _ := json.Marshal(expectedBA) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res).To(Equal(models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: "id", + CreatedAt: time.Unix(expectedBA.CreationDate, 0), + Name: &ba.Name, + Metadata: map[string]string{ + "user_id": "u1", + }, + Raw: raw, + }, + })) + }) + + It("should return an error - create GB bank account with missing account number", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.AccountNumber = nil + ba.Country = pointer.For("GB") + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing account number in request")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - createGBBankAccount error", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("GB") + sca := sampleClientAddress + sca.Country = "GB" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().CreateGBBankAccount(gomock.Any(), "u1", &client.CreateGBBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + SortCode: "sort_code", + Tag: "foo=bar", + }).Return(nil, errors.New("test error")) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to create account: test error")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should be ok - create GB bank account", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("GB") + sca := sampleClientAddress + sca.Country = "GB" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + expectedBA := &client.BankAccount{ + ID: "id", + OwnerName: ba.Name, + CreationDate: now.UTC().Unix(), + } + m.EXPECT().CreateGBBankAccount(gomock.Any(), "u1", &client.CreateGBBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + SortCode: "sort_code", + Tag: "foo=bar", + }).Return(expectedBA, nil) + + raw, _ := json.Marshal(expectedBA) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res).To(Equal(models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: "id", + CreatedAt: time.Unix(expectedBA.CreationDate, 0), + Name: &ba.Name, + Metadata: map[string]string{ + "user_id": "u1", + }, + Raw: raw, + }, + })) + }) + + It("should return an error - create other bank account with missing account number", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.AccountNumber = nil + ba.Country = pointer.For("TEST_COUNTRY") + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing account number in request")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should return an error - createOtherBankAccount error", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("TEST_COUNTRY") + sca := sampleClientAddress + sca.Country = "TEST_COUNTRY" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + m.EXPECT().CreateOtherBankAccount(gomock.Any(), "u1", &client.CreateOtherBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + BIC: *ba.SwiftBicCode, + Country: "TEST_COUNTRY", + Tag: "foo=bar", + }).Return(nil, errors.New("test error")) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to create account: test error")) + Expect(res).To(Equal(models.CreateBankAccountResponse{})) + }) + + It("should be ok - create other bank account", func(ctx SpecContext) { + ba := sampleBankAccount + ba.IBAN = nil + ba.Country = pointer.For("TEST_COUNTRY") + sca := sampleClientAddress + sca.Country = "TEST_COUNTRY" + req := models.CreateBankAccountRequest{ + BankAccount: ba, + } + + expectedBA := &client.BankAccount{ + ID: "id", + OwnerName: ba.Name, + CreationDate: now.UTC().Unix(), + } + m.EXPECT().CreateOtherBankAccount(gomock.Any(), "u1", &client.CreateOtherBankAccountRequest{ + OwnerName: ba.Name, + OwnerAddress: &sca, + AccountNumber: *ba.AccountNumber, + BIC: *ba.SwiftBicCode, + Country: "TEST_COUNTRY", + Tag: "foo=bar", + }).Return(expectedBA, nil) + + raw, _ := json.Marshal(expectedBA) + + res, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(BeNil()) + Expect(res).To(Equal(models.CreateBankAccountResponse{ + RelatedAccount: models.PSPAccount{ + Reference: "id", + CreatedAt: time.Unix(expectedBA.CreationDate, 0), + Name: &ba.Name, + Metadata: map[string]string{ + "user_id": "u1", + }, + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/capabilities.go b/internal/connectors/plugins/public/mangopay/capabilities.go new file mode 100644 index 00000000..0d1b79f8 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/capabilities.go @@ -0,0 +1,18 @@ +package mangopay + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + models.CAPABILITY_FETCH_OTHERS, + + models.CAPABILITY_CREATE_BANK_ACCOUNT, + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, + + models.CAPABILITY_CREATE_WEBHOOKS, + models.CAPABILITY_TRANSLATE_WEBHOOKS, +} diff --git a/internal/connectors/plugins/public/mangopay/client/bank_accounts.go b/internal/connectors/plugins/public/mangopay/client/bank_accounts.go new file mode 100644 index 00000000..8e6fb097 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/bank_accounts.go @@ -0,0 +1,153 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type OwnerAddress struct { + AddressLine1 string `json:"AddressLine1,omitempty"` + AddressLine2 string `json:"AddressLine2,omitempty"` + City string `json:"City,omitempty"` + // Region is needed if country is either US, CA or MX + Region string `json:"Region,omitempty"` + PostalCode string `json:"PostalCode,omitempty"` + // ISO 3166-1 alpha-2 format. + Country string `json:"Country,omitempty"` +} + +type CreateIBANBankAccountRequest struct { + OwnerName string `json:"OwnerName"` + OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` + IBAN string `json:"IBAN,omitempty"` + BIC string `json:"BIC,omitempty"` + // Metadata + Tag string `json:"Tag,omitempty"` +} + +func (c *client) CreateIBANBankAccount(ctx context.Context, userID string, req *CreateIBANBankAccountRequest) (*BankAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_iban_bank_account") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/iban", c.endpoint, c.clientID, userID) + return c.createBankAccount(ctx, endpoint, req) +} + +type CreateUSBankAccountRequest struct { + OwnerName string `json:"OwnerName"` + OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` + AccountNumber string `json:"AccountNumber"` + ABA string `json:"ABA"` + DepositAccountType string `json:"DepositAccountType,omitempty"` + Tag string `json:"Tag,omitempty"` +} + +func (c *client) CreateUSBankAccount(ctx context.Context, userID string, req *CreateUSBankAccountRequest) (*BankAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_us_bank_account") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/us", c.endpoint, c.clientID, userID) + return c.createBankAccount(ctx, endpoint, req) +} + +type CreateCABankAccountRequest struct { + OwnerName string `json:"OwnerName"` + OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` + AccountNumber string `json:"AccountNumber"` + InstitutionNumber string `json:"InstitutionNumber"` + BranchCode string `json:"BranchCode"` + BankName string `json:"BankName"` + Tag string `json:"Tag,omitempty"` +} + +func (c *client) CreateCABankAccount(ctx context.Context, userID string, req *CreateCABankAccountRequest) (*BankAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_ca_bank_account") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/ca", c.endpoint, c.clientID, userID) + return c.createBankAccount(ctx, endpoint, req) +} + +type CreateGBBankAccountRequest struct { + OwnerName string `json:"OwnerName"` + OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` + AccountNumber string `json:"AccountNumber"` + SortCode string `json:"SortCode"` + Tag string `json:"Tag,omitempty"` +} + +func (c *client) CreateGBBankAccount(ctx context.Context, userID string, req *CreateGBBankAccountRequest) (*BankAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_gb_bank_account") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/gb", c.endpoint, c.clientID, userID) + return c.createBankAccount(ctx, endpoint, req) +} + +type CreateOtherBankAccountRequest struct { + OwnerName string `json:"OwnerName"` + OwnerAddress *OwnerAddress `json:"OwnerAddress,omitempty"` + AccountNumber string `json:"AccountNumber"` + BIC string `json:"BIC,omitempty"` + Country string `json:"Country,omitempty"` + Tag string `json:"Tag,omitempty"` +} + +func (c *client) CreateOtherBankAccount(ctx context.Context, userID string, req *CreateOtherBankAccountRequest) (*BankAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_other_bank_account") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts/other", c.endpoint, c.clientID, userID) + return c.createBankAccount(ctx, endpoint, req) +} + +func (c *client) createBankAccount(ctx context.Context, endpoint string, req any) (*BankAccount, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal bank account request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create bank account request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + var bankAccount BankAccount + statusCode, err := c.httpClient.Do(ctx, httpReq, &bankAccount, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to create bank account: %w", err), statusCode) + } + return &bankAccount, nil +} + +type BankAccount struct { + ID string `json:"Id"` + OwnerName string `json:"OwnerName"` + CreationDate int64 `json:"CreationDate"` +} + +func (c *client) GetBankAccounts(ctx context.Context, userID string, page, pageSize int) ([]BankAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_bank_accounts") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/bankaccounts", c.endpoint, c.clientID, userID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", strconv.Itoa(pageSize)) + q.Add("page", fmt.Sprint(page)) + q.Add("Sort", "CreationDate:ASC") + req.URL.RawQuery = q.Encode() + + var bankAccounts []BankAccount + statusCode, err := c.httpClient.Do(ctx, req, &bankAccounts, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get bank accounts: %w", err), statusCode) + } + return bankAccounts, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/client.go b/internal/connectors/plugins/public/mangopay/client/client.go new file mode 100644 index 00000000..ad602682 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/client.go @@ -0,0 +1,61 @@ +package client + +import ( + "context" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "golang.org/x/oauth2/clientcredentials" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + CreateIBANBankAccount(ctx context.Context, userID string, req *CreateIBANBankAccountRequest) (*BankAccount, error) + CreateUSBankAccount(ctx context.Context, userID string, req *CreateUSBankAccountRequest) (*BankAccount, error) + CreateCABankAccount(ctx context.Context, userID string, req *CreateCABankAccountRequest) (*BankAccount, error) + CreateGBBankAccount(ctx context.Context, userID string, req *CreateGBBankAccountRequest) (*BankAccount, error) + CreateOtherBankAccount(ctx context.Context, userID string, req *CreateOtherBankAccountRequest) (*BankAccount, error) + GetBankAccounts(ctx context.Context, userID string, page, pageSize int) ([]BankAccount, error) + GetPayin(ctx context.Context, payinID string) (*PayinResponse, error) + InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) + GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) + GetRefund(ctx context.Context, refundID string) (*Refund, error) + GetTransactions(ctx context.Context, walletsID string, page, pageSize int, afterCreatedAt time.Time) ([]Payment, error) + InitiateWalletTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) + GetWalletTransfer(ctx context.Context, transferID string) (TransferResponse, error) + GetUsers(ctx context.Context, page int, pageSize int) ([]User, error) + GetWallets(ctx context.Context, userID string, page, pageSize int) ([]Wallet, error) + GetWallet(ctx context.Context, walletID string) (*Wallet, error) + ListAllHooks(ctx context.Context) ([]*Hook, error) + CreateHook(ctx context.Context, eventType EventType, URL string) error + UpdateHook(ctx context.Context, hookID string, URL string) error +} + +// TODO(polo): Fetch Client wallets (FEES, ...) in the future +type client struct { + httpClient httpwrapper.Client + + clientID string + endpoint string +} + +func New(clientID, apiKey, endpoint string) Client { + endpoint = strings.TrimSuffix(endpoint, "/") + + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("mangopay"), + OAuthConfig: &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: apiKey, + TokenURL: endpoint + "/v2.01/oauth/token", + }, + } + + return &client{ + httpClient: httpwrapper.NewClient(config), + + clientID: clientID, + endpoint: endpoint, + } +} diff --git a/internal/connectors/plugins/public/mangopay/client/client_generated.go b/internal/connectors/plugins/public/mangopay/client/client_generated.go new file mode 100644 index 00000000..72c951aa --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/client_generated.go @@ -0,0 +1,325 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreateCABankAccount mocks base method. +func (m *MockClient) CreateCABankAccount(ctx context.Context, userID string, req *CreateCABankAccountRequest) (*BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCABankAccount", ctx, userID, req) + ret0, _ := ret[0].(*BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCABankAccount indicates an expected call of CreateCABankAccount. +func (mr *MockClientMockRecorder) CreateCABankAccount(ctx, userID, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCABankAccount", reflect.TypeOf((*MockClient)(nil).CreateCABankAccount), ctx, userID, req) +} + +// CreateGBBankAccount mocks base method. +func (m *MockClient) CreateGBBankAccount(ctx context.Context, userID string, req *CreateGBBankAccountRequest) (*BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGBBankAccount", ctx, userID, req) + ret0, _ := ret[0].(*BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateGBBankAccount indicates an expected call of CreateGBBankAccount. +func (mr *MockClientMockRecorder) CreateGBBankAccount(ctx, userID, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGBBankAccount", reflect.TypeOf((*MockClient)(nil).CreateGBBankAccount), ctx, userID, req) +} + +// CreateHook mocks base method. +func (m *MockClient) CreateHook(ctx context.Context, eventType EventType, URL string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateHook", ctx, eventType, URL) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateHook indicates an expected call of CreateHook. +func (mr *MockClientMockRecorder) CreateHook(ctx, eventType, URL any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateHook", reflect.TypeOf((*MockClient)(nil).CreateHook), ctx, eventType, URL) +} + +// CreateIBANBankAccount mocks base method. +func (m *MockClient) CreateIBANBankAccount(ctx context.Context, userID string, req *CreateIBANBankAccountRequest) (*BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateIBANBankAccount", ctx, userID, req) + ret0, _ := ret[0].(*BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateIBANBankAccount indicates an expected call of CreateIBANBankAccount. +func (mr *MockClientMockRecorder) CreateIBANBankAccount(ctx, userID, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIBANBankAccount", reflect.TypeOf((*MockClient)(nil).CreateIBANBankAccount), ctx, userID, req) +} + +// CreateOtherBankAccount mocks base method. +func (m *MockClient) CreateOtherBankAccount(ctx context.Context, userID string, req *CreateOtherBankAccountRequest) (*BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOtherBankAccount", ctx, userID, req) + ret0, _ := ret[0].(*BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateOtherBankAccount indicates an expected call of CreateOtherBankAccount. +func (mr *MockClientMockRecorder) CreateOtherBankAccount(ctx, userID, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOtherBankAccount", reflect.TypeOf((*MockClient)(nil).CreateOtherBankAccount), ctx, userID, req) +} + +// CreateUSBankAccount mocks base method. +func (m *MockClient) CreateUSBankAccount(ctx context.Context, userID string, req *CreateUSBankAccountRequest) (*BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUSBankAccount", ctx, userID, req) + ret0, _ := ret[0].(*BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUSBankAccount indicates an expected call of CreateUSBankAccount. +func (mr *MockClientMockRecorder) CreateUSBankAccount(ctx, userID, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUSBankAccount", reflect.TypeOf((*MockClient)(nil).CreateUSBankAccount), ctx, userID, req) +} + +// GetBankAccounts mocks base method. +func (m *MockClient) GetBankAccounts(ctx context.Context, userID string, page, pageSize int) ([]BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBankAccounts", ctx, userID, page, pageSize) + ret0, _ := ret[0].([]BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBankAccounts indicates an expected call of GetBankAccounts. +func (mr *MockClientMockRecorder) GetBankAccounts(ctx, userID, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBankAccounts", reflect.TypeOf((*MockClient)(nil).GetBankAccounts), ctx, userID, page, pageSize) +} + +// GetPayin mocks base method. +func (m *MockClient) GetPayin(ctx context.Context, payinID string) (*PayinResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayin", ctx, payinID) + ret0, _ := ret[0].(*PayinResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayin indicates an expected call of GetPayin. +func (mr *MockClientMockRecorder) GetPayin(ctx, payinID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayin", reflect.TypeOf((*MockClient)(nil).GetPayin), ctx, payinID) +} + +// GetPayout mocks base method. +func (m *MockClient) GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayout", ctx, payoutID) + ret0, _ := ret[0].(*PayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayout indicates an expected call of GetPayout. +func (mr *MockClientMockRecorder) GetPayout(ctx, payoutID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayout", reflect.TypeOf((*MockClient)(nil).GetPayout), ctx, payoutID) +} + +// GetRefund mocks base method. +func (m *MockClient) GetRefund(ctx context.Context, refundID string) (*Refund, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRefund", ctx, refundID) + ret0, _ := ret[0].(*Refund) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRefund indicates an expected call of GetRefund. +func (mr *MockClientMockRecorder) GetRefund(ctx, refundID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRefund", reflect.TypeOf((*MockClient)(nil).GetRefund), ctx, refundID) +} + +// GetTransactions mocks base method. +func (m *MockClient) GetTransactions(ctx context.Context, walletsID string, page, pageSize int, afterCreatedAt time.Time) ([]Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransactions", ctx, walletsID, page, pageSize, afterCreatedAt) + ret0, _ := ret[0].([]Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransactions indicates an expected call of GetTransactions. +func (mr *MockClientMockRecorder) GetTransactions(ctx, walletsID, page, pageSize, afterCreatedAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactions", reflect.TypeOf((*MockClient)(nil).GetTransactions), ctx, walletsID, page, pageSize, afterCreatedAt) +} + +// GetUsers mocks base method. +func (m *MockClient) GetUsers(ctx context.Context, page, pageSize int) ([]User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsers", ctx, page, pageSize) + ret0, _ := ret[0].([]User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsers indicates an expected call of GetUsers. +func (mr *MockClientMockRecorder) GetUsers(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockClient)(nil).GetUsers), ctx, page, pageSize) +} + +// GetWallet mocks base method. +func (m *MockClient) GetWallet(ctx context.Context, walletID string) (*Wallet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWallet", ctx, walletID) + ret0, _ := ret[0].(*Wallet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWallet indicates an expected call of GetWallet. +func (mr *MockClientMockRecorder) GetWallet(ctx, walletID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWallet", reflect.TypeOf((*MockClient)(nil).GetWallet), ctx, walletID) +} + +// GetWalletTransfer mocks base method. +func (m *MockClient) GetWalletTransfer(ctx context.Context, transferID string) (TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWalletTransfer", ctx, transferID) + ret0, _ := ret[0].(TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWalletTransfer indicates an expected call of GetWalletTransfer. +func (mr *MockClientMockRecorder) GetWalletTransfer(ctx, transferID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWalletTransfer", reflect.TypeOf((*MockClient)(nil).GetWalletTransfer), ctx, transferID) +} + +// GetWallets mocks base method. +func (m *MockClient) GetWallets(ctx context.Context, userID string, page, pageSize int) ([]Wallet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWallets", ctx, userID, page, pageSize) + ret0, _ := ret[0].([]Wallet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWallets indicates an expected call of GetWallets. +func (mr *MockClientMockRecorder) GetWallets(ctx, userID, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWallets", reflect.TypeOf((*MockClient)(nil).GetWallets), ctx, userID, page, pageSize) +} + +// InitiatePayout mocks base method. +func (m *MockClient) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiatePayout", ctx, payoutRequest) + ret0, _ := ret[0].(*PayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiatePayout indicates an expected call of InitiatePayout. +func (mr *MockClientMockRecorder) InitiatePayout(ctx, payoutRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiatePayout", reflect.TypeOf((*MockClient)(nil).InitiatePayout), ctx, payoutRequest) +} + +// InitiateWalletTransfer mocks base method. +func (m *MockClient) InitiateWalletTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiateWalletTransfer", ctx, transferRequest) + ret0, _ := ret[0].(*TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiateWalletTransfer indicates an expected call of InitiateWalletTransfer. +func (mr *MockClientMockRecorder) InitiateWalletTransfer(ctx, transferRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateWalletTransfer", reflect.TypeOf((*MockClient)(nil).InitiateWalletTransfer), ctx, transferRequest) +} + +// ListAllHooks mocks base method. +func (m *MockClient) ListAllHooks(ctx context.Context) ([]*Hook, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAllHooks", ctx) + ret0, _ := ret[0].([]*Hook) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAllHooks indicates an expected call of ListAllHooks. +func (mr *MockClientMockRecorder) ListAllHooks(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAllHooks", reflect.TypeOf((*MockClient)(nil).ListAllHooks), ctx) +} + +// UpdateHook mocks base method. +func (m *MockClient) UpdateHook(ctx context.Context, hookID, URL string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateHook", ctx, hookID, URL) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateHook indicates an expected call of UpdateHook. +func (mr *MockClientMockRecorder) UpdateHook(ctx, hookID, URL any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHook", reflect.TypeOf((*MockClient)(nil).UpdateHook), ctx, hookID, URL) +} diff --git a/internal/connectors/plugins/public/mangopay/client/error.go b/internal/connectors/plugins/public/mangopay/client/error.go new file mode 100644 index 00000000..1807ebcb --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/error.go @@ -0,0 +1,31 @@ +package client + +import ( + "fmt" +) + +type mangopayError struct { + StatusCode int `json:"-"` + Message string `json:"Message"` + Type string `json:"Type"` + Errors map[string]string `json:"Errors"` +} + +func (me *mangopayError) Error() error { + var errorMessage string + if len(me.Errors) > 0 { + for _, message := range me.Errors { + errorMessage = message + break + } + } + + var err error + if errorMessage == "" { + err = fmt.Errorf("unexpected status code: %d", me.StatusCode) + } else { + err = fmt.Errorf("%d: %s", me.StatusCode, errorMessage) + } + + return err +} diff --git a/cmd/connectors/internal/connectors/mangopay/client/metadata.go b/internal/connectors/plugins/public/mangopay/client/metadata.go similarity index 100% rename from cmd/connectors/internal/connectors/mangopay/client/metadata.go rename to internal/connectors/plugins/public/mangopay/client/metadata.go diff --git a/internal/connectors/plugins/public/mangopay/client/payin.go b/internal/connectors/plugins/public/mangopay/client/payin.go new file mode 100644 index 00000000..5242fc01 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/payin.go @@ -0,0 +1,47 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type PayinResponse struct { + ID string `json:"Id"` + Tag string `json:"Tag"` + CreationDate int64 `json:"CreationDate"` + ResultCode string `json:"ResultCode"` + ResultMessage string `json:"ResultMessage"` + AuthorId string `json:"AuthorId"` + CreditedUserId string `json:"CreditedUserId"` + DebitedFunds Funds `json:"DebitedFunds"` + CreditedFunds Funds `json:"CreditedFunds"` + Fees Funds `json:"Fees"` + Status string `json:"Status"` + ExecutionDate int64 `json:"ExecutionDate"` + Type string `json:"Type"` + CreditedWalletID string `json:"CreditedWalletId"` + PaymentType string `json:"PaymentType"` + ExecutionType string `json:"ExecutionType"` +} + +func (c *client) GetPayin(ctx context.Context, payinID string) (*PayinResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_payin") + + endpoint := fmt.Sprintf("%s/v2.01/%s/payins/%s", c.endpoint, c.clientID, payinID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create get payin request: %w", err) + } + + var payinResponse PayinResponse + statusCode, err := c.httpClient.Do(ctx, req, &payinResponse, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get payin: %w", err), statusCode) + } + return &payinResponse, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/payout.go b/internal/connectors/plugins/public/mangopay/client/payout.go new file mode 100644 index 00000000..1b9ddb28 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/payout.go @@ -0,0 +1,88 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type PayoutRequest struct { + Reference string `json:"-"` // Needed for idempotency + AuthorID string `json:"AuthorId"` + DebitedFunds Funds `json:"DebitedFunds"` + Fees Funds `json:"Fees"` + DebitedWalletID string `json:"DebitedWalletId"` + BankAccountID string `json:"BankAccountId"` + BankWireRef string `json:"BankWireRef,omitempty"` + PayoutModeRequested string `json:"PayoutModeRequested,omitempty"` +} + +type PayoutResponse struct { + ID string `json:"Id"` + ModeRequest string `json:"ModeRequested"` + ModeApplied string `json:"ModeApplied"` + FallbackReason string `json:"FallbackReason"` + CreationDate int64 `json:"CreationDate"` + AuthorID string `json:"AuthorId"` + DebitedFunds Funds `json:"DebitedFunds"` + Fees Funds `json:"Fees"` + CreditedFunds Funds `json:"CreditedFunds"` + Status string `json:"Status"` + ResultCode string `json:"ResultCode"` + ResultMessage string `json:"ResultMessage"` + Type string `json:"Type"` + Nature string `json:"Nature"` + ExecutionDate int64 `json:"ExecutionDate"` + BankAccountID string `json:"BankAccountId"` + DebitedWalletID string `json:"DebitedWalletId"` + PaymentType string `json:"PaymentType"` + BankWireRef string `json:"BankWireRef"` +} + +func (c *client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_payout") + + endpoint := fmt.Sprintf("%s/v2.01/%s/payouts/bankwire", c.endpoint, c.clientID) + + body, err := json.Marshal(payoutRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal transfer request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", payoutRequest.Reference) + + var payoutResponse PayoutResponse + statusCode, err := c.httpClient.Do(ctx, req, &payoutResponse, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to initiate payout: %w", err), statusCode) + } + return &payoutResponse, nil +} + +func (c *client) GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_payout") + + endpoint := fmt.Sprintf("%s/v2.01/%s/payouts/%s", c.endpoint, c.clientID, payoutID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create get payout request: %w", err) + } + + var payoutResponse PayoutResponse + statusCode, err := c.httpClient.Do(ctx, req, &payoutResponse, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get payout: %w", err), statusCode) + } + return &payoutResponse, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/refund.go b/internal/connectors/plugins/public/mangopay/client/refund.go new file mode 100644 index 00000000..414878b6 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/refund.go @@ -0,0 +1,48 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Refund struct { + ID string `json:"Id"` + Tag string `json:"Tag"` + CreationDate int64 `json:"CreationDate"` + AuthorId string `json:"AuthorId"` + CreditedUserId string `json:"CreditedUserId"` + DebitedFunds Funds `json:"DebitedFunds"` + CreditedFunds Funds `json:"CreditedFunds"` + Fees Funds `json:"Fees"` + Status string `json:"Status"` + ResultCode string `json:"ResultCode"` + ResultMessage string `json:"ResultMessage"` + ExecutionDate int64 `json:"ExecutionDate"` + Type string `json:"Type"` + DebitedWalletId string `json:"DebitedWalletId"` + CreditedWalletId string `json:"CreditedWalletId"` + InitialTransactionID string `json:"InitialTransactionId"` + InitialTransactionType string `json:"InitialTransactionType"` +} + +func (c *client) GetRefund(ctx context.Context, refundID string) (*Refund, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_refund") + + endpoint := fmt.Sprintf("%s/v2.01/%s/refunds/%s", c.endpoint, c.clientID, refundID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create get refund request: %w", err) + } + + var refund Refund + statusCode, err := c.httpClient.Do(ctx, req, &refund, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get refund: %w", err), statusCode) + } + return &refund, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/transactions.go b/internal/connectors/plugins/public/mangopay/client/transactions.go new file mode 100644 index 00000000..7e6a8dc0 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/transactions.go @@ -0,0 +1,57 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Payment struct { + Id string `json:"Id"` + Tag string `json:"Tag"` + CreationDate int64 `json:"CreationDate"` + AuthorId string `json:"AuthorId"` + CreditedUserId string `json:"CreditedUserId"` + DebitedFunds Funds `json:"DebitedFunds"` + CreditedFunds Funds `json:"CreditedFunds"` + Fees Funds `json:"Fees"` + Status string `json:"Status"` + ResultCode string `json:"ResultCode"` + ResultMessage string `json:"ResultMessage"` + ExecutionDate int64 `json:"ExecutionDate"` + Type string `json:"Type"` + Nature string `json:"Nature"` + CreditedWalletID string `json:"CreditedWalletId"` + DebitedWalletID string `json:"DebitedWalletId"` +} + +func (c *client) GetTransactions(ctx context.Context, walletsID string, page, pageSize int, afterCreatedAt time.Time) ([]Payment, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_transactions") + + endpoint := fmt.Sprintf("%s/v2.01/%s/wallets/%s/transactions", c.endpoint, c.clientID, walletsID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", strconv.Itoa(pageSize)) + q.Add("page", fmt.Sprint(page)) + q.Add("Sort", "CreationDate:ASC") + if !afterCreatedAt.IsZero() { + q.Add("AfterDate", strconv.FormatInt(afterCreatedAt.UTC().Unix(), 10)) + } + req.URL.RawQuery = q.Encode() + + var payments []Payment + statusCode, err := c.httpClient.Do(ctx, req, &payments, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get transactions: %w", err), statusCode) + } + return payments, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/transfer.go b/internal/connectors/plugins/public/mangopay/client/transfer.go new file mode 100644 index 00000000..3deaa017 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/transfer.go @@ -0,0 +1,92 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Funds struct { + Currency string `json:"Currency"` + Amount json.Number `json:"Amount"` +} + +type TransferRequest struct { + Reference string `json:"-"` // Needed for idempotency + AuthorID string `json:"AuthorId"` + CreditedUserID string `json:"CreditedUserId,omitempty"` + DebitedFunds Funds `json:"DebitedFunds"` + Fees Funds `json:"Fees"` + DebitedWalletID string `json:"DebitedWalletId"` + CreditedWalletID string `json:"CreditedWalletId"` +} + +type TransferResponse struct { + ID string `json:"Id"` + CreationDate int64 `json:"CreationDate"` + AuthorID string `json:"AuthorId"` + CreditedUserID string `json:"CreditedUserId"` + DebitedFunds Funds `json:"DebitedFunds"` + Fees Funds `json:"Fees"` + CreditedFunds Funds `json:"CreditedFunds"` + Status string `json:"Status"` + ResultCode string `json:"ResultCode"` + ResultMessage string `json:"ResultMessage"` + Type string `json:"Type"` + ExecutionDate int64 `json:"ExecutionDate"` + Nature string `json:"Nature"` + DebitedWalletID string `json:"DebitedWalletId"` + CreditedWalletID string `json:"CreditedWalletId"` +} + +func (c *client) InitiateWalletTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_transfer") + + endpoint := fmt.Sprintf("%s/v2.01/%s/transfers", c.endpoint, c.clientID) + + body, err := json.Marshal(transferRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal transfer request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create transfer request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", transferRequest.Reference) + + var transferResponse TransferResponse + var errRes mangopayError + statusCode, err := c.httpClient.Do(ctx, req, &transferResponse, &errRes) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to initiate transfer: %w %w", err, errRes.Error()), statusCode) + } + + return &transferResponse, nil +} + +func (c *client) GetWalletTransfer(ctx context.Context, transferID string) (TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_transfer") + + endpoint := fmt.Sprintf("%s/v2.01/%s/transfers/%s", c.endpoint, c.clientID, transferID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return TransferResponse{}, fmt.Errorf("failed to create login request: %w", err) + } + + var transfer TransferResponse + statusCode, err := c.httpClient.Do(ctx, req, &transfer, nil) + if err != nil { + return transfer, errorsutils.NewErrorWithExitCode( + fmt.Errorf("failed to get transfer response: %w", err), + statusCode, + ) + } + return transfer, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/users.go b/internal/connectors/plugins/public/mangopay/client/users.go new file mode 100644 index 00000000..d4027677 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/users.go @@ -0,0 +1,39 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type User struct { + ID string `json:"Id"` + CreationDate int64 `json:"CreationDate"` +} + +func (c *client) GetUsers(ctx context.Context, page int, pageSize int) ([]User, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_users") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users", c.endpoint, c.clientID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", strconv.Itoa(pageSize)) + q.Add("page", fmt.Sprint(page)) + q.Add("Sort", "CreationDate:ASC") + req.URL.RawQuery = q.Encode() + + var users []User + statusCode, err := c.httpClient.Do(ctx, req, &users, nil) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get user response: %w", err), statusCode) + } + return users, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/wallets.go b/internal/connectors/plugins/public/mangopay/client/wallets.go new file mode 100644 index 00000000..78b2710d --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/wallets.go @@ -0,0 +1,66 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Wallet struct { + ID string `json:"Id"` + Owners []string `json:"Owners"` + Description string `json:"Description"` + CreationDate int64 `json:"CreationDate"` + Currency string `json:"Currency"` + Balance struct { + Currency string `json:"Currency"` + Amount json.Number `json:"Amount"` + } `json:"Balance"` +} + +func (c *client) GetWallets(ctx context.Context, userID string, page, pageSize int) ([]Wallet, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_wallets") + + endpoint := fmt.Sprintf("%s/v2.01/%s/users/%s/wallets", c.endpoint, c.clientID, userID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", strconv.Itoa(pageSize)) + q.Add("page", fmt.Sprint(page)) + q.Add("Sort", "CreationDate:ASC") + req.URL.RawQuery = q.Encode() + + var wallets []Wallet + var errRes mangopayError + statusCode, err := c.httpClient.Do(ctx, req, &wallets, &errRes) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get wallets: %w %w", err, errRes.Error()), statusCode) + } + return wallets, nil +} + +func (c *client) GetWallet(ctx context.Context, walletID string) (*Wallet, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_wallet") + + endpoint := fmt.Sprintf("%s/v2.01/%s/wallets/%s", c.endpoint, c.clientID, walletID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create wallet request: %w", err) + } + + var wallet Wallet + var errRes mangopayError + statusCode, err := c.httpClient.Do(ctx, req, &wallet, &errRes) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to get wallet: %w %w", err, errRes.Error()), statusCode) + } + return &wallet, nil +} diff --git a/internal/connectors/plugins/public/mangopay/client/webhooks.go b/internal/connectors/plugins/public/mangopay/client/webhooks.go new file mode 100644 index 00000000..1b7865f8 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/client/webhooks.go @@ -0,0 +1,168 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/go-libs/v2/errorsutils" + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type EventType string + +const ( + // Transfer + EventTypeTransferNormalCreated EventType = "TRANSFER_NORMAL_CREATED" + EventTypeTransferNormalFailed EventType = "TRANSFER_NORMAL_FAILED" + EventTypeTransferNormalSucceeded EventType = "TRANSFER_NORMAL_SUCCEEDED" + + // PayOut + EventTypePayoutNormalCreated EventType = "PAYOUT_NORMAL_CREATED" + EventTypePayoutNormalFailed EventType = "PAYOUT_NORMAL_FAILED" + EventTypePayoutNormalSucceeded EventType = "PAYOUT_NORMAL_SUCCEEDED" + EventTypePayoutInstantFailed EventType = "INSTANT_PAYOUT_FAILED" + EventTypePayoutInstantSucceeded EventType = "INSTANT_PAYOUT_SUCCEEDED" + + // PayIn + EventTypePayinNormalCreated EventType = "PAYIN_NORMAL_CREATED" + EventTypePayinNormalFailed EventType = "PAYIN_NORMAL_FAILED" + EventTypePayinNormalSucceeded EventType = "PAYIN_NORMAL_SUCCEEDED" + + // Refund + EventTypeTransferRefundCreated EventType = "TRANSFER_REFUND_CREATED" + EventTypeTransferRefundFailed EventType = "TRANSFER_REFUND_FAILED" + EventTypeTransferRefundSucceeded EventType = "TRANSFER_REFUND_SUCCEEDED" + EventTypePayinRefundCreated EventType = "PAYIN_REFUND_CREATED" + EventTypePayinRefundFailed EventType = "PAYIN_REFUND_FAILED" + EventTypePayinRefundSucceeded EventType = "PAYIN_REFUND_SUCCEEDED" + EventTypePayOutRefundCreated EventType = "PAYOUT_REFUND_CREATED" + EventTypePayOutRefundFailed EventType = "PAYOUT_REFUND_FAILED" + EventTypePayOutRefundSucceeded EventType = "PAYOUT_REFUND_SUCCEEDED" +) + +var ( + AllEventTypes = []EventType{ + EventTypeTransferNormalCreated, + EventTypeTransferNormalFailed, + EventTypeTransferNormalSucceeded, + EventTypePayoutNormalCreated, + EventTypePayoutNormalFailed, + EventTypePayoutNormalSucceeded, + EventTypePayoutInstantFailed, + EventTypePayoutInstantSucceeded, + EventTypePayinNormalCreated, + EventTypePayinNormalFailed, + EventTypePayinNormalSucceeded, + EventTypeTransferRefundCreated, + EventTypeTransferRefundFailed, + EventTypeTransferRefundSucceeded, + EventTypePayinRefundCreated, + EventTypePayinRefundFailed, + EventTypePayinRefundSucceeded, + EventTypePayOutRefundCreated, + EventTypePayOutRefundFailed, + EventTypePayOutRefundSucceeded, + } +) + +type Webhook struct { + ResourceID string `json:"ResourceId"` + Date int64 `json:"Date"` + EventType EventType `json:"EventType"` +} + +type Hook struct { + ID string `json:"Id"` + URL string `json:"Url"` + Status string `json:"Status"` + Validity string `json:"Validity"` + EventType EventType `json:"EventType"` +} + +func (c *client) ListAllHooks(ctx context.Context) ([]*Hook, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_hooks") + + endpoint := fmt.Sprintf("%s/v2.01/%s/hooks", c.endpoint, c.clientID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create hooks request: %w", err) + } + + q := req.URL.Query() + q.Add("per_page", "100") // Should be enough, since we're creating only a few + q.Add("Sort", "CreationDate:ASC") + req.URL.RawQuery = q.Encode() + + var hooks []*Hook + var errRes mangopayError + statusCode, err := c.httpClient.Do(ctx, req, &hooks, &errRes) + if err != nil { + return nil, errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to list hooks: %w %w", err, errRes.Error()), statusCode) + } + return hooks, nil +} + +type CreateHookRequest struct { + EventType EventType `json:"EventType"` + URL string `json:"Url"` +} + +func (c *client) CreateHook(ctx context.Context, eventType EventType, URL string) error { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_hook") + + body, err := json.Marshal(&CreateHookRequest{ + EventType: eventType, + URL: URL, + }) + if err != nil { + return fmt.Errorf("failed to marshal create hook request: %w", err) + } + + endpoint := fmt.Sprintf("%s/v2.01/%s/hooks", c.endpoint, c.clientID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create hooks request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + var errRes mangopayError + statusCode, err := c.httpClient.Do(ctx, req, nil, &errRes) + if err != nil { + return errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to create hook: %w %w", err, errRes.Error()), statusCode) + } + return nil +} + +type UpdateHookRequest struct { + URL string `json:"Url"` + Status string `json:"Status"` +} + +func (c *client) UpdateHook(ctx context.Context, hookID string, URL string) error { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "update_hook") + + body, err := json.Marshal(&UpdateHookRequest{ + URL: URL, + Status: "ENABLED", + }) + if err != nil { + return fmt.Errorf("failed to marshal udpate hook request: %w", err) + } + + endpoint := fmt.Sprintf("%s/v2.01/%s/hooks/%s", c.endpoint, c.clientID, hookID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create update hooks request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + var errRes mangopayError + statusCode, err := c.httpClient.Do(ctx, req, nil, &errRes) + if err != nil { + return errorsutils.NewErrorWithExitCode(fmt.Errorf("failed to update hook: %w %w", err, errRes.Error()), statusCode) + } + return nil +} diff --git a/internal/connectors/plugins/public/mangopay/config.go b/internal/connectors/plugins/public/mangopay/config.go new file mode 100644 index 00000000..42611a45 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/config.go @@ -0,0 +1,39 @@ +package mangopay + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + ClientID string `json:"clientID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +func (c Config) validate() error { + if c.ClientID == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing clientID in config") + } + + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api key in config") + } + + if c.Endpoint == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing endpoint in config") + } + + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/mangopay/config.json b/internal/connectors/plugins/public/mangopay/config.json new file mode 100644 index 00000000..59bacea9 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/config.json @@ -0,0 +1,17 @@ +{ + "clientID": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "endpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/mangopay/currencies.go b/internal/connectors/plugins/public/mangopay/currencies.go new file mode 100644 index 00000000..0ee643f8 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/currencies.go @@ -0,0 +1,24 @@ +package mangopay + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + // c.f. https://mangopay.com/docs/api-basics/data-formats + supportedCurrenciesWithDecimal = map[string]int{ + "AED": currency.ISO4217Currencies["AED"], // UAE Dirham + "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar + "CAD": currency.ISO4217Currencies["CAD"], // Canadian Dollar + "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc + "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna + "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling + "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar + "JPY": currency.ISO4217Currencies["JPY"], // Japan, Yen + "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone + "PLN": currency.ISO4217Currencies["PLN"], // Poland, Zloty + "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona + "USD": currency.ISO4217Currencies["USD"], // US Dollar + "ZAR": currency.ISO4217Currencies["ZAR"], // South Africa, Rand + } +) diff --git a/internal/connectors/plugins/public/mangopay/external_accounts.go b/internal/connectors/plugins/public/mangopay/external_accounts.go new file mode 100644 index 00000000..895d9212 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/external_accounts.go @@ -0,0 +1,120 @@ +package mangopay + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type externalAccountsState struct { + LastPage int `json:"lastPage"` + LastCreationDate time.Time `json:"lastCreationDate"` +} + +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } else { + oldState = externalAccountsState{ + // Mangopay pages start at 1 + LastPage: 1, + } + } + + var from client.User + if req.FromPayload == nil { + return models.FetchNextExternalAccountsResponse{}, errors.New("missing from payload when fetching external accounts") + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + newState := externalAccountsState{ + LastPage: oldState.LastPage, + LastCreationDate: oldState.LastCreationDate, + } + + var accounts []models.PSPAccount + needMore := false + hasMore := false + for page := oldState.LastPage; ; page++ { + newState.LastPage = page + + pagedExternalAccounts, err := p.client.GetBankAccounts(ctx, from.ID, page, req.PageSize) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + accounts, err = fillExternalAccounts(pagedExternalAccounts, accounts, from, oldState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedExternalAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastCreationDate = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillExternalAccounts( + pagedExternalAccounts []client.BankAccount, + accounts []models.PSPAccount, + from client.User, + oldState externalAccountsState, +) ([]models.PSPAccount, error) { + for _, bankAccount := range pagedExternalAccounts { + creationDate := time.Unix(bankAccount.CreationDate, 0) + switch creationDate.Compare(oldState.LastCreationDate) { + case -1, 0: + // creationDate <= state.LastCreationDate, nothing to do, + // we already processed this bank account. + continue + default: + } + + raw, err := json.Marshal(bankAccount) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: bankAccount.ID, + CreatedAt: creationDate, + Name: &bankAccount.OwnerName, + Metadata: map[string]string{ + "user_id": from.ID, + }, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/mangopay/external_accounts_test.go b/internal/connectors/plugins/public/mangopay/external_accounts_test.go new file mode 100644 index 00000000..9dffa986 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/external_accounts_test.go @@ -0,0 +1,174 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin External Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next external accounts", func() { + var ( + m *client.MockClient + sampleBankAccounts []client.BankAccount + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBankAccounts = make([]client.BankAccount, 0) + for i := 0; i < 50; i++ { + sampleBankAccounts = append(sampleBankAccounts, client.BankAccount{ + ID: fmt.Sprintf("%d", i), + OwnerName: fmt.Sprintf("Account %d", i), + CreationDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Unix(), + }) + } + }) + + It("should return an error - get beneficiaries error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetBankAccounts(gomock.Any(), "test", 1, 60).Return( + []client.BankAccount{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetBankAccounts(gomock.Any(), "test", 1, 60).Return( + []client.BankAccount{}, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.IsZero()).To(BeTrue()) + }) + + It("should fetch next external accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetBankAccounts(gomock.Any(), "test", 1, 60).Return( + sampleBankAccounts, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime := time.Unix(sampleBankAccounts[49].CreationDate, 0) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 40, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetBankAccounts(gomock.Any(), "test", 1, 40).Return( + sampleBankAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + createdTime := time.Unix(sampleBankAccounts[39].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next external accounts - with state pageSize < total accounts", func(ctx SpecContext) { + lastCreatedAt := time.Unix(sampleBankAccounts[38].CreationDate, 0) + req := models.FetchNextExternalAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": 1, "lastCreationDate": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + FromPayload: json.RawMessage(`{"Id": "test"}`), + } + + m.EXPECT().GetBankAccounts(gomock.Any(), "test", 1, 40).Return( + sampleBankAccounts[:40], + nil, + ) + + m.EXPECT().GetBankAccounts(gomock.Any(), "test", 2, 40).Return( + sampleBankAccounts[41:], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(2)) + createdTime := time.Unix(sampleBankAccounts[49].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/metadata.go b/internal/connectors/plugins/public/mangopay/metadata.go new file mode 100644 index 00000000..bd4a1355 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/metadata.go @@ -0,0 +1,5 @@ +package mangopay + +const ( + userIDMetadataKey = "userID" +) diff --git a/internal/connectors/plugins/public/mangopay/payments.go b/internal/connectors/plugins/public/mangopay/payments.go new file mode 100644 index 00000000..a4cf66b6 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/payments.go @@ -0,0 +1,169 @@ +package mangopay + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type paymentsState struct { + LastPage int `json:"lastPage"` + LastCreationDate time.Time `json:"lastCreationDate"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } else { + oldState = paymentsState{ + LastPage: 1, + } + } + + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextPaymentsResponse{}, errors.New("missing from payload when fetching payments") + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + newState := paymentsState{ + LastPage: oldState.LastPage, + LastCreationDate: oldState.LastCreationDate, + } + + var payments []models.PSPPayment + needMore := false + hasMore := false + page := oldState.LastPage + for { + pagedTransactions, err := p.client.GetTransactions(ctx, from.Reference, page, req.PageSize, oldState.LastCreationDate) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, err = fillPayments(pagedTransactions, payments) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedTransactions, req.PageSize) + if !needMore || !hasMore { + break + } + + page++ + } + + if !needMore { + payments = payments[:req.PageSize] + } + + newState.LastPage = page + if len(payments) > 0 { + newState.LastCreationDate = payments[len(payments)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillPayments( + pagedTransactions []client.Payment, + payments []models.PSPPayment, +) ([]models.PSPPayment, error) { + for _, transaction := range pagedTransactions { + payment, err := transactionToPayment(transaction) + if err != nil { + return nil, err + } + + if payment != nil { + payments = append(payments, *payment) + } + } + return payments, nil +} + +func transactionToPayment(from client.Payment) (*models.PSPPayment, error) { + raw, err := json.Marshal(&from) + if err != nil { + return nil, err + } + + paymentType := matchPaymentType(from.Type) + paymentStatus := matchPaymentStatus(from.Status) + + var amount big.Int + _, ok := amount.SetString(from.DebitedFunds.Amount.String(), 10) + if !ok { + return nil, fmt.Errorf("failed to parse amount %s", from.DebitedFunds.Amount.String()) + } + + payment := models.PSPPayment{ + Reference: from.Id, + CreatedAt: time.Unix(from.CreationDate, 0), + Type: paymentType, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: paymentStatus, + Raw: raw, + } + + if from.DebitedWalletID != "" { + payment.SourceAccountReference = &from.DebitedWalletID + } + + if from.CreditedWalletID != "" { + payment.DestinationAccountReference = &from.CreditedWalletID + } + + return &payment, nil +} + +func matchPaymentType(paymentType string) models.PaymentType { + switch paymentType { + case "PAYIN": + return models.PAYMENT_TYPE_PAYIN + case "PAYOUT": + return models.PAYMENT_TYPE_PAYOUT + case "TRANSFER": + return models.PAYMENT_TYPE_TRANSFER + } + + return models.PAYMENT_TYPE_OTHER +} + +func matchPaymentStatus(paymentStatus string) models.PaymentStatus { + switch paymentStatus { + case "CREATED": + return models.PAYMENT_STATUS_PENDING + case "SUCCEEDED": + return models.PAYMENT_STATUS_SUCCEEDED + case "FAILED": + return models.PAYMENT_STATUS_FAILED + } + + return models.PAYMENT_STATUS_OTHER +} diff --git a/internal/connectors/plugins/public/mangopay/payments_test.go b/internal/connectors/plugins/public/mangopay/payments_test.go new file mode 100644 index 00000000..fdde07b5 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/payments_test.go @@ -0,0 +1,176 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next payments", func() { + var ( + m *client.MockClient + sampleTransactions []client.Payment + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleTransactions = make([]client.Payment, 0) + for i := 0; i < 50; i++ { + sampleTransactions = append(sampleTransactions, client.Payment{ + Id: fmt.Sprintf("%d", i), + CreationDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Unix(), + DebitedFunds: client.Funds{ + Currency: "USD", + Amount: "100", + }, + Status: "SUCCEEDED", + Type: "PAYIN", + CreditedWalletID: "acc2", + DebitedWalletID: "acc1", + }) + } + }) + + It("should return an error - get transactions error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 1, 60, time.Time{}).Return( + []client.Payment{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 1, 60, time.Time{}).Return( + []client.Payment{}, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.IsZero()).To(BeTrue()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + FromPayload: json.RawMessage(`{"Reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 1, 60, time.Time{}).Return( + sampleTransactions, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime := time.Unix(sampleTransactions[49].CreationDate, 0) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 40, + FromPayload: json.RawMessage(`{"Reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 1, 40, time.Time{}).Return( + sampleTransactions[:40], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + createdTime := time.Unix(sampleTransactions[39].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + lastCreatedAt := time.Unix(sampleTransactions[38].CreationDate, 0) + req := models.FetchNextPaymentsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": 1, "lastCreationDate": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 10, + FromPayload: json.RawMessage(`{"Reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 1, 10, lastCreatedAt.UTC()).Return( + sampleTransactions[39:49], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(10)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + createdTime := time.Unix(sampleTransactions[48].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/payouts.go b/internal/connectors/plugins/public/mangopay/payouts.go new file mode 100644 index 00000000..c6d07529 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/payouts.go @@ -0,0 +1,110 @@ +package mangopay + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "regexp" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +var ( + bankWireRefPatternRegexp = regexp.MustCompile("[a-zA-Z0-9 ]*") +) + +func (p *Plugin) validatePayoutRequest(pi models.PSPPaymentInitiation) error { + _, err := uuid.Parse(pi.Reference) + if err != nil { + return fmt.Errorf("reference is required as an uuid: %w", models.ErrInvalidRequest) + } + + if pi.SourceAccount == nil { + return fmt.Errorf("missing source account: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("missing destination account: %w", models.ErrInvalidRequest) + } + + _, ok := pi.SourceAccount.Metadata[userIDMetadataKey] + if !ok { + return fmt.Errorf("source account metadata with user id is required: %w", models.ErrInvalidRequest) + } + + if len(pi.Description) > 12 || !bankWireRefPatternRegexp.MatchString(pi.Description) { + return fmt.Errorf("description must be alphanumeric and less than 12 characters: %w", models.ErrInvalidRequest) + } + + return nil +} + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validatePayoutRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + userID := pi.SourceAccount.Metadata[userIDMetadataKey] + + curr, _, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %w: %w", err, models.ErrInvalidRequest) + } + + resp, err := p.client.InitiatePayout(ctx, &client.PayoutRequest{ + Reference: pi.Reference, + AuthorID: userID, + DebitedFunds: client.Funds{ + Currency: curr, + Amount: json.Number(pi.Amount.String()), + }, + Fees: client.Funds{ + Currency: curr, + Amount: json.Number("0"), + }, + DebitedWalletID: pi.SourceAccount.Reference, + BankAccountID: pi.DestinationAccount.Reference, + BankWireRef: pi.Description, + }) + if err != nil { + return models.PSPPayment{}, err + } + + payment, err := FromPayoutToPayment(resp, pi.DestinationAccount.Reference) + if err != nil { + return models.PSPPayment{}, err + } + + return payment, nil +} + +func FromPayoutToPayment(from *client.PayoutResponse, destinationAccountReference string) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + var amount big.Int + _, ok := amount.SetString(from.DebitedFunds.Amount.String(), 10) + if !ok { + return models.PSPPayment{}, fmt.Errorf("failed to parse amount %s", from.DebitedFunds.Amount.String()) + } + + return models.PSPPayment{ + Reference: from.ID, + CreatedAt: time.Unix(from.CreationDate, 0), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchPaymentStatus(from.Status), + SourceAccountReference: &from.DebitedWalletID, + DestinationAccountReference: &destinationAccountReference, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/mangopay/payouts_test.go b/internal/connectors/plugins/public/mangopay/payouts_test.go new file mode 100644 index 00000000..3b38f63f --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/payouts_test.go @@ -0,0 +1,231 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: uuid.New().String(), + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "userID": "u1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - reference", func(ctx SpecContext) { + sa := samplePSPPaymentInitiation + sa.Reference = "test" + req := models.CreatePayoutRequest{ + PaymentInitiation: sa, + } + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("reference is required as an uuid: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing source account: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing destination account: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - missing user ID in source account", func(ctx SpecContext) { + sa := samplePSPPaymentInitiation + sa.SourceAccount.Metadata = map[string]string{} + req := models.CreatePayoutRequest{ + PaymentInitiation: sa, + } + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account metadata with user id is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - description error", func(ctx SpecContext) { + sa := samplePSPPaymentInitiation + sa.Description = "test1test349[;'/';']" + req := models.CreatePayoutRequest{ + PaymentInitiation: sa, + } + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("description must be alphanumeric and less than 12 characters: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - initiate payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + Reference: samplePSPPaymentInitiation.Reference, + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + DebitedWalletID: samplePSPPaymentInitiation.SourceAccount.Reference, + BankAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + BankWireRef: "test1", + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.PayoutResponse{ + ID: "123", + CreationDate: now.Unix(), + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + Status: "SUCCEEDED", + BankAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + DebitedWalletID: samplePSPPaymentInitiation.SourceAccount.Reference, + } + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + Reference: samplePSPPaymentInitiation.Reference, + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + DebitedWalletID: samplePSPPaymentInitiation.SourceAccount.Reference, + BankAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + BankWireRef: "test1", + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "123", + CreatedAt: time.Unix(trResponse.CreationDate, 0), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + })) + }) + + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/plugin.go b/internal/connectors/plugins/public/mangopay/plugin.go new file mode 100644 index 00000000..dcdb1ba1 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/plugin.go @@ -0,0 +1,218 @@ +package mangopay + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("mangopay", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client + webhookConfigs map[client.EventType]webhookConfig +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New(config.ClientID, config.APIKey, config.Endpoint) + + p := &Plugin{ + name: name, + client: client, + } + + p.initWebhookConfig() + + return p, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { + configs := make([]models.PSPWebhookConfig, 0, len(p.webhookConfigs)) + for name, config := range p.webhookConfigs { + configs = append(configs, models.PSPWebhookConfig{ + Name: string(name), + URLPath: config.urlPath, + }) + } + + return models.InstallResponse{ + WebhooksConfigs: configs, + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + if p.client == nil { + return models.FetchNextOthersResponse{}, plugins.ErrNotYetInstalled + } + + switch req.Name { + case fetchUsersName: + return p.fetchNextUsers(ctx, req) + default: + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented + } +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + if p.client == nil { + return models.CreateBankAccountResponse{}, plugins.ErrNotYetInstalled + } + return p.createBankAccount(ctx, req.BankAccount) +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + + return models.CreateTransferResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + + return models.CreatePayoutResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + if p.client == nil { + return models.CreateWebhooksResponse{}, plugins.ErrNotYetInstalled + } + err := p.createWebhooks(ctx, req) + return models.CreateWebhooksResponse{}, err +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + if p.client == nil { + return models.TranslateWebhookResponse{}, plugins.ErrNotYetInstalled + } + + // Mangopay does not send us the event inside the body, but using + // URL query. + eventType, ok := req.Webhook.QueryValues["EventType"] + if !ok || len(eventType) == 0 { + return models.TranslateWebhookResponse{}, fmt.Errorf("missing EventType query parameter: %w", models.ErrInvalidRequest) + } + resourceID, ok := req.Webhook.QueryValues["RessourceId"] + if !ok || len(resourceID) == 0 { + return models.TranslateWebhookResponse{}, fmt.Errorf("missing RessourceId query parameter: %w", models.ErrInvalidRequest) + } + v, ok := req.Webhook.QueryValues["Date"] + if !ok || len(v) == 0 { + return models.TranslateWebhookResponse{}, fmt.Errorf("missing Date query parameter: %w", models.ErrInvalidRequest) + } + date, err := strconv.ParseInt(v[0], 10, 64) + if err != nil { + return models.TranslateWebhookResponse{}, fmt.Errorf("invalid Date query parameter: %w", models.ErrInvalidRequest) + } + + webhook := client.Webhook{ + ResourceID: resourceID[0], + Date: date, + EventType: client.EventType(eventType[0]), + } + + config, ok := p.webhookConfigs[webhook.EventType] + if !ok { + return models.TranslateWebhookResponse{}, fmt.Errorf("unsupported webhook event type: %w", models.ErrInvalidRequest) + } + + webhookResponse, err := config.fn(ctx, webhookTranslateRequest{ + req: req, + webhook: &webhook, + }) + if err != nil { + return models.TranslateWebhookResponse{}, err + } + + return models.TranslateWebhookResponse{ + Responses: []models.WebhookResponse{webhookResponse}, + }, nil +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/mangopay/plugin_test.go b/internal/connectors/plugins/public/mangopay/plugin_test.go new file mode 100644 index 00000000..53ebee53 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/plugin_test.go @@ -0,0 +1,194 @@ +package mangopay + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Mangopay Plugin Suite") +} + +var _ = Describe("Mangopay Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - clientID", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey": "test", "endpoint": "test"}`) + _, err := New("mangopay", config) + Expect(err).To(MatchError("missing clientID in config: invalid config")) + }) + + It("should report errors in config - apiKey", func(ctx SpecContext) { + config := json.RawMessage(`{"clientID": "test", "endpoint": "test"}`) + _, err := New("mangopay", config) + Expect(err).To(MatchError("missing api key in config: invalid config")) + }) + + It("should report errors in config - endpoint", func(ctx SpecContext) { + config := json.RawMessage(`{"clientID": "test", "apiKey": "test"}`) + _, err := New("mangopay", config) + Expect(err).To(MatchError("missing endpoint in config: invalid config")) + }) + + It("should return valid install response", func(ctx SpecContext) { + config := json.RawMessage(`{"clientID": "test", "apiKey": "test", "endpoint": "test"}`) + _, err := New("mangopay", config) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in external_accounts_test.go + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in others_test.go + }) + + Context("create bank account", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in bank_accounts_test.go + }) + + Context("create transfer", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in transfers_test.go + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payouts_test.go + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create webhooks", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) + + Context("translate webhook", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/transfers.go b/internal/connectors/plugins/public/mangopay/transfers.go new file mode 100644 index 00000000..a2f1aff9 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/transfers.go @@ -0,0 +1,103 @@ +package mangopay + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" +) + +func (p *Plugin) validateTransferRequest(pi models.PSPPaymentInitiation) error { + _, err := uuid.Parse(pi.Reference) + if err != nil { + return fmt.Errorf("reference is required as an uuid: %w", models.ErrInvalidRequest) + } + + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + _, ok := pi.SourceAccount.Metadata[userIDMetadataKey] + if !ok { + return fmt.Errorf("source account metadata with user id is required: %w", models.ErrInvalidRequest) + } + + return nil +} + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validateTransferRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + userID := pi.SourceAccount.Metadata[userIDMetadataKey] + + curr, _, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + resp, err := p.client.InitiateWalletTransfer( + ctx, + &client.TransferRequest{ + Reference: pi.Reference, + AuthorID: userID, + DebitedFunds: client.Funds{ + Currency: curr, + Amount: json.Number(pi.Amount.String()), + }, + Fees: client.Funds{ + Currency: curr, + Amount: "0", + }, + DebitedWalletID: pi.SourceAccount.Reference, + CreditedWalletID: pi.DestinationAccount.Reference, + }, + ) + if err != nil { + return models.PSPPayment{}, err + } + + payment, err := FromTransferToPayment(resp) + if err != nil { + return models.PSPPayment{}, err + } + + return payment, nil +} + +func FromTransferToPayment(from *client.TransferResponse) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + var amount big.Int + _, ok := amount.SetString(from.DebitedFunds.Amount.String(), 10) + if !ok { + return models.PSPPayment{}, fmt.Errorf("failed to parse amount %s", from.DebitedFunds.Amount.String()) + } + + return models.PSPPayment{ + Reference: from.ID, + CreatedAt: time.Unix(from.CreationDate, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchPaymentStatus(from.Status), + SourceAccountReference: &from.DebitedWalletID, + DestinationAccountReference: &from.CreditedWalletID, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/mangopay/transfers_test.go b/internal/connectors/plugins/public/mangopay/transfers_test.go new file mode 100644 index 00000000..cea82cf1 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/transfers_test.go @@ -0,0 +1,216 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: uuid.New().String(), + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "userID": "u1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - reference", func(ctx SpecContext) { + sa := samplePSPPaymentInitiation + sa.Reference = "test" + req := models.CreateTransferRequest{ + PaymentInitiation: sa, + } + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("reference is required as an uuid: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - missing user ID in source account", func(ctx SpecContext) { + sa := samplePSPPaymentInitiation + sa.SourceAccount.Metadata = map[string]string{} + req := models.CreateTransferRequest{ + PaymentInitiation: sa, + } + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account metadata with user id is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - initiate transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiateWalletTransfer(gomock.Any(), &client.TransferRequest{ + Reference: samplePSPPaymentInitiation.Reference, + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + DebitedWalletID: samplePSPPaymentInitiation.SourceAccount.Reference, + CreditedWalletID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.TransferResponse{ + ID: "123", + CreationDate: now.Unix(), + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + Status: "SUCCEEDED", + DebitedWalletID: samplePSPPaymentInitiation.SourceAccount.Reference, + CreditedWalletID: samplePSPPaymentInitiation.DestinationAccount.Reference, + } + m.EXPECT().InitiateWalletTransfer(gomock.Any(), &client.TransferRequest{ + Reference: samplePSPPaymentInitiation.Reference, + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + DebitedWalletID: samplePSPPaymentInitiation.SourceAccount.Reference, + CreditedWalletID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "123", + CreatedAt: time.Unix(trResponse.CreationDate, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + })) + }) + + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/users.go b/internal/connectors/plugins/public/mangopay/users.go new file mode 100644 index 00000000..26a96805 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/users.go @@ -0,0 +1,110 @@ +package mangopay + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type usersState struct { + LastPage int `json:"lastPage"` + LastCreationDate time.Time `json:"lastCreationDate"` +} + +func (p *Plugin) fetchNextUsers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + var oldState usersState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextOthersResponse{}, err + } + } else { + oldState = usersState{ + LastPage: 1, + } + } + + newState := usersState{ + LastPage: oldState.LastPage, + LastCreationDate: oldState.LastCreationDate, + } + + var users []models.PSPOther + var userCreationDates []time.Time + needMore := false + hasMore := false + page := oldState.LastPage + for { + pagedUsers, err := p.client.GetUsers(ctx, page, req.PageSize) + if err != nil { + return models.FetchNextOthersResponse{}, err + } + + users, userCreationDates, err = fillUsers(pagedUsers, users, userCreationDates, oldState) + if err != nil { + return models.FetchNextOthersResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(users, pagedUsers, req.PageSize) + if !needMore || !hasMore { + break + } + + page++ + } + + if !needMore { + users = users[:req.PageSize] + userCreationDates = userCreationDates[:req.PageSize] + } + + newState.LastPage = page + if len(userCreationDates) > 0 { + newState.LastCreationDate = userCreationDates[len(users)-1] + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextOthersResponse{}, err + } + + return models.FetchNextOthersResponse{ + Others: users, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillUsers( + pagedUsers []client.User, + users []models.PSPOther, + userCreationDates []time.Time, + oldState usersState, +) ([]models.PSPOther, []time.Time, error) { + for _, user := range pagedUsers { + userCreationDate := time.Unix(user.CreationDate, 0) + switch userCreationDate.Compare(oldState.LastCreationDate) { + case -1, 0: + // creationDate <= state.LastCreationDate, nothing to do, + // we already processed this user. + continue + default: + } + + raw, err := json.Marshal(user) + if err != nil { + return nil, nil, err + } + + users = append(users, models.PSPOther{ + ID: user.ID, + Other: raw, + }) + userCreationDates = append(userCreationDates, userCreationDate) + } + + return users, userCreationDates, nil +} diff --git a/internal/connectors/plugins/public/mangopay/users_test.go b/internal/connectors/plugins/public/mangopay/users_test.go new file mode 100644 index 00000000..ec897b3e --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/users_test.go @@ -0,0 +1,173 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Users", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next users", func() { + var ( + m *client.MockClient + sampleUsers []client.User + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleUsers = make([]client.User, 0) + for i := 0; i < 50; i++ { + sampleUsers = append(sampleUsers, client.User{ + ID: fmt.Sprintf("%d", i), + CreationDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Unix(), + }) + } + }) + + It("should return an error - get users error", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + Name: fetchUsersName, + PageSize: 60, + } + + m.EXPECT().GetUsers(gomock.Any(), 1, 60).Return( + []client.User{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextOthers(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextOthersResponse{})) + }) + + It("should fetch next users - no state no results", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + Name: fetchUsersName, + PageSize: 60, + } + + m.EXPECT().GetUsers(gomock.Any(), 1, 60).Return( + []client.User{}, + nil, + ) + + resp, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Others).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state usersState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.IsZero()).To(BeTrue()) + }) + + It("should fetch next users - no state pageSize > total users", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + Name: fetchUsersName, + PageSize: 60, + } + + m.EXPECT().GetUsers(gomock.Any(), 1, 60).Return( + sampleUsers, + nil, + ) + + resp, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Others).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state usersState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime := time.Unix(sampleUsers[49].CreationDate, 0) + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch users - no state pageSize < total users", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + Name: fetchUsersName, + PageSize: 40, + } + + m.EXPECT().GetUsers(gomock.Any(), 1, 40).Return( + sampleUsers[:40], + nil, + ) + + resp, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Others).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state usersState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(1)) + createdTime := time.Unix(sampleUsers[39].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next users - with state pageSize < total users", func(ctx SpecContext) { + lastCreatedAt := time.Unix(sampleUsers[38].CreationDate, 0) + req := models.FetchNextOthersRequest{ + Name: fetchUsersName, + State: []byte(fmt.Sprintf(`{"lastPage": 1, "lastCreationDate": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetUsers(gomock.Any(), 1, 40).Return( + sampleUsers[:40], + nil, + ) + + m.EXPECT().GetUsers(gomock.Any(), 2, 40).Return( + sampleUsers[41:], + nil, + ) + + resp, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Others).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state usersState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(2)) + createdTime := time.Unix(sampleUsers[49].CreationDate, 0) + Expect(state.LastCreationDate.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/webhooks.go b/internal/connectors/plugins/public/mangopay/webhooks.go new file mode 100644 index 00000000..9cc59257 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/webhooks.go @@ -0,0 +1,330 @@ +package mangopay + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/url" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" +) + +type webhookTranslateRequest struct { + req models.TranslateWebhookRequest + webhook *client.Webhook +} + +type webhookConfig struct { + urlPath string + fn func(context.Context, webhookTranslateRequest) (models.WebhookResponse, error) +} + +func (p *Plugin) initWebhookConfig() { + p.webhookConfigs = map[client.EventType]webhookConfig{ + client.EventTypeTransferNormalCreated: { + urlPath: "/transfer/created", + fn: p.translateTransfer, + }, + client.EventTypeTransferNormalFailed: { + urlPath: "/transfer/failed", + fn: p.translateTransfer, + }, + client.EventTypeTransferNormalSucceeded: { + urlPath: "/transfer/succeeded", + fn: p.translateTransfer, + }, + + client.EventTypePayoutNormalCreated: { + urlPath: "/payout/normal/created", + fn: p.translatePayout, + }, + client.EventTypePayoutNormalFailed: { + urlPath: "/payout/normal/failed", + fn: p.translatePayout, + }, + client.EventTypePayoutNormalSucceeded: { + urlPath: "/payout/normal/succeeded", + fn: p.translatePayout, + }, + client.EventTypePayoutInstantFailed: { + urlPath: "/payout/instant/failed", + fn: p.translatePayout, + }, + client.EventTypePayoutInstantSucceeded: { + urlPath: "/payout/instant/succeeded", + fn: p.translatePayout, + }, + + client.EventTypePayinNormalCreated: { + urlPath: "/payin/normal/created", + fn: p.translatePayin, + }, + client.EventTypePayinNormalSucceeded: { + urlPath: "/payin/normal/succeeded", + fn: p.translatePayin, + }, + client.EventTypePayinNormalFailed: { + urlPath: "/payin/normal/failed", + fn: p.translatePayin, + }, + + client.EventTypeTransferRefundFailed: { + urlPath: "/refund/transfer/failed", + fn: p.translateRefund, + }, + client.EventTypeTransferRefundSucceeded: { + urlPath: "/refund/transfer/succeeded", + fn: p.translateRefund, + }, + client.EventTypePayOutRefundFailed: { + urlPath: "/refund/payout/failed", + fn: p.translateRefund, + }, + client.EventTypePayOutRefundSucceeded: { + urlPath: "/refund/payout/succeeded", + fn: p.translateRefund, + }, + client.EventTypePayinRefundFailed: { + urlPath: "/refund/payin/failed", + fn: p.translateRefund, + }, + client.EventTypePayinRefundSucceeded: { + urlPath: "/refund/payin/succeeded", + fn: p.translateRefund, + }, + } +} + +func (p *Plugin) createWebhooks(ctx context.Context, req models.CreateWebhooksRequest) error { + if req.WebhookBaseUrl == "" { + return fmt.Errorf("STACK_PUBLIC_URL is not set: %w", models.ErrInvalidRequest) + } + + activeHooks, err := p.getActiveHooks(ctx) + if err != nil { + return err + } + + for eventType, config := range p.webhookConfigs { + url, err := url.JoinPath(req.WebhookBaseUrl, config.urlPath) + if err != nil { + return err + } + + if v, ok := activeHooks[eventType]; ok { + // Already created, continue + + if v.URL != url { + // If the URL is different, update it + err := p.client.UpdateHook(ctx, v.ID, url) + if err != nil { + return err + } + } + + continue + } + + // Otherwise, create it + err = p.client.CreateHook(ctx, eventType, url) + if err != nil { + return err + } + } + + return nil +} + +func (p *Plugin) getActiveHooks(ctx context.Context) (map[client.EventType]*client.Hook, error) { + alreadyExistingHooks, err := p.client.ListAllHooks(ctx) + if err != nil { + return nil, err + } + + activeHooks := make(map[client.EventType]*client.Hook) + for _, hook := range alreadyExistingHooks { + // Mangopay allows only one active hook per event type. + if hook.Validity == "VALID" { + activeHooks[hook.EventType] = hook + } + } + + return activeHooks, nil +} + +func (p *Plugin) translateTransfer(ctx context.Context, req webhookTranslateRequest) (models.WebhookResponse, error) { + transfer, err := p.client.GetWalletTransfer(ctx, req.webhook.ResourceID) + if err != nil { + return models.WebhookResponse{}, err + } + + raw, err := json.Marshal(transfer) + if err != nil { + return models.WebhookResponse{}, fmt.Errorf("failed to marshal transfer: %w", err) + } + + paymentStatus := matchPaymentStatus(transfer.Status) + + var amount big.Int + _, ok := amount.SetString(transfer.DebitedFunds.Amount.String(), 10) + if !ok { + return models.WebhookResponse{}, fmt.Errorf("failed to parse amount %s", transfer.DebitedFunds.Amount.String()) + } + + payment := models.PSPPayment{ + Reference: transfer.ID, + CreatedAt: time.Unix(transfer.CreationDate, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transfer.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: paymentStatus, + Raw: raw, + } + + if transfer.DebitedWalletID != "" { + payment.SourceAccountReference = &transfer.DebitedWalletID + } + + if transfer.CreditedWalletID != "" { + payment.DestinationAccountReference = &transfer.CreditedWalletID + } + + return models.WebhookResponse{ + IdempotencyKey: fmt.Sprintf("%s-%s-%d", req.webhook.ResourceID, string(req.webhook.EventType), req.webhook.Date), + Payment: &payment, + }, nil +} + +func (p *Plugin) translatePayout(ctx context.Context, req webhookTranslateRequest) (models.WebhookResponse, error) { + payout, err := p.client.GetPayout(ctx, req.webhook.ResourceID) + if err != nil { + return models.WebhookResponse{}, err + } + + raw, err := json.Marshal(payout) + if err != nil { + return models.WebhookResponse{}, fmt.Errorf("failed to marshal transfer: %w", err) + } + + paymentStatus := matchPaymentStatus(payout.Status) + + var amount big.Int + _, ok := amount.SetString(payout.DebitedFunds.Amount.String(), 10) + if !ok { + return models.WebhookResponse{}, fmt.Errorf("failed to parse amount %s", payout.DebitedFunds.Amount.String()) + } + + payment := models.PSPPayment{ + Reference: payout.ID, + CreatedAt: time.Unix(payout.CreationDate, 0), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, payout.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: paymentStatus, + Raw: raw, + } + + if payout.DebitedWalletID != "" { + payment.SourceAccountReference = &payout.DebitedWalletID + } + + if payout.BankAccountID != "" { + payment.DestinationAccountReference = &payout.BankAccountID + } + + return models.WebhookResponse{ + IdempotencyKey: fmt.Sprintf("%s-%s-%d", req.webhook.ResourceID, string(req.webhook.EventType), req.webhook.Date), + Payment: &payment, + }, nil +} + +func (p *Plugin) translatePayin(ctx context.Context, req webhookTranslateRequest) (models.WebhookResponse, error) { + payin, err := p.client.GetPayin(ctx, req.webhook.ResourceID) + if err != nil { + return models.WebhookResponse{}, err + } + + raw, err := json.Marshal(payin) + if err != nil { + return models.WebhookResponse{}, fmt.Errorf("failed to marshal transfer: %w", err) + } + + paymentStatus := matchPaymentStatus(payin.Status) + + var amount big.Int + _, ok := amount.SetString(payin.DebitedFunds.Amount.String(), 10) + if !ok { + return models.WebhookResponse{}, fmt.Errorf("failed to parse amount %s", payin.DebitedFunds.Amount.String()) + } + + payment := models.PSPPayment{ + Reference: payin.ID, + CreatedAt: time.Unix(payin.CreationDate, 0), + Type: models.PAYMENT_TYPE_PAYIN, + Amount: &amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, payin.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: paymentStatus, + Raw: raw, + } + + if payin.CreditedWalletID != "" { + payment.DestinationAccountReference = &payin.CreditedWalletID + } + + return models.WebhookResponse{ + IdempotencyKey: fmt.Sprintf("%s-%s-%d", req.webhook.ResourceID, string(req.webhook.EventType), req.webhook.Date), + Payment: &payment, + }, nil +} + +func (p *Plugin) translateRefund(ctx context.Context, req webhookTranslateRequest) (models.WebhookResponse, error) { + refund, err := p.client.GetRefund(ctx, req.webhook.ResourceID) + if err != nil { + return models.WebhookResponse{}, err + } + + raw, err := json.Marshal(refund) + if err != nil { + return models.WebhookResponse{}, fmt.Errorf("failed to marshal transfer: %w", err) + } + + paymentType := matchPaymentType(refund.InitialTransactionType) + + var amountRefunded big.Int + _, ok := amountRefunded.SetString(refund.DebitedFunds.Amount.String(), 10) + if !ok { + return models.WebhookResponse{}, fmt.Errorf("failed to parse amount %s", refund.DebitedFunds.Amount.String()) + } + + status := models.PAYMENT_STATUS_REFUNDED + switch req.webhook.EventType { + case client.EventTypePayOutRefundFailed, + client.EventTypePayinRefundFailed, + client.EventTypeTransferRefundFailed: + status = models.PAYMENT_STATUS_REFUNDED_FAILURE + } + + payment := models.PSPPayment{ + ParentReference: refund.InitialTransactionID, + Reference: refund.ID, + CreatedAt: time.Unix(refund.CreationDate, 0), + Type: paymentType, + Amount: &amountRefunded, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, refund.DebitedFunds.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + Raw: raw, + } + + return models.WebhookResponse{ + IdempotencyKey: fmt.Sprintf("%s-%s-%d", req.webhook.ResourceID, string(req.webhook.EventType), req.webhook.Date), + Payment: &payment, + }, nil +} diff --git a/internal/connectors/plugins/public/mangopay/webhooks_test.go b/internal/connectors/plugins/public/mangopay/webhooks_test.go new file mode 100644 index 00000000..5e2366a2 --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/webhooks_test.go @@ -0,0 +1,740 @@ +package mangopay + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "strconv" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/mangopay/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Mangopay Plugin Create Webhooks", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create webhooks", func() { + var ( + m *client.MockClient + listAllValidHooksResponse []*client.Hook + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + plg.initWebhookConfig() + + listAllValidHooksResponse = []*client.Hook{ + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypeTransferNormalCreated, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypeTransferNormalFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypeTransferNormalSucceeded, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayoutNormalCreated, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayoutNormalFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayoutNormalSucceeded, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayoutInstantFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayoutInstantSucceeded, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayinNormalCreated, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayinNormalSucceeded, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayinNormalFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypeTransferRefundFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypeTransferRefundSucceeded, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayOutRefundFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayOutRefundSucceeded, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayinRefundFailed, + }, + { + ID: "1", + URL: "test", + Validity: "VALID", + EventType: client.EventTypePayinRefundSucceeded, + }, + } + }) + + It("should return an error - missing stack url", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + + resp, err := plg.CreateWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("STACK_PUBLIC_URL is not set: invalid request")) + Expect(resp).To(Equal(models.CreateWebhooksResponse{})) + }) + + It("should return an error - get active hooks error", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + WebhookBaseUrl: "http://localhost:8080", + } + + m.EXPECT().ListAllHooks(gomock.Any()).Return(nil, errors.New("test error")) + + resp, err := plg.CreateWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateWebhooksResponse{})) + }) + + It("should return an error - update hook error", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + WebhookBaseUrl: "http://localhost:8080", + } + + m.EXPECT().ListAllHooks(gomock.Any()).Return(listAllValidHooksResponse, nil) + m.EXPECT().UpdateHook(gomock.Any(), "1", gomock.Any()). + Return(errors.New("test error")) + + resp, err := plg.CreateWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateWebhooksResponse{})) + }) + + It("should return an error - create hook error", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + WebhookBaseUrl: "http://localhost:8080", + } + + m.EXPECT().ListAllHooks(gomock.Any()).Return(nil, nil) + m.EXPECT().CreateHook(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New("test error")) + + resp, err := plg.CreateWebhooks(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateWebhooksResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + ConnectorID: "test", + WebhookBaseUrl: "http://localhost:8080", + } + + m.EXPECT().ListAllHooks(gomock.Any()).Return(nil, nil) + for range plg.webhookConfigs { + m.EXPECT().CreateHook(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil) + } + + resp, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateWebhooksResponse{})) + }) + }) +}) + +var _ = Describe("Mangopay Plugin Translate Webhook", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create webhooks", func() { + var ( + m *client.MockClient + sampleTransferResponse client.TransferResponse + samplePayoutResponse client.PayoutResponse + samplePayinResponse client.PayinResponse + sampleRefundResponse client.Refund + now time.Time + date string + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + date = strconv.FormatInt(now.UTC().Unix(), 10) + plg.initWebhookConfig() + + sampleTransferResponse = client.TransferResponse{ + ID: "1", + CreationDate: now.Unix(), + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + Status: "SUCCEEDED", + DebitedWalletID: "acc1", + CreditedWalletID: "acc2", + } + + samplePayoutResponse = client.PayoutResponse{ + ID: "1", + CreationDate: now.Unix(), + AuthorID: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + Status: "SUCCEEDED", + BankAccountID: "acc2", + DebitedWalletID: "acc1", + } + + samplePayinResponse = client.PayinResponse{ + ID: "1", + CreationDate: now.Unix(), + AuthorId: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + Status: "SUCCEEDED", + CreditedWalletID: "acc1", + } + + sampleRefundResponse = client.Refund{ + ID: "1", + CreationDate: now.Unix(), + AuthorId: "u1", + DebitedFunds: client.Funds{ + Currency: "EUR", + Amount: "100", + }, + Fees: client.Funds{ + Currency: "EUR", + Amount: "0", + }, + Status: "SUCEEDED", + DebitedWalletId: "acc2", + CreditedWalletId: "acc1", + InitialTransactionID: "123", + InitialTransactionType: "PAYIN", + } + }) + + It("should return an error - missing event type in query", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing EventType query parameter: invalid request")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should return an error - missing resource id in query", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"TRANSFER_NORMAL_CREATED"}, + }, + }, + } + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing RessourceId query parameter: invalid request")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should return an error - missing Date in query", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"TRANSFER_NORMAL_CREATED"}, + "RessourceId": {"1"}, + }, + }, + } + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing Date query parameter: invalid request")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should return an error - invalid Date in query", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"TRANSFER_NORMAL_CREATED"}, + "RessourceId": {"1"}, + "Date": {"test"}, + }, + }, + } + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("invalid Date query parameter: invalid request")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should return an error - invalid event type", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"TEST"}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("unsupported webhook event type: invalid request")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should return an error - get transfer error", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"TRANSFER_NORMAL_CREATED"}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + m.EXPECT().GetWalletTransfer(gomock.Any(), "1").Return(client.TransferResponse{}, errors.New("test error")) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should be ok transfers", func(ctx SpecContext) { + for _, eventType := range []string{ + "TRANSFER_NORMAL_CREATED", + "TRANSFER_NORMAL_FAILED", + "TRANSFER_NORMAL_SUCCEEDED", + } { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {eventType}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + sa := sampleTransferResponse + status := models.PAYMENT_STATUS_PENDING + switch eventType { + case "TRANSFER_NORMAL_FAILED": + sa.Status = "FAILED" + status = models.PAYMENT_STATUS_FAILED + case "TRANSFER_NORMAL_SUCCEEDED": + sa.Status = "SUCCEEDED" + status = models.PAYMENT_STATUS_SUCCEEDED + case "TRANSFER_NORMAL_CREATED": + sa.Status = "CREATED" + status = models.PAYMENT_STATUS_PENDING + } + raw, _ := json.Marshal(sa) + + m.EXPECT().GetWalletTransfer(gomock.Any(), "1"). + Return(sa, nil) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.TranslateWebhookResponse{ + Responses: []models.WebhookResponse{ + { + IdempotencyKey: fmt.Sprintf("1-%s-%s", eventType, date), + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: time.Unix(sampleTransferResponse.CreationDate, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + }, + }, + })) + } + }) + + It("should return an error - get payout error", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"PAYOUT_NORMAL_CREATED"}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + m.EXPECT().GetPayout(gomock.Any(), "1").Return(nil, errors.New("test error")) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should be ok payouts", func(ctx SpecContext) { + for _, eventType := range []string{ + "PAYOUT_NORMAL_CREATED", + "PAYOUT_NORMAL_FAILED", + "PAYOUT_NORMAL_SUCCEEDED", + "INSTANT_PAYOUT_FAILED", + "INSTANT_PAYOUT_SUCCEEDED", + } { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {eventType}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + sp := samplePayoutResponse + status := models.PAYMENT_STATUS_PENDING + switch eventType { + case "PAYOUT_NORMAL_FAILED", "INSTANT_PAYOUT_FAILED": + sp.Status = "FAILED" + status = models.PAYMENT_STATUS_FAILED + case "PAYOUT_NORMAL_SUCCEEDED", "INSTANT_PAYOUT_SUCCEEDED": + sp.Status = "SUCCEEDED" + status = models.PAYMENT_STATUS_SUCCEEDED + case "PAYOUT_NORMAL_CREATED": + sp.Status = "CREATED" + status = models.PAYMENT_STATUS_PENDING + } + + raw, _ := json.Marshal(sp) + + m.EXPECT().GetPayout(gomock.Any(), "1"). + Return(&sp, nil) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.TranslateWebhookResponse{ + Responses: []models.WebhookResponse{ + { + IdempotencyKey: fmt.Sprintf("1-%s-%s", eventType, date), + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: time.Unix(samplePayoutResponse.CreationDate, 0), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + }, + }, + })) + } + }) + + It("should return an error - get payin error", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"PAYIN_NORMAL_CREATED"}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + m.EXPECT().GetPayin(gomock.Any(), "1").Return(nil, errors.New("test error")) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should be ok payins", func(ctx SpecContext) { + for _, eventType := range []string{ + "PAYIN_NORMAL_CREATED", + "PAYIN_NORMAL_FAILED", + "PAYIN_NORMAL_SUCCEEDED", + } { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {eventType}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + sp := samplePayinResponse + status := models.PAYMENT_STATUS_PENDING + switch eventType { + case "PAYIN_NORMAL_FAILED": + sp.Status = "FAILED" + status = models.PAYMENT_STATUS_FAILED + case "PAYIN_NORMAL_SUCCEEDED": + sp.Status = "SUCCEEDED" + status = models.PAYMENT_STATUS_SUCCEEDED + case "PAYIN_NORMAL_CREATED": + sp.Status = "CREATED" + status = models.PAYMENT_STATUS_PENDING + } + + raw, _ := json.Marshal(sp) + + m.EXPECT().GetPayin(gomock.Any(), "1"). + Return(&sp, nil) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.TranslateWebhookResponse{ + Responses: []models.WebhookResponse{ + { + IdempotencyKey: fmt.Sprintf("1-%s-%s", eventType, date), + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: time.Unix(samplePayinResponse.CreationDate, 0), + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + DestinationAccountReference: pointer.For("acc1"), + Raw: raw, + }, + }, + }, + })) + } + }) + + It("should return an error - get refund error", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {"TRANSFER_REFUND_FAILED"}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + m.EXPECT().GetRefund(gomock.Any(), "1").Return(nil, errors.New("test error")) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.TranslateWebhookResponse{})) + }) + + It("should be ok refunds", func(ctx SpecContext) { + for _, eventType := range []string{ + "TRANSFER_REFUND_FAILED", + "TRANSFER_REFUND_SUCCEEDED", + "PAYOUT_REFUND_FAILED", + "PAYOUT_REFUND_SUCCEEDED", + "PAYIN_REFUND_FAILED", + "PAYIN_REFUND_SUCCEEDED", + } { + req := models.TranslateWebhookRequest{ + Name: "test", + Webhook: models.PSPWebhook{ + QueryValues: map[string][]string{ + "EventType": {eventType}, + "RessourceId": {"1"}, + "Date": {strconv.FormatInt(now.UTC().Unix(), 10)}, + }, + }, + } + + sp := sampleRefundResponse + status := models.PAYMENT_STATUS_PENDING + pType := models.PAYMENT_TYPE_PAYIN + switch eventType { + case "TRANSFER_REFUND_FAILED": + sp.Status = "FAILED" + sp.InitialTransactionType = "TRANSFER" + status = models.PAYMENT_STATUS_REFUNDED_FAILURE + pType = models.PAYMENT_TYPE_TRANSFER + case "TRANSFER_REFUND_SUCCEEDED": + sp.Status = "SUCCEEDED" + sp.InitialTransactionType = "TRANSFER" + status = models.PAYMENT_STATUS_REFUNDED + pType = models.PAYMENT_TYPE_TRANSFER + case "PAYOUT_REFUND_FAILED": + sp.Status = "FAILED" + sp.InitialTransactionType = "PAYOUT" + status = models.PAYMENT_STATUS_REFUNDED_FAILURE + pType = models.PAYMENT_TYPE_PAYOUT + case "PAYOUT_REFUND_SUCCEEDED": + sp.Status = "SUCCEEDED" + sp.InitialTransactionType = "PAYOUT" + status = models.PAYMENT_STATUS_REFUNDED + pType = models.PAYMENT_TYPE_PAYOUT + case "PAYIN_REFUND_FAILED": + sp.Status = "FAILED" + sp.InitialTransactionType = "PAYIN" + status = models.PAYMENT_STATUS_REFUNDED_FAILURE + pType = models.PAYMENT_TYPE_PAYIN + case "PAYIN_REFUND_SUCCEEDED": + sp.Status = "SUCCEEDED" + sp.InitialTransactionType = "PAYIN" + status = models.PAYMENT_STATUS_REFUNDED + pType = models.PAYMENT_TYPE_PAYIN + } + + raw, _ := json.Marshal(sp) + + m.EXPECT().GetRefund(gomock.Any(), "1"). + Return(&sp, nil) + + resp, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.TranslateWebhookResponse{ + Responses: []models.WebhookResponse{ + { + IdempotencyKey: fmt.Sprintf("1-%s-%s", eventType, date), + Payment: &models.PSPPayment{ + ParentReference: "123", + Reference: "1", + CreatedAt: time.Unix(sampleRefundResponse.CreationDate, 0), + Type: pType, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + Raw: raw, + }, + }, + }, + })) + } + }) + }) +}) diff --git a/internal/connectors/plugins/public/mangopay/workflow.go b/internal/connectors/plugins/public/mangopay/workflow.go new file mode 100644 index 00000000..c47001eb --- /dev/null +++ b/internal/connectors/plugins/public/mangopay/workflow.go @@ -0,0 +1,50 @@ +package mangopay + +import "github.com/formancehq/payments/internal/models" + +const ( + fetchUsersName = "fetch_users" +) + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_OTHERS, + Name: fetchUsersName, + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: false, // We will be using webhooks after polling the history + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_external_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_CREATE_WEBHOOKS, + Name: "create_webhooks", + Periodically: false, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/modulr/accounts.go b/internal/connectors/plugins/public/modulr/accounts.go new file mode 100644 index 00000000..92872d16 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/accounts.go @@ -0,0 +1,109 @@ +package modulr + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type accountsState struct { + LastCreatedAt time.Time `json:"lastCreatedAt"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + newState := accountsState{ + LastCreatedAt: oldState.LastCreatedAt, + } + + var accounts []models.PSPAccount + needMore := false + hasMore := false + for page := 0; ; page++ { + pagedAccounts, err := p.client.GetAccounts(ctx, page, req.PageSize, oldState.LastCreatedAt) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + accounts, err = fillAccounts(pagedAccounts, accounts, oldState, req.PageSize) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastCreatedAt = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillAccounts( + pagedAccounts []client.Account, + accounts []models.PSPAccount, + oldState accountsState, + pageSize int, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + if len(accounts) >= pageSize { + break + } + + createdTime, err := time.Parse("2006-01-02T15:04:05.999-0700", account.CreatedDate) + if err != nil { + return nil, err + } + + switch createdTime.Compare(oldState.LastCreatedAt) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + raw, err := json.Marshal(account) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.ID, + CreatedAt: createdTime, + Name: &account.Name, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency)), + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/modulr/accounts_test.go b/internal/connectors/plugins/public/modulr/accounts_test.go new file mode 100644 index 00000000..87311ad9 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/accounts_test.go @@ -0,0 +1,170 @@ +package modulr + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Modulr Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleAccounts []client.Account + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleAccounts = make([]client.Account, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, client.Account{ + ID: fmt.Sprintf("%d", i), + Name: fmt.Sprintf("Account %d", i), + Currency: "USD", + CreatedDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format("2006-01-02T15:04:05.999-0700"), + }) + } + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 60, time.Time{}).Return( + []client.Account{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 60, time.Time{}).Return( + []client.Account{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAt.IsZero()).To(BeTrue()) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 60, time.Time{}).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleAccounts[49].CreatedDate) + Expect(state.LastCreatedAt.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 40, time.Time{}).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleAccounts[39].CreatedDate) + Expect(state.LastCreatedAt.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + lastCreatedAt, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleAccounts[38].CreatedDate) + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastCreatedAt": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 40, lastCreatedAt.UTC()).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().GetAccounts(gomock.Any(), 1, 40, lastCreatedAt.UTC()).Return( + sampleAccounts[41:], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleAccounts[49].CreatedDate) + Expect(state.LastCreatedAt.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/modulr/balances.go b/internal/connectors/plugins/public/modulr/balances.go new file mode 100644 index 00000000..9fd8276f --- /dev/null +++ b/internal/connectors/plugins/public/modulr/balances.go @@ -0,0 +1,46 @@ +package modulr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, errors.New("missing from payload when fetching balances") + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + account, err := p.client.GetAccount(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + precision := supportedCurrenciesWithDecimal[account.Currency] + + amount, err := currency.GetAmountWithPrecisionFromString(account.Balance, precision) + if err != nil { + return models.FetchNextBalancesResponse{}, fmt.Errorf("failed to parse amount %s: %w", account.Balance, err) + } + + balance := models.PSPBalance{ + AccountReference: from.Reference, + CreatedAt: time.Now().UTC(), + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, account.Currency), + } + + return models.FetchNextBalancesResponse{ + Balances: []models.PSPBalance{balance}, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/modulr/balances_test.go b/internal/connectors/plugins/public/modulr/balances_test.go new file mode 100644 index 00000000..552709d6 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/balances_test.go @@ -0,0 +1,87 @@ +package modulr + +import ( + "errors" + "math/big" + + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Modulr Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next balances", func() { + var ( + m *client.MockClient + sampleBalance client.Account + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + sampleBalance = client.Account{ + Balance: "150.01", + Currency: "USD", + } + }) + + It("should return an error - missing payload", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + } + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload when fetching balances")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should return an error - get balances error", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetAccount(gomock.Any(), "test").Return( + &sampleBalance, + errors.New("test error"), + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextBalancesResponse{})) + }) + + It("should fetch all balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetAccount(gomock.Any(), "test").Return( + &sampleBalance, + nil, + ) + + resp, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Balances).To(HaveLen(1)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).To(BeNil()) + Expect(resp.Balances[0].Amount).To(Equal(big.NewInt(15001))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/modulr/capabilities.go b/internal/connectors/plugins/public/modulr/capabilities.go new file mode 100644 index 00000000..b6f17b9b --- /dev/null +++ b/internal/connectors/plugins/public/modulr/capabilities.go @@ -0,0 +1,13 @@ +package modulr + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, +} diff --git a/internal/connectors/plugins/public/modulr/client/accounts.go b/internal/connectors/plugins/public/modulr/client/accounts.go new file mode 100644 index 00000000..7ce24d49 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/accounts.go @@ -0,0 +1,72 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +//nolint:tagliatelle // allow for clients +type Account struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Balance string `json:"balance"` + Currency string `json:"currency"` + CustomerID string `json:"customerId"` + Identifiers []struct { + AccountNumber string `json:"accountNumber"` + SortCode string `json:"sortCode"` + Type string `json:"type"` + } `json:"identifiers"` + DirectDebit bool `json:"directDebit"` + CreatedDate string `json:"createdDate"` +} + +func (c *client) GetAccounts(ctx context.Context, page, pageSize int, fromCreatedAt time.Time) ([]Account, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_accounts") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("accounts"), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create accounts request: %w", err) + } + + q := req.URL.Query() + q.Add("page", strconv.Itoa(page)) + q.Add("size", strconv.Itoa(pageSize)) + q.Add("sortField", "createdDate") + q.Add("sortOrder", "asc") + if !fromCreatedAt.IsZero() { + q.Add("fromCreatedDate", fromCreatedAt.Format("2006-01-02T15:04:05-0700")) + } + req.URL.RawQuery = q.Encode() + + var res responseWrapper[[]Account] + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get accounts: %w %w", err, errRes.Error()) + } + return res.Content, nil +} + +func (c *client) GetAccount(ctx context.Context, accountID string) (*Account, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_account") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("accounts/%s", accountID), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create accounts request: %w", err) + } + + var res Account + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get account: %w %w", err, errRes.Error()) + } + return &res, nil +} diff --git a/internal/connectors/plugins/public/modulr/client/beneficiaries.go b/internal/connectors/plugins/public/modulr/client/beneficiaries.go new file mode 100644 index 00000000..0642045a --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/beneficiaries.go @@ -0,0 +1,42 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Beneficiary struct { + ID string `json:"id"` + Name string `json:"name"` + Created string `json:"created"` +} + +func (c *client) GetBeneficiaries(ctx context.Context, page, pageSize int, modifiedSince time.Time) ([]Beneficiary, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_beneficiaries") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("beneficiaries"), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create accounts request: %w", err) + } + + q := req.URL.Query() + q.Add("page", strconv.Itoa(page)) + q.Add("size", strconv.Itoa(pageSize)) + if !modifiedSince.IsZero() { + q.Add("modifiedSince", modifiedSince.Format("2006-01-02T15:04:05-0700")) + } + req.URL.RawQuery = q.Encode() + + var res responseWrapper[[]Beneficiary] + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get beneficiaries: %w %w", err, errRes.Error()) + } + return res.Content, nil +} diff --git a/internal/connectors/plugins/public/modulr/client/client.go b/internal/connectors/plugins/public/modulr/client/client.go new file mode 100644 index 00000000..47c9d9b0 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/client.go @@ -0,0 +1,90 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client/hmac" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetAccounts(ctx context.Context, page, pageSize int, fromCreatedAt time.Time) ([]Account, error) + GetAccount(ctx context.Context, accountID string) (*Account, error) + GetBeneficiaries(ctx context.Context, page, pageSize int, modifiedSince time.Time) ([]Beneficiary, error) + GetPayments(ctx context.Context, paymentType PaymentType, page, pageSize int, modifiedSince time.Time) ([]Payment, error) + InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) + GetPayout(ctx context.Context, payoutID string) (PayoutResponse, error) + GetTransactions(ctx context.Context, accountID string, page, pageSize int, fromTransactionDate time.Time) ([]Transaction, error) + InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) + GetTransfer(ctx context.Context, transferID string) (TransferResponse, error) +} + +type apiTransport struct { + apiKey string + headers map[string]string + underlying http.RoundTripper +} + +func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", t.apiKey) + + return t.underlying.RoundTrip(req) +} + +type responseWrapper[t any] struct { + Content t `json:"content"` + Size int `json:"size"` + TotalSize int `json:"totalSize"` + Page int `json:"page"` + TotalPages int `json:"totalPages"` +} + +type client struct { + httpClient httpwrapper.Client + endpoint string +} + +func (m *client) buildEndpoint(path string, args ...interface{}) string { + endpoint := strings.TrimSuffix(m.endpoint, "/") + return fmt.Sprintf("%s/%s", endpoint, fmt.Sprintf(path, args...)) +} + +const SandboxAPIEndpoint = "https://api-sandbox.modulrfinance.com/api-sandbox-token" + +func New(apiKey, apiSecret, endpoint string) (Client, error) { + if endpoint == "" { + endpoint = SandboxAPIEndpoint + } + + headers, err := hmac.GenerateHeaders(apiKey, apiSecret, "", false) + if err != nil { + return nil, fmt.Errorf("failed to generate headers: %w", err) + } + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("modulr"), + Transport: &apiTransport{ + headers: headers, + apiKey: apiKey, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + } + + return &client{ + httpClient: httpwrapper.NewClient(config), + endpoint: endpoint, + }, nil +} + +type ErrorResponse struct { + Field string `json:"field"` + Code string `json:"code"` + Message string `json:"message"` + ErrorCode string `json:"errorCode"` + SourceService string `json:"sourceService"` +} diff --git a/internal/connectors/plugins/public/modulr/client/client_generated.go b/internal/connectors/plugins/public/modulr/client/client_generated.go new file mode 100644 index 00000000..bec51180 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/client_generated.go @@ -0,0 +1,177 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetAccount mocks base method. +func (m *MockClient) GetAccount(ctx context.Context, accountID string) (*Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) + ret0, _ := ret[0].(*Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockClientMockRecorder) GetAccount(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockClient)(nil).GetAccount), ctx, accountID) +} + +// GetAccounts mocks base method. +func (m *MockClient) GetAccounts(ctx context.Context, page, pageSize int, fromCreatedAt time.Time) ([]Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccounts", ctx, page, pageSize, fromCreatedAt) + ret0, _ := ret[0].([]Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccounts indicates an expected call of GetAccounts. +func (mr *MockClientMockRecorder) GetAccounts(ctx, page, pageSize, fromCreatedAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccounts", reflect.TypeOf((*MockClient)(nil).GetAccounts), ctx, page, pageSize, fromCreatedAt) +} + +// GetBeneficiaries mocks base method. +func (m *MockClient) GetBeneficiaries(ctx context.Context, page, pageSize int, modifiedSince time.Time) ([]Beneficiary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBeneficiaries", ctx, page, pageSize, modifiedSince) + ret0, _ := ret[0].([]Beneficiary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBeneficiaries indicates an expected call of GetBeneficiaries. +func (mr *MockClientMockRecorder) GetBeneficiaries(ctx, page, pageSize, modifiedSince any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBeneficiaries", reflect.TypeOf((*MockClient)(nil).GetBeneficiaries), ctx, page, pageSize, modifiedSince) +} + +// GetPayments mocks base method. +func (m *MockClient) GetPayments(ctx context.Context, paymentType PaymentType, page, pageSize int, modifiedSince time.Time) ([]Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayments", ctx, paymentType, page, pageSize, modifiedSince) + ret0, _ := ret[0].([]Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayments indicates an expected call of GetPayments. +func (mr *MockClientMockRecorder) GetPayments(ctx, paymentType, page, pageSize, modifiedSince any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayments", reflect.TypeOf((*MockClient)(nil).GetPayments), ctx, paymentType, page, pageSize, modifiedSince) +} + +// GetPayout mocks base method. +func (m *MockClient) GetPayout(ctx context.Context, payoutID string) (PayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayout", ctx, payoutID) + ret0, _ := ret[0].(PayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayout indicates an expected call of GetPayout. +func (mr *MockClientMockRecorder) GetPayout(ctx, payoutID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayout", reflect.TypeOf((*MockClient)(nil).GetPayout), ctx, payoutID) +} + +// GetTransactions mocks base method. +func (m *MockClient) GetTransactions(ctx context.Context, accountID string, page, pageSize int, fromTransactionDate time.Time) ([]Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransactions", ctx, accountID, page, pageSize, fromTransactionDate) + ret0, _ := ret[0].([]Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransactions indicates an expected call of GetTransactions. +func (mr *MockClientMockRecorder) GetTransactions(ctx, accountID, page, pageSize, fromTransactionDate any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactions", reflect.TypeOf((*MockClient)(nil).GetTransactions), ctx, accountID, page, pageSize, fromTransactionDate) +} + +// GetTransfer mocks base method. +func (m *MockClient) GetTransfer(ctx context.Context, transferID string) (TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransfer", ctx, transferID) + ret0, _ := ret[0].(TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransfer indicates an expected call of GetTransfer. +func (mr *MockClientMockRecorder) GetTransfer(ctx, transferID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransfer", reflect.TypeOf((*MockClient)(nil).GetTransfer), ctx, transferID) +} + +// InitiatePayout mocks base method. +func (m *MockClient) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiatePayout", ctx, payoutRequest) + ret0, _ := ret[0].(*PayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiatePayout indicates an expected call of InitiatePayout. +func (mr *MockClientMockRecorder) InitiatePayout(ctx, payoutRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiatePayout", reflect.TypeOf((*MockClient)(nil).InitiatePayout), ctx, payoutRequest) +} + +// InitiateTransfer mocks base method. +func (m *MockClient) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiateTransfer", ctx, transferRequest) + ret0, _ := ret[0].(*TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiateTransfer indicates an expected call of InitiateTransfer. +func (mr *MockClientMockRecorder) InitiateTransfer(ctx, transferRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateTransfer", reflect.TypeOf((*MockClient)(nil).InitiateTransfer), ctx, transferRequest) +} diff --git a/internal/connectors/plugins/public/modulr/client/error.go b/internal/connectors/plugins/public/modulr/client/error.go new file mode 100644 index 00000000..dff0488b --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/error.go @@ -0,0 +1,25 @@ +package client + +import ( + "fmt" +) + +type modulrError struct { + StatusCode int `json:"-"` + Field string `json:"field"` + Code string `json:"code"` + Message string `json:"message"` + ErrorCode string `json:"errorCode"` + SourceService string `json:"sourceService"` +} + +func (me *modulrError) Error() error { + var err error + if me.Message == "" { + err = fmt.Errorf("unexpected status code: %d", me.StatusCode) + } else { + err = fmt.Errorf("%d: %s", me.StatusCode, me.Message) + } + + return err +} diff --git a/cmd/connectors/internal/connectors/modulr/hmac/hmac.go b/internal/connectors/plugins/public/modulr/client/hmac/hmac.go similarity index 100% rename from cmd/connectors/internal/connectors/modulr/hmac/hmac.go rename to internal/connectors/plugins/public/modulr/client/hmac/hmac.go diff --git a/cmd/connectors/internal/connectors/modulr/hmac/hmac_test.go b/internal/connectors/plugins/public/modulr/client/hmac/hmac_test.go similarity index 100% rename from cmd/connectors/internal/connectors/modulr/hmac/hmac_test.go rename to internal/connectors/plugins/public/modulr/client/hmac/hmac_test.go diff --git a/cmd/connectors/internal/connectors/modulr/hmac/signature_generator.go b/internal/connectors/plugins/public/modulr/client/hmac/signature_generator.go similarity index 100% rename from cmd/connectors/internal/connectors/modulr/hmac/signature_generator.go rename to internal/connectors/plugins/public/modulr/client/hmac/signature_generator.go diff --git a/cmd/connectors/internal/connectors/modulr/hmac/signature_test.go b/internal/connectors/plugins/public/modulr/client/hmac/signature_test.go similarity index 100% rename from cmd/connectors/internal/connectors/modulr/hmac/signature_test.go rename to internal/connectors/plugins/public/modulr/client/hmac/signature_test.go diff --git a/internal/connectors/plugins/public/modulr/client/payments.go b/internal/connectors/plugins/public/modulr/client/payments.go new file mode 100644 index 00000000..7a4c6027 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/payments.go @@ -0,0 +1,66 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type PaymentType string + +const ( + PAYIN PaymentType = "PAYIN" + PAYOUT PaymentType = "PAYOUT" +) + +type Payment struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedDate string `json:"createdDate"` + ExternalReference string `json:"externalReference"` + ApprovalStatus string `json:"approvalStatus"` + CreatedBy string `json:"createdBy"` + Type string `json:"type"` + Details struct { + AccountNumber string `json:"accountNumber"` + SourceAccountID string `json:"sourceAccountId"` + Destination struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"destination"` + Amount json.Number `json:"amount"` + Currency string `json:"currency"` + } `json:"details"` +} + +func (c *client) GetPayments(ctx context.Context, paymentType PaymentType, page, pageSize int, modifiedSince time.Time) ([]Payment, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_payments") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("payments"), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create accounts request: %w", err) + } + + q := req.URL.Query() + q.Add("page", strconv.Itoa(page)) + q.Add("size", strconv.Itoa(pageSize)) + q.Add("type", string(paymentType)) + q.Add("sortOrder", "asc") + if !modifiedSince.IsZero() { + q.Add("modifiedSince", modifiedSince.Format("2006-01-02T15:04:05-0700")) + } + req.URL.RawQuery = q.Encode() + + var res responseWrapper[[]Payment] + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get payments: %w %w", err, errRes.Error()) + } + return res.Content, nil +} diff --git a/internal/connectors/plugins/public/modulr/client/payout.go b/internal/connectors/plugins/public/modulr/client/payout.go new file mode 100644 index 00000000..e7cee172 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/payout.go @@ -0,0 +1,72 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type PayoutRequest struct { + IdempotencyKey string `json:"-"` + SourceAccountID string `json:"sourceAccountId"` + Destination Destination `json:"destination"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + Reference string `json:"reference"` + ExternalReference string `json:"externalReference"` +} + +type PayoutResponse struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedDate string `json:"createdDate"` + ExternalReference string `json:"externalReference"` + ApprovalStatus string `json:"approvalStatus"` + Message string `json:"message"` + Details Details `json:"details"` +} + +func (c *client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_payout") + + body, err := json.Marshal(payoutRequest) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.buildEndpoint("payments"), bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create payout request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-mod-nonce", payoutRequest.IdempotencyKey) + + var res PayoutResponse + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to create payout: %w %w", err, errRes.Error()) + } + return &res, nil +} + +func (c *client) GetPayout(ctx context.Context, payoutID string) (PayoutResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_payout") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("payments?id=%s", payoutID), nil) + if err != nil { + return PayoutResponse{}, fmt.Errorf("failed to create get payout request: %w", err) + } + + var res PayoutResponse + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return PayoutResponse{}, fmt.Errorf("failed to get payout: %w %w", err, errRes.Error()) + } + return res, nil +} diff --git a/internal/connectors/plugins/public/modulr/client/transactions.go b/internal/connectors/plugins/public/modulr/client/transactions.go new file mode 100644 index 00000000..22ca42c9 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/transactions.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +//nolint:tagliatelle // allow different styled tags in client +type Transaction struct { + ID string `json:"id"` + Type string `json:"type"` + Amount json.Number `json:"amount"` + Credit bool `json:"credit"` + SourceID string `json:"sourceId"` + Description string `json:"description"` + PostedDate string `json:"postedDate"` + TransactionDate string `json:"transactionDate"` + Account Account `json:"account"` + AdditionalInfo interface{} `json:"additionalInfo"` +} + +func (c *client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, fromTransactionDate time.Time) ([]Transaction, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_transactions") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("accounts/%s/transactions", accountID), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create accounts request: %w", err) + } + + q := req.URL.Query() + q.Add("page", strconv.Itoa(page)) + q.Add("size", strconv.Itoa(pageSize)) + if !fromTransactionDate.IsZero() { + q.Add("fromTransactionDate", fromTransactionDate.Format("2006-01-02T15:04:05-0700")) + } + req.URL.RawQuery = q.Encode() + + var res responseWrapper[[]Transaction] + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get transactions: %w %w", err, errRes.Error()) + } + return res.Content, nil +} diff --git a/internal/connectors/plugins/public/modulr/client/transfer.go b/internal/connectors/plugins/public/modulr/client/transfer.go new file mode 100644 index 00000000..affba012 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/client/transfer.go @@ -0,0 +1,99 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type DestinationType string + +const ( + DestinationTypeAccount DestinationType = "ACCOUNT" + DestinationTypeBeneficiary DestinationType = "BENEFICIARY" +) + +type Destination struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type Details struct { + SourceAccountID string `json:"sourceAccountId"` + Destination Destination `json:"destination"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` +} + +type TransferRequest struct { + IdempotencyKey string `json:"-"` + SourceAccountID string `json:"sourceAccountId"` + Destination Destination `json:"destination"` + Currency string `json:"currency"` + Amount json.Number `json:"amount"` + Reference string `json:"reference"` + ExternalReference string `json:"externalReference"` +} + +type getTransferResponse struct { + Content []TransferResponse `json:"content"` +} + +type TransferResponse struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedDate string `json:"createdDate"` + ExternalReference string `json:"externalReference"` + ApprovalStatus string `json:"approvalStatus"` + Message string `json:"message"` + Details Details `json:"details"` +} + +func (c *client) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_transfer") + + body, err := json.Marshal(transferRequest) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, c.buildEndpoint("payments"), bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create transfer request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-mod-nonce", transferRequest.IdempotencyKey) + + var res TransferResponse + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to initiate transfer: %w %w", err, errRes.Error()) + } + return &res, nil +} + +func (c *client) GetTransfer(ctx context.Context, transferID string) (TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_transfer") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("payments?id=%s", transferID), nil) + if err != nil { + return TransferResponse{}, fmt.Errorf("failed to create get transfer request: %w", err) + } + + var res getTransferResponse + var errRes modulrError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return TransferResponse{}, fmt.Errorf("failed to get transfer: %w %w", err, errRes.Error()) + } + + if len(res.Content) == 0 { + return TransferResponse{}, fmt.Errorf("transfer not found") + } + return res.Content[0], nil +} diff --git a/internal/connectors/plugins/public/modulr/config.go b/internal/connectors/plugins/public/modulr/config.go new file mode 100644 index 00000000..b0551374 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/config.go @@ -0,0 +1,39 @@ +package modulr + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + APIKey string `json:"apiKey"` + APISecret string `json:"apiSecret"` + Endpoint string `json:"endpoint"` +} + +func (c Config) validate() error { + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api key in config") + } + + if c.APISecret == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api secret in config") + } + + if c.Endpoint == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing endpoint in config") + } + + return nil +} + +func unmarshalAndValidateConfig(payload []byte) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/modulr/config.json b/internal/connectors/plugins/public/modulr/config.json new file mode 100644 index 00000000..c2b6ee2d --- /dev/null +++ b/internal/connectors/plugins/public/modulr/config.json @@ -0,0 +1,17 @@ +{ + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "apiSecret": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "endpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/modulr/currencies.go b/internal/connectors/plugins/public/modulr/currencies.go new file mode 100644 index 00000000..e0f653aa --- /dev/null +++ b/internal/connectors/plugins/public/modulr/currencies.go @@ -0,0 +1,20 @@ +package modulr + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + // c.f. https://modulr.readme.io/docs/international-payments + supportedCurrenciesWithDecimal = map[string]int{ + "GBP": currency.ISO4217Currencies["GBP"], // Pound Sterling + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "CZK": currency.ISO4217Currencies["CZK"], // Czech Koruna + "DKK": currency.ISO4217Currencies["DKK"], // Danish Krone + "NOK": currency.ISO4217Currencies["NOK"], // Norwegian Krone + "PLN": currency.ISO4217Currencies["PLN"], // Poland, Zloty + "SEK": currency.ISO4217Currencies["SEK"], // Swedish Krona + "CHF": currency.ISO4217Currencies["CHF"], // Swiss Franc + "USD": currency.ISO4217Currencies["USD"], // US Dollar + "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong Dollar + "JPY": currency.ISO4217Currencies["JPY"], // Japan, Yen + } +) diff --git a/internal/connectors/plugins/public/modulr/external_accounts.go b/internal/connectors/plugins/public/modulr/external_accounts.go new file mode 100644 index 00000000..81a16607 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/external_accounts.go @@ -0,0 +1,106 @@ +package modulr + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type externalAccountsState struct { + LastModifiedSince time.Time `json:"lastModifiedSince"` +} + +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + + newState := externalAccountsState{ + LastModifiedSince: oldState.LastModifiedSince, + } + + accounts := make([]models.PSPAccount, 0, req.PageSize) + needMore := false + hasMore := false + for page := 0; ; page++ { + pagedBeneficiaries, err := p.client.GetBeneficiaries(ctx, page, req.PageSize, oldState.LastModifiedSince) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + accounts, err = fillBeneficiaries(pagedBeneficiaries, accounts, oldState, req.PageSize) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedBeneficiaries, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastModifiedSince = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillBeneficiaries( + pagedBeneficiaries []client.Beneficiary, + accounts []models.PSPAccount, + oldState externalAccountsState, + pageSize int, +) ([]models.PSPAccount, error) { + for _, beneficiary := range pagedBeneficiaries { + if len(accounts) >= pageSize { + break + } + + createdTime, err := time.Parse("2006-01-02T15:04:05.999-0700", beneficiary.Created) + if err != nil { + return nil, err + } + + switch createdTime.Compare(oldState.LastModifiedSince) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + raw, err := json.Marshal(beneficiary) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: beneficiary.ID, + CreatedAt: createdTime, + Name: &beneficiary.Name, + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/modulr/external_accounts_test.go b/internal/connectors/plugins/public/modulr/external_accounts_test.go new file mode 100644 index 00000000..5f4b0653 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/external_accounts_test.go @@ -0,0 +1,169 @@ +package modulr + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Modulr Plugin External Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next external accounts", func() { + var ( + m *client.MockClient + sampleBeneficiaries []client.Beneficiary + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBeneficiaries = make([]client.Beneficiary, 0) + for i := 0; i < 50; i++ { + sampleBeneficiaries = append(sampleBeneficiaries, client.Beneficiary{ + ID: fmt.Sprintf("%d", i), + Name: fmt.Sprintf("Account %d", i), + Created: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format("2006-01-02T15:04:05.999-0700"), + }) + } + }) + + It("should return an error - get beneficiaries error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 0, 60, time.Time{}).Return( + []client.Beneficiary{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 0, 60, time.Time{}).Return( + []client.Beneficiary{}, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastModifiedSince.IsZero()).To(BeTrue()) + }) + + It("should fetch next external accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 0, 60, time.Time{}).Return( + sampleBeneficiaries, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleBeneficiaries[49].Created) + Expect(state.LastModifiedSince.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 0, 40, time.Time{}).Return( + sampleBeneficiaries[:40], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleBeneficiaries[39].Created) + Expect(state.LastModifiedSince.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next external accounts - with state pageSize < total accounts", func(ctx SpecContext) { + lastCreatedAt, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleBeneficiaries[38].Created) + req := models.FetchNextExternalAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastModifiedSince": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + } + + m.EXPECT().GetBeneficiaries(gomock.Any(), 0, 40, lastCreatedAt.UTC()).Return( + sampleBeneficiaries[:40], + nil, + ) + + m.EXPECT().GetBeneficiaries(gomock.Any(), 1, 40, lastCreatedAt.UTC()).Return( + sampleBeneficiaries[41:], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleBeneficiaries[49].Created) + Expect(state.LastModifiedSince.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/modulr/payments.go b/internal/connectors/plugins/public/modulr/payments.go new file mode 100644 index 00000000..c959440c --- /dev/null +++ b/internal/connectors/plugins/public/modulr/payments.go @@ -0,0 +1,221 @@ +package modulr + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type paymentsState struct { + LastTransactionTime time.Time `json:"lastTransactionTime"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextPaymentsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + newState := paymentsState{ + LastTransactionTime: oldState.LastTransactionTime, + } + + var payments []models.PSPPayment + needMore := false + hasMore := false + for page := 0; ; page++ { + pagedTransactions, err := p.client.GetTransactions(ctx, from.Reference, page, req.PageSize, oldState.LastTransactionTime) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, err = p.fillPayments(ctx, pagedTransactions, from, payments, oldState, req.PageSize) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedTransactions, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + payments = payments[:req.PageSize] + } + + if len(payments) > 0 { + newState.LastTransactionTime = payments[len(payments)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func (p *Plugin) fillPayments( + ctx context.Context, + pagedTransactions []client.Transaction, + from models.PSPAccount, + payments []models.PSPPayment, + oldState paymentsState, + pageSize int, +) ([]models.PSPPayment, error) { + for _, transaction := range pagedTransactions { + if len(payments) >= pageSize { + break + } + + createdTime, err := time.Parse("2006-01-02T15:04:05.999-0700", transaction.TransactionDate) + if err != nil { + return nil, err + } + + switch createdTime.Compare(oldState.LastTransactionTime) { + case -1, 0: + // Account already ingested, skip + continue + default: + } + + payment, err := p.transactionToPayment(ctx, transaction, from) + if err != nil { + return nil, err + } + + if payment != nil { + payments = append(payments, *payment) + } + } + + return payments, nil +} + +func (p *Plugin) transactionToPayment( + ctx context.Context, + transaction client.Transaction, + from models.PSPAccount, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(transaction) + if err != nil { + return nil, err + } + + paymentType := matchTransactionType(transaction.Type) + switch paymentType { + case models.PAYMENT_TYPE_TRANSFER: + // We want to fetch the transfer details in order to have the source + // and destination account references + return p.fetchAndTranslateTransfer(ctx, transaction) + default: + } + + precision, ok := supportedCurrenciesWithDecimal[transaction.Account.Currency] + if !ok { + return nil, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(transaction.Amount.String(), precision) + if err != nil { + return nil, fmt.Errorf("failed to parse amount %s: %w", transaction.Amount, err) + } + + createdAt, err := time.Parse("2006-01-02T15:04:05.999-0700", transaction.PostedDate) + if err != nil { + return nil, fmt.Errorf("failed to parse posted date %s: %w", transaction.PostedDate, err) + } + + payment := &models.PSPPayment{ + Reference: transaction.SourceID, // Do not take the transaction ID, but the source ID + CreatedAt: createdAt, + Type: paymentType, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Account.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Raw: raw, + } + + switch paymentType { + case models.PAYMENT_TYPE_PAYIN: + payment.DestinationAccountReference = &from.Reference + case models.PAYMENT_TYPE_PAYOUT: + payment.SourceAccountReference = &from.Reference + default: + if transaction.Credit { + payment.DestinationAccountReference = &from.Reference + } else { + payment.SourceAccountReference = &from.Reference + } + } + + return payment, nil +} + +func (p *Plugin) fetchAndTranslateTransfer( + ctx context.Context, + transaction client.Transaction, +) (*models.PSPPayment, error) { + if !transaction.Credit { + // Transfer are reprensented as double transactions: one for the source + // account and one for the destination account. We don't want to generate + // multiple events for the same transfer, and since we are fetching the + // whole object, we can safely send it once. Let's ignore the transfer + // if the transaction is a debit. It will be fetch on the other side ( + // the other account's transaction) + return nil, nil + } + + transfer, err := p.client.GetTransfer(ctx, transaction.SourceID) + if err != nil { + return nil, err + } + + return translateTransferToPayment(&transfer) +} + +func matchTransactionType(transactionType string) models.PaymentType { + if transactionType == "PI_REV" || + transactionType == "PO_REV" || + transactionType == "ADHOC" { + return models.PAYMENT_TYPE_OTHER + } + + if transactionType == "INT_INTERC" { + return models.PAYMENT_TYPE_TRANSFER + } + + if strings.HasPrefix(transactionType, "PI_") { + return models.PAYMENT_TYPE_PAYIN + } + + if strings.HasPrefix(transactionType, "PO_") { + return models.PAYMENT_TYPE_PAYOUT + } + + return models.PAYMENT_TYPE_OTHER +} diff --git a/internal/connectors/plugins/public/modulr/payments_test.go b/internal/connectors/plugins/public/modulr/payments_test.go new file mode 100644 index 00000000..f28735f9 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/payments_test.go @@ -0,0 +1,424 @@ +package modulr + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Modulr Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleTransactions []client.Transaction + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleTransactions = make([]client.Transaction, 0) + for i := 0; i < 50; i++ { + sampleTransactions = append(sampleTransactions, client.Transaction{ + ID: fmt.Sprintf("%d", i), + Type: "PI_FAST", + Amount: "100.01", + Credit: false, + SourceID: fmt.Sprintf("test-%d", i), + Description: fmt.Sprintf("Description %d", i), + PostedDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format("2006-01-02T15:04:05.999-0700"), + TransactionDate: now.Add(-time.Duration(50-i) * time.Minute).UTC().Format("2006-01-02T15:04:05.999-0700"), + Account: client.Account{ + Currency: "USD", + }, + }) + } + }) + + It("should return an error - missing from payload", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should return an error - get transactions error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 0, 60, time.Time{}).Return( + []client.Transaction{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 0, 60, time.Time{}).Return( + []client.Transaction{}, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastTransactionTime.IsZero()).To(BeTrue()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 0, 60, time.Time{}).Return( + sampleTransactions, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleTransactions[49].PostedDate) + Expect(state.LastTransactionTime.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 40, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 0, 40, time.Time{}).Return( + sampleTransactions[:40], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleTransactions[39].PostedDate) + Expect(state.LastTransactionTime.UTC()).To(Equal(createdTime.UTC())) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + lastCreatedAt, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleTransactions[38].PostedDate) + req := models.FetchNextPaymentsRequest{ + State: []byte(fmt.Sprintf(`{"lastTransactionTime": "%s"}`, lastCreatedAt.UTC().Format(time.RFC3339Nano))), + PageSize: 40, + FromPayload: []byte(`{"reference": "test"}`), + } + + m.EXPECT().GetTransactions(gomock.Any(), "test", 0, 40, lastCreatedAt.UTC()).Return( + sampleTransactions[:40], + nil, + ) + + m.EXPECT().GetTransactions(gomock.Any(), "test", 1, 40, lastCreatedAt.UTC()).Return( + sampleTransactions[41:], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdTime, _ := time.Parse("2006-01-02T15:04:05.999-0700", sampleTransactions[49].PostedDate) + Expect(state.LastTransactionTime.UTC()).To(Equal(createdTime.UTC())) + }) + }) +}) + +var _ = Describe("Modulr Plugin Transaction to Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + samplePayinTransaction client.Transaction + samplePayoutTransaction client.Transaction + sampleTransferTransaction client.Transaction + sampleTransfer client.TransferResponse + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now, _ = time.Parse("2006-01-02T15:04:05.999-0700", time.Now().UTC().Format("2006-01-02T15:04:05.999-0700")) + + sampleTransfer = client.TransferResponse{ + ID: "test", + CreatedDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + ExternalReference: "test1", + ApprovalStatus: "test1", + Status: "PROCESSED", + Details: client.Details{ + SourceAccountID: "acc1", + Destination: client.Destination{ + ID: "acc2", + }, + Currency: "EUR", + Amount: "150.01", + }, + } + + samplePayinTransaction = client.Transaction{ + ID: "1", + Type: "PI_FAST", + Amount: "130.00", + Credit: true, + SourceID: "PI1", + Description: "test1", + PostedDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + TransactionDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + Account: client.Account{ + Currency: "USD", + }, + } + + samplePayoutTransaction = client.Transaction{ + ID: "2", + Type: "PO_FAST", + Amount: "145.08", + Credit: false, + SourceID: "PO2", + Description: "test2", + PostedDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + TransactionDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + Account: client.Account{ + Currency: "USD", + }, + } + + sampleTransferTransaction = client.Transaction{ + ID: "3", + Type: "INT_INTERC", + Amount: "150.01", + Credit: true, + SourceID: "test", + Description: "test3", + PostedDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + TransactionDate: now.UTC().Format("2006-01-02T15:04:05.999-0700"), + Account: client.Account{ + Currency: "EUR", + }, + } + }) + + It("should return an error - wrong amount string", func(ctx SpecContext) { + po := samplePayoutTransaction + po.Amount = "wrong" + payment, err := plg.transactionToPayment(ctx, po, models.PSPAccount{}) + Expect(err).ToNot(BeNil()) + Expect(payment).To(BeNil()) + _ = samplePayinTransaction + _ = sampleTransferTransaction + _ = sampleTransfer + }) + + It("should return an error - wrong posted date", func(ctx SpecContext) { + po := samplePayoutTransaction + po.PostedDate = "wrong" + payment, err := plg.transactionToPayment(ctx, po, models.PSPAccount{}) + Expect(err).ToNot(BeNil()) + Expect(payment).To(BeNil()) + }) + + It("should return an error - fetch transfer error", func(ctx SpecContext) { + m.EXPECT().GetTransfer(ctx, "test").Return( + client.TransferResponse{}, + errors.New("test error"), + ) + + payment, err := plg.fetchAndTranslateTransfer(ctx, sampleTransferTransaction) + Expect(err).ToNot(BeNil()) + Expect(payment).To(BeNil()) + }) + + It("should return a nil payment - unhandled currency", func(ctx SpecContext) { + po := samplePayoutTransaction + po.Account.Currency = "HUF" + payment, err := plg.transactionToPayment(ctx, po, models.PSPAccount{}) + Expect(err).To(BeNil()) + Expect(payment).To(BeNil()) + }) + + It("should return a payin payment", func(ctx SpecContext) { + payment, err := plg.transactionToPayment(ctx, samplePayinTransaction, models.PSPAccount{Reference: "acc1"}) + Expect(err).To(BeNil()) + Expect(payment).ToNot(BeNil()) + + expected := models.PSPPayment{ + Reference: samplePayinTransaction.SourceID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYIN, + Amount: big.NewInt(13000), + Asset: "USD/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + DestinationAccountReference: pointer.For("acc1"), + } + + comparePSPPayments(*payment, expected) + }) + + It("should return a payout payment", func(ctx SpecContext) { + payment, err := plg.transactionToPayment(ctx, samplePayoutTransaction, models.PSPAccount{Reference: "acc2"}) + Expect(err).To(BeNil()) + Expect(payment).ToNot(BeNil()) + + expected := models.PSPPayment{ + Reference: samplePayoutTransaction.SourceID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(14508), + Asset: "USD/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc2"), + } + + comparePSPPayments(*payment, expected) + }) + + It("should return a transfer payment", func(ctx SpecContext) { + m.EXPECT().GetTransfer(gomock.Any(), "test").Return( + sampleTransfer, + nil, + ) + + payment, err := plg.transactionToPayment(ctx, sampleTransferTransaction, models.PSPAccount{Reference: "acc1"}) + Expect(err).To(BeNil()) + Expect(payment).ToNot(BeNil()) + + expected := models.PSPPayment{ + Reference: sampleTransferTransaction.SourceID, + CreatedAt: now, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(15001), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + } + + comparePSPPayments(*payment, expected) + }) + + It("should return a nil payment - transfer in debit", func(ctx SpecContext) { + tr := sampleTransferTransaction + tr.Credit = false + + payment, err := plg.transactionToPayment(ctx, tr, models.PSPAccount{Reference: "acc1"}) + Expect(err).To(BeNil()) + Expect(payment).To(BeNil()) + }) + }) +}) + +func comparePSPPayments(a, b models.PSPPayment) { + Expect(a.ParentReference).To(Equal(b.ParentReference)) + Expect(a.Reference).To(Equal(b.Reference)) + Expect(a.CreatedAt.UTC()).To(Equal(b.CreatedAt.UTC())) + Expect(a.Type).To(Equal(b.Type)) + Expect(a.Amount).To(Equal(b.Amount)) + Expect(a.Asset).To(Equal(b.Asset)) + Expect(a.Scheme).To(Equal(b.Scheme)) + Expect(a.Status).To(Equal(b.Status)) + + switch { + case a.SourceAccountReference != nil && b.SourceAccountReference != nil: + Expect(*a.SourceAccountReference).To(Equal(*b.SourceAccountReference)) + case a.SourceAccountReference == nil && b.SourceAccountReference == nil: + default: + Fail(fmt.Sprintf("SourceAccountReference mismatch: %v != %v", a.SourceAccountReference, b.SourceAccountReference)) + } + + switch { + case a.DestinationAccountReference != nil && b.DestinationAccountReference != nil: + Expect(*a.DestinationAccountReference).To(Equal(*b.DestinationAccountReference)) + case a.DestinationAccountReference == nil && b.DestinationAccountReference == nil: + default: + Fail(fmt.Sprintf("DestinationAccountReference mismatch: %v != %v", a.DestinationAccountReference, b.DestinationAccountReference)) + } + + Expect(len(a.Metadata)).To(Equal(len(b.Metadata))) + for k, v := range a.Metadata { + Expect(v).To(Equal(b.Metadata[k])) + } +} diff --git a/internal/connectors/plugins/public/modulr/payouts.go b/internal/connectors/plugins/public/modulr/payouts.go new file mode 100644 index 00000000..6181cab6 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/payouts.go @@ -0,0 +1,87 @@ +package modulr + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (*models.PSPPayment, error) { + if err := p.validateTransferPayoutRequests(pi); err != nil { + return nil, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return nil, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return nil, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + description := pi.Description + + resp, err := p.client.InitiatePayout(ctx, &client.PayoutRequest{ + IdempotencyKey: pi.Reference, + SourceAccountID: pi.SourceAccount.Reference, + Destination: client.Destination{ + Type: string(client.DestinationTypeBeneficiary), + ID: pi.DestinationAccount.Reference, + }, + Currency: curr, + Amount: json.Number(amount), + Reference: description, + ExternalReference: description, + }) + if err != nil { + return nil, err + } + + return translatePayoutToPayment(resp) +} + +func translatePayoutToPayment( + from *client.PayoutResponse, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return nil, err + } + + status := matchPaymentStatus(from.Status) + + createdAt, err := time.Parse("2006-01-02T15:04:05.999-0700", from.CreatedDate) + if err != nil { + return nil, fmt.Errorf("failed to parse posted date %s: %w", from.CreatedDate, err) + } + + precision, ok := supportedCurrenciesWithDecimal[from.Details.Currency] + if !ok { + return nil, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.Details.Amount.String(), precision) + if err != nil { + return nil, fmt.Errorf("failed to parse amount %s: %w", from.Details.Amount, err) + } + + return &models.PSPPayment{ + Reference: from.ID, + CreatedAt: createdAt, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Details.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + SourceAccountReference: &from.Details.SourceAccountID, + DestinationAccountReference: &from.Details.Destination.ID, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/modulr/payouts_test.go b/internal/connectors/plugins/public/modulr/payouts_test.go new file mode 100644 index 00000000..b2897082 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/payouts_test.go @@ -0,0 +1,180 @@ +package modulr + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Modulr Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now, _ = time.Parse("2006-01-02T15:04:05.999-0700", time.Now().UTC().Format("2006-01-02T15:04:05.999-0700")) + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now, + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - initiate payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + Destination: client.Destination{ + Type: "BENEFICIARY", + ID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }, + Currency: "EUR", + Amount: "1.00", + Reference: samplePSPPaymentInitiation.Description, + ExternalReference: samplePSPPaymentInitiation.Description, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.PayoutResponse{ + ID: "1", + Status: "PROCESSED", + CreatedDate: now.Format("2006-01-02T15:04:05.999-0700"), + ExternalReference: samplePSPPaymentInitiation.Description, + Details: client.Details{ + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + Destination: client.Destination{ + Type: "BENEFICIARY", + ID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }, + Currency: "EUR", + Amount: "1.00", + }, + } + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + Destination: client.Destination{ + Type: "BENEFICIARY", + ID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }, + Currency: "EUR", + Amount: "1.00", + Reference: samplePSPPaymentInitiation.Description, + ExternalReference: samplePSPPaymentInitiation.Description, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/modulr/plugin.go b/internal/connectors/plugins/public/modulr/plugin.go new file mode 100644 index 00000000..3393b70b --- /dev/null +++ b/internal/connectors/plugins/public/modulr/plugin.go @@ -0,0 +1,143 @@ +package modulr + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("modulr", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client, err := client.New(config.APIKey, config.APISecret, config.Endpoint) + if err != nil { + return nil, err + } + + return &Plugin{ + name: name, + client: client, + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + + return models.CreateTransferResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + return models.CreatePayoutResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/modulr/plugin_test.go b/internal/connectors/plugins/public/modulr/plugin_test.go new file mode 100644 index 00000000..4b5c1dae --- /dev/null +++ b/internal/connectors/plugins/public/modulr/plugin_test.go @@ -0,0 +1,190 @@ +package modulr + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Modulr Plugin Suite") +} + +var _ = Describe("Modulr Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - apiSecret", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey": "test", "endpoint": "test"}`) + _, err := New("modulr", config) + Expect(err).To(MatchError("missing api secret in config: invalid config")) + }) + + It("should report errors in config - apiKey", func(ctx SpecContext) { + config := json.RawMessage(`{"apiSecret": "test", "endpoint": "test"}`) + _, err := New("modulr", config) + Expect(err).To(MatchError("missing api key in config: invalid config")) + }) + + It("should report errors in config - endpoint", func(ctx SpecContext) { + config := json.RawMessage(`{"apiSecret": "test", "apiKey": "test"}`) + _, err := New("modulr", config) + Expect(err).To(MatchError("missing endpoint in config: invalid config")) + }) + + It("should return valid install response", func(ctx SpecContext) { + config := json.RawMessage(`{"apiSecret": "test", "apiKey": "test", "endpoint": "test"}`) + _, err := New("modulr", config) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in external_accounts_test.go + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create bank account", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create transfer", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in transfers_test.go + }) + + Context("reverse transfer", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payouts_test.go + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create webhooks", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("translate webhook", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/modulr/transfers.go b/internal/connectors/plugins/public/modulr/transfers.go new file mode 100644 index 00000000..b6ddb1bb --- /dev/null +++ b/internal/connectors/plugins/public/modulr/transfers.go @@ -0,0 +1,107 @@ +package modulr + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (*models.PSPPayment, error) { + if err := p.validateTransferPayoutRequests(pi); err != nil { + return nil, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return nil, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return nil, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + description := pi.Description + + resp, err := p.client.InitiateTransfer( + ctx, + &client.TransferRequest{ + IdempotencyKey: pi.Reference, + SourceAccountID: pi.SourceAccount.Reference, + Destination: client.Destination{ + Type: string(client.DestinationTypeAccount), + ID: pi.DestinationAccount.Reference, + }, + Currency: curr, + Amount: json.Number(amount), + Reference: description, + ExternalReference: description, + }, + ) + if err != nil { + return nil, err + } + + return translateTransferToPayment(resp) +} + +func translateTransferToPayment( + from *client.TransferResponse, +) (*models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return nil, err + } + + status := matchPaymentStatus(from.Status) + + createdAt, err := time.Parse("2006-01-02T15:04:05.999-0700", from.CreatedDate) + if err != nil { + return nil, fmt.Errorf("failed to parse posted date %s: %w", from.CreatedDate, err) + } + + precision, ok := supportedCurrenciesWithDecimal[from.Details.Currency] + if !ok { + return nil, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.Details.Amount.String(), precision) + if err != nil { + return nil, fmt.Errorf("failed to parse amount %s: %w", from.Details.Amount, err) + } + + return &models.PSPPayment{ + Reference: from.ID, + CreatedAt: createdAt, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Details.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: status, + SourceAccountReference: &from.Details.SourceAccountID, + DestinationAccountReference: &from.Details.Destination.ID, + Raw: raw, + }, nil +} + +func matchPaymentStatus(status string) models.PaymentStatus { + switch status { + case "SUBMITTED", "VALIDATED", "PENDING_FOR_DATE", "PENDING_FOR_FUNDS", "ER_EXTCONN": + return models.PAYMENT_STATUS_PENDING + case "PROCESSED": + return models.PAYMENT_STATUS_SUCCEEDED + case "CANCELLED": + return models.PAYMENT_STATUS_CANCELLED + case "ER_EXPIRED": + return models.PAYMENT_STATUS_EXPIRED + case "ER_INVALID", "ER_EXTSYS", "ER_GENERAL": + return models.PAYMENT_STATUS_FAILED + default: + return models.PAYMENT_STATUS_UNKNOWN + } +} diff --git a/internal/connectors/plugins/public/modulr/transfers_test.go b/internal/connectors/plugins/public/modulr/transfers_test.go new file mode 100644 index 00000000..e8e7f6b8 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/transfers_test.go @@ -0,0 +1,181 @@ +package modulr + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Modulr Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now, _ = time.Parse("2006-01-02T15:04:05.999-0700", time.Now().UTC().Format("2006-01-02T15:04:05.999-0700")) + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - initiate transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiateTransfer(gomock.Any(), &client.TransferRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + Destination: client.Destination{ + Type: "ACCOUNT", + ID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }, + Currency: "EUR", + Amount: "1.00", + Reference: samplePSPPaymentInitiation.Description, + ExternalReference: samplePSPPaymentInitiation.Description, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.TransferResponse{ + ID: "1", + Status: "PROCESSED", + CreatedDate: now.Format("2006-01-02T15:04:05.999-0700"), + ExternalReference: samplePSPPaymentInitiation.Description, + Details: client.Details{ + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + Destination: client.Destination{ + Type: "ACCOUNT", + ID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }, + Currency: "EUR", + Amount: "1.00", + }, + } + m.EXPECT().InitiateTransfer(gomock.Any(), &client.TransferRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + Destination: client.Destination{ + Type: "ACCOUNT", + ID: samplePSPPaymentInitiation.DestinationAccount.Reference, + }, + Currency: "EUR", + Amount: "1.00", + Reference: samplePSPPaymentInitiation.Description, + ExternalReference: samplePSPPaymentInitiation.Description, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: now, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Raw: raw, + }, + })) + }) + + }) +}) diff --git a/internal/connectors/plugins/public/modulr/utils.go b/internal/connectors/plugins/public/modulr/utils.go new file mode 100644 index 00000000..c3547d74 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/utils.go @@ -0,0 +1,28 @@ +package modulr + +import ( + "fmt" + "regexp" + + "github.com/formancehq/payments/internal/models" +) + +var ( + referencePatternRegexp = regexp.MustCompile("[a-zA-Z0-9 ]*") +) + +func (p *Plugin) validateTransferPayoutRequests(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + if len(pi.Description) > 18 || !referencePatternRegexp.MatchString(pi.Description) { + return fmt.Errorf("description is invalid: %w", models.ErrInvalidRequest) + } + + return nil +} diff --git a/internal/connectors/plugins/public/modulr/workflow.go b/internal/connectors/plugins/public/modulr/workflow.go new file mode 100644 index 00000000..cd367780 --- /dev/null +++ b/internal/connectors/plugins/public/modulr/workflow.go @@ -0,0 +1,33 @@ +package modulr + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_beneficiaries", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } +} diff --git a/internal/connectors/plugins/public/moneycorp/accounts.go b/internal/connectors/plugins/public/moneycorp/accounts.go new file mode 100644 index 00000000..a04c6db9 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/accounts.go @@ -0,0 +1,114 @@ +package moneycorp + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type accountsState struct { + LastPage int `json:"lastPage"` + // Moneycorp does not send the creation date for accounts, but we can still + // sort by ID created (which is incremental when creating accounts). + LastIDCreated int64 `json:"lastIDCreated"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + if oldState.LastIDCreated == 0 { + oldState.LastIDCreated = -1 + } + + newState := accountsState{ + LastPage: oldState.LastPage, + LastIDCreated: oldState.LastIDCreated, + } + + accounts := make([]models.PSPAccount, 0, req.PageSize) + needMore := false + hasMore := false + for page := oldState.LastPage; ; page++ { + newState.LastPage = page + + pagedAccounts, err := p.client.GetAccounts(ctx, page, req.PageSize) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + accounts, err = toPSPAccounts(oldState.LastIDCreated, accounts, pagedAccounts) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedAccounts, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + id, err := strconv.ParseInt(accounts[len(accounts)-1].Reference, 10, 64) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + newState.LastIDCreated = id + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func toPSPAccounts( + lastIDSeen int64, + accounts []models.PSPAccount, + pagedAccounts []*client.Account, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + id, err := strconv.ParseInt(account.ID, 10, 64) + if err != nil { + return accounts, err + } + + if id <= lastIDSeen { + continue + } + + raw, err := json.Marshal(account) + if err != nil { + return accounts, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.ID, + // Moneycorp does not send the opening date of the account + CreatedAt: time.Now().UTC(), + Name: &account.Attributes.AccountName, + Raw: raw, + }) + } + return accounts, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/accounts_test.go b/internal/connectors/plugins/public/moneycorp/accounts_test.go new file mode 100644 index 00000000..9a69637f --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/accounts_test.go @@ -0,0 +1,159 @@ +package moneycorp + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp *Plugin Accounts", func() { + Context("fetch next accounts", func() { + var ( + plg *Plugin + m *client.MockClient + + sampleAccounts []*client.Account + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + + sampleAccounts = make([]*client.Account, 0) + for i := 0; i < 50; i++ { + sampleAccounts = append(sampleAccounts, &client.Account{ + ID: fmt.Sprintf("%d", i), + }) + } + + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 60).Return( + []*client.Account{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 60).Return( + []*client.Account{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastIDCreated).To(Equal(int64(-1))) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 60).Return( + sampleAccounts, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastIDCreated).To(Equal(int64(49))) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 40).Return( + sampleAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastIDCreated).To(Equal(int64(39))) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": %d, "lastIDCreated": 38}`, 0)), + PageSize: 40, + } + + m.EXPECT().GetAccounts(gomock.Any(), 0, 40).Return( + sampleAccounts[:40], + nil, + ) + + m.EXPECT().GetAccounts(gomock.Any(), 1, 40).Return( + sampleAccounts[41:], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + Expect(state.LastIDCreated).To(Equal(int64(49))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/balances.go b/internal/connectors/plugins/public/moneycorp/balances.go new file mode 100644 index 00000000..63775eff --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/balances.go @@ -0,0 +1,50 @@ +package moneycorp + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balances, err := p.client.GetAccountBalances(ctx, from.Reference) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + var accountBalances []models.PSPBalance + for _, balance := range balances { + precision, err := currency.GetPrecision(supportedCurrenciesWithDecimal, balance.Attributes.CurrencyCode) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + amount, err := currency.GetAmountWithPrecisionFromString(balance.Attributes.AvailableBalance.String(), precision) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + accountBalances = append(accountBalances, models.PSPBalance{ + AccountReference: from.Reference, + CreatedAt: time.Now(), + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Attributes.CurrencyCode), + }) + } + + return models.FetchNextBalancesResponse{ + Balances: accountBalances, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/balances_test.go b/internal/connectors/plugins/public/moneycorp/balances_test.go new file mode 100644 index 00000000..68699465 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/balances_test.go @@ -0,0 +1,62 @@ +package moneycorp + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp *Plugin Balances", func() { + var ( + plg *Plugin + ) + + Context("fetch next balances", func() { + var ( + m *client.MockClient + + accRef string + sampleBalance *client.Balance + expectedAmount *big.Int + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + + accRef = "abc" + expectedAmount = big.NewInt(309900) + sampleBalance = &client.Balance{ + Attributes: client.Attributes{ + CurrencyCode: "AED", + AvailableBalance: json.Number("3099"), + }, + } + }) + It("fetches next balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + } + m.EXPECT().GetAccountBalances(gomock.Any(), accRef).Return( + []*client.Balance{sampleBalance}, + nil, + ) + res, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Balances).To(HaveLen(1)) + + Expect(res.Balances[0].AccountReference).To(Equal(accRef)) + Expect(res.Balances[0].Amount).To(BeEquivalentTo(expectedAmount)) + Expect(res.Balances[0].Asset).To(HavePrefix(strings.ToUpper(sampleBalance.Attributes.CurrencyCode))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/capabilities.go b/internal/connectors/plugins/public/moneycorp/capabilities.go new file mode 100644 index 00000000..fb171b5a --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/capabilities.go @@ -0,0 +1,13 @@ +package moneycorp + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, +} diff --git a/internal/connectors/plugins/public/moneycorp/client/accounts.go b/internal/connectors/plugins/public/moneycorp/client/accounts.go new file mode 100644 index 00000000..49f5fb06 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/accounts.go @@ -0,0 +1,48 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type accountsResponse struct { + Accounts []*Account `json:"data"` +} + +type Account struct { + ID string `json:"id"` + Attributes struct { + AccountName string `json:"accountName"` + } `json:"attributes"` +} + +func (c *client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_accounts") + + endpoint := fmt.Sprintf("%s/accounts", c.endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create accounts request: %w", err) + } + + // TODO generic headers can be set in wrapper + req.Header.Set("Content-Type", "application/json") + + q := req.URL.Query() + q.Add("page[size]", strconv.Itoa(pageSize)) + q.Add("page[number]", fmt.Sprint(page)) + q.Add("sortBy", "id.asc") + req.URL.RawQuery = q.Encode() + + accounts := accountsResponse{Accounts: make([]*Account, 0)} + var errRes moneycorpError + _, err = c.httpClient.Do(ctx, req, &accounts, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get accounts: %w %w", err, errRes.Error()) + } + return accounts.Accounts, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/client/auth.go b/internal/connectors/plugins/public/moneycorp/client/auth.go new file mode 100644 index 00000000..6752136b --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/auth.go @@ -0,0 +1,102 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// Cannot use "golang.org/x/oauth2/clientcredentials" lib because moneycorp +// is only accepting request with "application/json" content type, and the lib +// sets it as application/x-www-form-urlencoded, giving us a 415 error. +type apiTransport struct { + clientID string + apiKey string + endpoint string + + accessToken string + accessTokenExpiresAt time.Time + + underlying *otelhttp.Transport +} + +func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.ensureAccessTokenIsValid(req.Context()); err != nil { + return nil, err + } + + req.Header.Add("Authorization", "Bearer "+t.accessToken) + + return t.underlying.RoundTrip(req) +} + +func (t *apiTransport) ensureAccessTokenIsValid(ctx context.Context) error { + if t.accessTokenExpiresAt.After(time.Now().Add(5 * time.Second)) { + return nil + } + + return t.login(ctx) +} + +type loginRequest struct { + ClientID string `json:"loginId"` + APIKey string `json:"apiKey"` +} + +type loginResponse struct { + Data struct { + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` + } `json:"data"` +} + +func (t *apiTransport) login(ctx context.Context) error { + lreq := loginRequest{ + ClientID: t.clientID, + APIKey: t.apiKey, + } + + requestBody, err := json.Marshal(lreq) + if err != nil { + return fmt.Errorf("failed to marshal login request: %w", err) + } + + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("moneycorp"), + } + httpClient := httpwrapper.NewClient(config) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + t.endpoint+"/login", bytes.NewBuffer(requestBody)) + if err != nil { + return fmt.Errorf("failed to create login request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "authenticate") + + var res loginResponse + var errRes moneycorpErrors + statusCode, err := httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return fmt.Errorf("failed to login: %w", err) + } + + if statusCode != http.StatusOK { + if statusCode >= http.StatusInternalServerError { + return toError(statusCode, errRes).Error() + } + return fmt.Errorf("%w: %w", httpwrapper.ErrStatusCodeClientError, toError(statusCode, errRes).Error()) + } + + t.accessToken = res.Data.AccessToken + t.accessTokenExpiresAt = time.Now().Add(time.Duration(res.Data.ExpiresIn) * time.Second) + return nil +} diff --git a/internal/connectors/plugins/public/moneycorp/client/balances.go b/internal/connectors/plugins/public/moneycorp/client/balances.go new file mode 100644 index 00000000..6e6981b2 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/balances.go @@ -0,0 +1,53 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type balancesResponse struct { + Balances []*Balance `json:"data"` +} + +type Balance struct { + ID string `json:"id"` + Attributes Attributes `json:"attributes"` +} + +type Attributes struct { + CurrencyCode string `json:"currencyCode"` + OverallBalance json.Number `json:"overallBalance"` + AvailableBalance json.Number `json:"availableBalance"` + ClearedBalance json.Number `json:"clearedBalance"` + ReservedBalance json.Number `json:"reservedBalance"` + UnclearedBalance json.Number `json:"unclearedBalance"` +} + +func (c *client) GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_account_balances") + + endpoint := fmt.Sprintf("%s/accounts/%s/balances", c.endpoint, accountID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + balances := balancesResponse{Balances: make([]*Balance, 0)} + var errRes moneycorpError + + _, err = c.httpClient.Do(ctx, req, &balances, &errRes) + if err != nil { + if errRes.StatusCode == http.StatusNotFound { + // No balances found + return []*Balance{}, nil + } + return nil, fmt.Errorf("failed to get account balances: %w %w", err, errRes.Error()) + } + + return balances.Balances, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/client/client.go b/internal/connectors/plugins/public/moneycorp/client/client.go new file mode 100644 index 00000000..2e189ec3 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/client.go @@ -0,0 +1,56 @@ +package client + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) + GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) + GetRecipients(ctx context.Context, accountID string, page int, pageSize int) ([]*Recipient, error) + GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) + InitiateTransfer(ctx context.Context, tr *TransferRequest) (*TransferResponse, error) + GetTransfer(ctx context.Context, accountID string, transferID string) (*TransferResponse, error) + InitiatePayout(ctx context.Context, pr *PayoutRequest) (*PayoutResponse, error) +} + +type client struct { + httpClient httpwrapper.Client + endpoint string +} + +func New(clientID, apiKey, endpoint string) *client { + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("moneycorp"), + Transport: &apiTransport{ + clientID: clientID, + apiKey: apiKey, + endpoint: endpoint, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + HttpErrorCheckerFn: func(statusCode int) error { + if statusCode == http.StatusNotFound { + return nil + } else if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError { + return httpwrapper.ErrStatusCodeClientError + } else if statusCode >= http.StatusInternalServerError { + return httpwrapper.ErrStatusCodeServerError + } + return nil + + }, + } + endpoint = strings.TrimSuffix(endpoint, "/") + + return &client{ + httpClient: httpwrapper.NewClient(config), + endpoint: endpoint, + } +} diff --git a/internal/connectors/plugins/public/moneycorp/client/client_generated.go b/internal/connectors/plugins/public/moneycorp/client/client_generated.go new file mode 100644 index 00000000..225b486b --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/client_generated.go @@ -0,0 +1,147 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetAccountBalances mocks base method. +func (m *MockClient) GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountBalances", ctx, accountID) + ret0, _ := ret[0].([]*Balance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountBalances indicates an expected call of GetAccountBalances. +func (mr *MockClientMockRecorder) GetAccountBalances(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountBalances", reflect.TypeOf((*MockClient)(nil).GetAccountBalances), ctx, accountID) +} + +// GetAccounts mocks base method. +func (m *MockClient) GetAccounts(ctx context.Context, page, pageSize int) ([]*Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccounts", ctx, page, pageSize) + ret0, _ := ret[0].([]*Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccounts indicates an expected call of GetAccounts. +func (mr *MockClientMockRecorder) GetAccounts(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccounts", reflect.TypeOf((*MockClient)(nil).GetAccounts), ctx, page, pageSize) +} + +// GetRecipients mocks base method. +func (m *MockClient) GetRecipients(ctx context.Context, accountID string, page, pageSize int) ([]*Recipient, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRecipients", ctx, accountID, page, pageSize) + ret0, _ := ret[0].([]*Recipient) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRecipients indicates an expected call of GetRecipients. +func (mr *MockClientMockRecorder) GetRecipients(ctx, accountID, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecipients", reflect.TypeOf((*MockClient)(nil).GetRecipients), ctx, accountID, page, pageSize) +} + +// GetTransactions mocks base method. +func (m *MockClient) GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransactions", ctx, accountID, page, pageSize, lastCreatedAt) + ret0, _ := ret[0].([]*Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransactions indicates an expected call of GetTransactions. +func (mr *MockClientMockRecorder) GetTransactions(ctx, accountID, page, pageSize, lastCreatedAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactions", reflect.TypeOf((*MockClient)(nil).GetTransactions), ctx, accountID, page, pageSize, lastCreatedAt) +} + +// GetTransfer mocks base method. +func (m *MockClient) GetTransfer(ctx context.Context, accountID, transferID string) (*TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransfer", ctx, accountID, transferID) + ret0, _ := ret[0].(*TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransfer indicates an expected call of GetTransfer. +func (mr *MockClientMockRecorder) GetTransfer(ctx, accountID, transferID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransfer", reflect.TypeOf((*MockClient)(nil).GetTransfer), ctx, accountID, transferID) +} + +// InitiatePayout mocks base method. +func (m *MockClient) InitiatePayout(ctx context.Context, pr *PayoutRequest) (*PayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiatePayout", ctx, pr) + ret0, _ := ret[0].(*PayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiatePayout indicates an expected call of InitiatePayout. +func (mr *MockClientMockRecorder) InitiatePayout(ctx, pr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiatePayout", reflect.TypeOf((*MockClient)(nil).InitiatePayout), ctx, pr) +} + +// InitiateTransfer mocks base method. +func (m *MockClient) InitiateTransfer(ctx context.Context, tr *TransferRequest) (*TransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitiateTransfer", ctx, tr) + ret0, _ := ret[0].(*TransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitiateTransfer indicates an expected call of InitiateTransfer. +func (mr *MockClientMockRecorder) InitiateTransfer(ctx, tr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateTransfer", reflect.TypeOf((*MockClient)(nil).InitiateTransfer), ctx, tr) +} diff --git a/internal/connectors/plugins/public/moneycorp/client/error.go b/internal/connectors/plugins/public/moneycorp/client/error.go new file mode 100644 index 00000000..737e9f6b --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/error.go @@ -0,0 +1,42 @@ +package client + +import ( + "fmt" +) + +type moneycorpErrors struct { + Errors []*moneycorpError `json:"errors"` +} + +type moneycorpError struct { + StatusCode int `json:"-"` + Code string `json:"code"` + Title string `json:"title"` + Detail string `json:"detail"` +} + +func (me *moneycorpError) Error() error { + var err error + if me.Detail == "" { + err = fmt.Errorf("unexpected status code: %d", me.StatusCode) + } else { + err = fmt.Errorf("%d: %s", me.StatusCode, me.Detail) + } + + return err +} + +func toError(statusCode int, ces moneycorpErrors) *moneycorpError { + if len(ces.Errors) == 0 { + return &moneycorpError{ + StatusCode: statusCode, + } + } + + return &moneycorpError{ + StatusCode: statusCode, + Code: ces.Errors[0].Code, + Title: ces.Errors[0].Title, + Detail: ces.Errors[0].Detail, + } +} diff --git a/internal/connectors/plugins/public/moneycorp/client/payouts.go b/internal/connectors/plugins/public/moneycorp/client/payouts.go new file mode 100644 index 00000000..a72e2469 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/payouts.go @@ -0,0 +1,90 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type payoutRequest struct { + Payout struct { + Attributes *PayoutRequest `json:"attributes"` + } `json:"data"` +} + +type PayoutRequest struct { + SourceAccountID string `json:"-"` + IdempotencyKey string `json:"-"` + RecipientID string `json:"recipientId"` + PaymentDate string `json:"paymentDate"` + PaymentAmount json.Number `json:"paymentAmount"` + PaymentCurrency string `json:"paymentCurrency"` + PaymentMethod string `json:"paymentMethod"` + PaymentReference string `json:"paymentReference"` + ClientReference string `json:"clientReference"` + PaymentPurpose string `json:"paymentPurpose"` +} + +type payoutResponse struct { + Payout *PayoutResponse `json:"data"` +} + +type RecipientDetails struct { + RecipientID int64 `json:"recipientId"` +} + +type PayoutAttributes struct { + AccountID int64 `json:"accountId"` + PaymentAmount json.Number `json:"paymentAmount"` + PaymentCurrency string `json:"paymentCurrency"` + PaymentApproved bool `json:"paymentApproved"` + PaymentStatus string `json:"paymentStatus"` + PaymentMethod string `json:"paymentMethod"` + PaymentDate string `json:"paymentDate"` + PaymentValueDate string `json:"paymentValueDate"` + RecipientDetails RecipientDetails `json:"recipientDetails"` + PaymentReference string `json:"paymentReference"` + ClientReference string `json:"clientReference"` + CreatedAt string `json:"createdAt"` + CreatedBy string `json:"createdBy"` + UpdatedAt string `json:"updatedAt"` + PaymentPurpose string `json:"paymentPurpose"` +} + +type PayoutResponse struct { + ID string `json:"id"` + Attributes PayoutAttributes `json:"attributes"` +} + +func (c *client) InitiatePayout(ctx context.Context, pr *PayoutRequest) (*PayoutResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_payout") + + endpoint := fmt.Sprintf("%s/accounts/%s/payments", c.endpoint, pr.SourceAccountID) + + reqBody := &payoutRequest{} + reqBody.Payout.Attributes = pr + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal payout request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", pr.IdempotencyKey) + + var res payoutResponse + var errRes moneycorpError + _, err = c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to initiate transfer: %w %w", err, errRes.Error()) + } + + return res.Payout, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/client/recipients.go b/internal/connectors/plugins/public/moneycorp/client/recipients.go new file mode 100644 index 00000000..60076094 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/recipients.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type recipientsResponse struct { + Recipients []*Recipient `json:"data"` +} + +type Recipient struct { + ID string `json:"id"` + Attributes RecipientAttributes `json:"attributes"` +} + +type RecipientAttributes struct { + BankAccountCurrency string `json:"bankAccountCurrency"` + CreatedAt string `json:"createdAt"` + BankAccountName string `json:"bankAccountName"` +} + +func (c *client) GetRecipients(ctx context.Context, accountID string, page int, pageSize int) ([]*Recipient, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_recipients") + + endpoint := fmt.Sprintf("%s/accounts/%s/recipients", c.endpoint, accountID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create recipients request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + q := req.URL.Query() + q.Add("page[size]", strconv.Itoa(pageSize)) + q.Add("page[number]", fmt.Sprint(page)) + q.Add("sortBy", "createdAt.asc") + req.URL.RawQuery = q.Encode() + + recipients := recipientsResponse{Recipients: make([]*Recipient, 0)} + var errRes moneycorpError + _, err = c.httpClient.Do(ctx, req, &recipients, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get recipients: %w %w", err, errRes.Error()) + } + return recipients.Recipients, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/client/transactions.go b/internal/connectors/plugins/public/moneycorp/client/transactions.go new file mode 100644 index 00000000..43651e60 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/transactions.go @@ -0,0 +1,106 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type transactionsResponse struct { + Transactions []*Transaction `json:"data"` +} + +type fetchTransactionRequest struct { + Data struct { + Attributes struct { + TransactionDateTimeFrom string `json:"transactionDateTimeFrom"` + } `json:"attributes"` + } `json:"data"` +} + +type Transaction struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes TransactionAttributes `json:"attributes"` + Relationships RelationShips `json:"relationships"` +} + +type Data struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type RelationShips struct { + Data Data `json:"data"` +} + +type TransactionAttributes struct { + AccountID int32 `json:"accountId"` + CreatedAt string `json:"createdAt"` + Currency string `json:"transactionCurrency"` + Amount json.Number `json:"transactionAmount"` + Direction string `json:"transactionDirection"` + Type string `json:"transactionType"` + ClientReference string `json:"clientReference"` + TransactionReference string `json:"transactionReference"` +} + +func (c *client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_transactions") + + var body io.Reader + if !lastCreatedAt.IsZero() { + reqBody := fetchTransactionRequest{ + Data: struct { + Attributes struct { + TransactionDateTimeFrom string "json:\"transactionDateTimeFrom\"" + } "json:\"attributes\"" + }{ + Attributes: struct { + TransactionDateTimeFrom string "json:\"transactionDateTimeFrom\"" + }{ + TransactionDateTimeFrom: lastCreatedAt.Format("2006-01-02T15:04:05.999999999"), + }, + }, + } + + raw, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal transfer request: %w", err) + } + + body = bytes.NewBuffer(raw) + } else { + body = http.NoBody + } + + endpoint := fmt.Sprintf("%s/accounts/%s/transactions/find", c.endpoint, accountID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) + if err != nil { + return nil, fmt.Errorf("failed to create transactions request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + q := req.URL.Query() + q.Add("page[size]", strconv.Itoa(pageSize)) + q.Add("page[number]", fmt.Sprint(page)) + q.Add("sortBy", "createdAt.asc") + req.URL.RawQuery = q.Encode() + + transactions := transactionsResponse{Transactions: make([]*Transaction, 0)} + var errRes moneycorpError + _, err = c.httpClient.Do(ctx, req, &transactions, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get transactions: %w %w", err, errRes.Error()) + } + + return transactions.Transactions, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/client/transfers.go b/internal/connectors/plugins/public/moneycorp/client/transfers.go new file mode 100644 index 00000000..daca499f --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/transfers.go @@ -0,0 +1,101 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type transferRequest struct { + Transfer struct { + Attributes *TransferRequest `json:"attributes"` + } `json:"data"` +} + +type TransferRequest struct { + SourceAccountID string `json:"-"` + IdempotencyKey string `json:"-"` + ReceivingAccountID string `json:"receivingAccountId"` + TransferAmount json.Number `json:"transferAmount"` + TransferCurrency string `json:"transferCurrency"` + TransferReference string `json:"transferReference,omitempty"` + ClientReference string `json:"clientReference,omitempty"` +} + +type transferResponse struct { + Transfer *TransferResponse `json:"data"` +} + +type TransferAttributes struct { + SendingAccountID int64 `json:"sendingAccountId"` + SendingAccountName string `json:"sendingAccountName"` + ReceivingAccountID int64 `json:"receivingAccountId"` + ReceivingAccountName string `json:"receivingAccountName"` + CreatedAt string `json:"createdAt"` + CreatedBy string `json:"createdBy"` + UpdatedAt string `json:"updatedAt"` + TransferReference string `json:"transferReference"` + ClientReference string `json:"clientReference"` + TransferDate string `json:"transferDate"` + TransferAmount json.Number `json:"transferAmount"` + TransferCurrency string `json:"transferCurrency"` + TransferStatus string `json:"transferStatus"` +} + +type TransferResponse struct { + ID string `json:"id"` + Attributes TransferAttributes `json:"attributes"` +} + +func (c *client) InitiateTransfer(ctx context.Context, tr *TransferRequest) (*TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_transfer") + + endpoint := fmt.Sprintf("%s/accounts/%s/transfers", c.endpoint, tr.SourceAccountID) + + reqBody := &transferRequest{} + reqBody.Transfer.Attributes = tr + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal transfer request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create transfer request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", tr.IdempotencyKey) + + var transferResponse transferResponse + var errRes moneycorpError + _, err = c.httpClient.Do(ctx, req, &transferResponse, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to initiate transfer: %w %w", err, errRes.Error()) + } + + return transferResponse.Transfer, nil +} + +func (c *client) GetTransfer(ctx context.Context, accountID string, transferID string) (*TransferResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_transfer") + + endpoint := fmt.Sprintf("%s/accounts/%s/transfers/%s", c.endpoint, accountID, transferID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create get transfer request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + var transferResponse transferResponse + var errRes moneycorpError + _, err = c.httpClient.Do(ctx, req, &transferResponse, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get transfer: %w %w", err, errRes.Error()) + } + + return transferResponse.Transfer, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/config.go b/internal/connectors/plugins/public/moneycorp/config.go new file mode 100644 index 00000000..b5a439a1 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/config.go @@ -0,0 +1,39 @@ +package moneycorp + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + ClientID string `json:"clientID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +func (c Config) validate() error { + if c.ClientID == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing clientID in config") + } + + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api key in config") + } + + if c.Endpoint == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing endpoint in config") + } + + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/moneycorp/config.json b/internal/connectors/plugins/public/moneycorp/config.json new file mode 100644 index 00000000..59bacea9 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/config.json @@ -0,0 +1,17 @@ +{ + "clientID": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "endpoint": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/moneycorp/currencies.go b/internal/connectors/plugins/public/moneycorp/currencies.go new file mode 100644 index 00000000..da428d54 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/currencies.go @@ -0,0 +1,63 @@ +package moneycorp + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + supportedCurrenciesWithDecimal = map[string]int{ + "AED": currency.ISO4217Currencies["AED"], // UAE Dirham + "AUD": currency.ISO4217Currencies["AUD"], // Australian Dollar + "BBD": currency.ISO4217Currencies["BBD"], // Barbados Dollar + "BGN": currency.ISO4217Currencies["BGN"], // Bulgarian lev + "BHD": currency.ISO4217Currencies["BHD"], // Bahraini dinar + "BWP": currency.ISO4217Currencies["BWP"], // Botswana pula + "CAD": currency.ISO4217Currencies["CAD"], // Canadian dollar + "CHF": currency.ISO4217Currencies["CHF"], // Swiss franc + "CZK": currency.ISO4217Currencies["CZK"], // Czech koruna + "DKK": currency.ISO4217Currencies["DKK"], // Danish krone + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "GBP": currency.ISO4217Currencies["GBP"], // Pound sterling + "GHS": currency.ISO4217Currencies["GHS"], // Ghanaian cedi + "HKD": currency.ISO4217Currencies["HKD"], // Hong Kong dollar + "ILS": currency.ISO4217Currencies["ILS"], // Israeli new shekel + "INR": currency.ISO4217Currencies["INR"], // Indian rupee + "JMD": currency.ISO4217Currencies["JMD"], // Jamaican dollar + "JPY": currency.ISO4217Currencies["JPY"], // Japanese yen + "KES": currency.ISO4217Currencies["KES"], // Kenyan shilling + "LKR": currency.ISO4217Currencies["LKR"], // Sri Lankan rupee + "MAD": currency.ISO4217Currencies["MAD"], // Moroccan dirham + "MUR": currency.ISO4217Currencies["MUR"], // Mauritian rupee + "MXN": currency.ISO4217Currencies["MXN"], // Mexican peso + "NOK": currency.ISO4217Currencies["NOK"], // Norwegian krone + "NPR": currency.ISO4217Currencies["NPR"], // Nepalese rupee + "NZD": currency.ISO4217Currencies["NZD"], // New Zealand dollar + "OMR": currency.ISO4217Currencies["OMR"], // Omani rial + "PHP": currency.ISO4217Currencies["PHP"], // Philippine peso + "PKR": currency.ISO4217Currencies["PKR"], // Pakistani rupee + "PLN": currency.ISO4217Currencies["PLN"], // Polish złoty + "QAR": currency.ISO4217Currencies["QAR"], // Qatari riyal + "RON": currency.ISO4217Currencies["RON"], // Romanian leu + "RSD": currency.ISO4217Currencies["RSD"], // Serbian dinar + "SAR": currency.ISO4217Currencies["SAR"], // Saudi riyal + "SEK": currency.ISO4217Currencies["SEK"], // Swedish krona/kronor + "SGD": currency.ISO4217Currencies["SGD"], // Singapore dollar + "THB": currency.ISO4217Currencies["THB"], // Thai baht + "TRY": currency.ISO4217Currencies["TRY"], // Turkish lira + "TTD": currency.ISO4217Currencies["TTD"], // Trinidad and Tobago dollar + "UGX": currency.ISO4217Currencies["UGX"], // Ugandan shilling + "USD": currency.ISO4217Currencies["USD"], // United States dollar + "XCD": currency.ISO4217Currencies["XCD"], // East Caribbean dollar + "ZAR": currency.ISO4217Currencies["ZAR"], // South African rand + "ZMW": currency.ISO4217Currencies["ZMW"], // Zambian kwacha + + // All following currencies have not the same decimals given by + // Moneycorp compared to the ISO 4217 standard. + // Let's not handle them for now. + // "CNH": 2, // Chinese Yuan + // "IDR": 2, // Indonesian rupiah + // "ISK": 0, // Icelandic króna + // "HUF": 2, // Hungarian forint + // "JOD": 3, // Jordanian dinar + // "KWD": 3, // Kuwaiti dinar + // "TND": 3, // Tunisian dinar + } +) diff --git a/internal/connectors/plugins/public/moneycorp/external_accounts.go b/internal/connectors/plugins/public/moneycorp/external_accounts.go new file mode 100644 index 00000000..66b6d5bc --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/external_accounts.go @@ -0,0 +1,116 @@ +package moneycorp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type externalAccountsState struct { + LastPage int `json:"lastPage"` + LastCreatedAt time.Time `json:"LastCreatedAt"` +} + +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextExternalAccountsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + newState := externalAccountsState{ + LastPage: oldState.LastPage, + LastCreatedAt: oldState.LastCreatedAt, + } + + needMore := false + hasMore := false + accounts := make([]models.PSPAccount, 0, req.PageSize) + for page := oldState.LastPage; ; page++ { + newState.LastPage = page + + pagedRecipients, err := p.client.GetRecipients(ctx, from.Reference, page, req.PageSize) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + accounts, err = recipientToPSPAccounts(oldState.LastCreatedAt, accounts, pagedRecipients) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedRecipients, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + newState.LastCreatedAt = accounts[len(accounts)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func recipientToPSPAccounts( + lastCreatedAt time.Time, + accounts []models.PSPAccount, + pagedAccounts []*client.Recipient, +) ([]models.PSPAccount, error) { + for _, recipient := range pagedAccounts { + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", recipient.Attributes.CreatedAt) + if err != nil { + return accounts, fmt.Errorf("failed to parse transaction date: %v", err) + } + + switch createdAt.Compare(lastCreatedAt) { + case -1, 0: + continue + default: + } + + raw, err := json.Marshal(recipient) + if err != nil { + return accounts, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: recipient.ID, + // Moneycorp does not send the opening date of the account + CreatedAt: createdAt, + Name: &recipient.Attributes.BankAccountName, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, recipient.Attributes.BankAccountCurrency)), + Raw: raw, + }) + } + return accounts, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/external_accounts_test.go b/internal/connectors/plugins/public/moneycorp/external_accounts_test.go new file mode 100644 index 00000000..1409303f --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/external_accounts_test.go @@ -0,0 +1,193 @@ +package moneycorp + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp *Plugin ExternalAccounts", func() { + var ( + plg *Plugin + ) + + Context("fetch next ExternalAccounts", func() { + var ( + m *client.MockClient + + sampleExternalAccounts []*client.Recipient + accRef string + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + accRef = "baseAcc" + now = time.Now().UTC() + + sampleExternalAccounts = make([]*client.Recipient, 0) + for i := 0; i < 50; i++ { + sampleExternalAccounts = append(sampleExternalAccounts, &client.Recipient{ + Attributes: client.RecipientAttributes{ + BankAccountCurrency: "JPY", + CreatedAt: strings.TrimSuffix(now.Add(-time.Duration(60-i)*time.Minute).UTC().Format(time.RFC3339Nano), "Z"), + BankAccountName: "jpy account", + }, + }) + } + + }) + + It("should return an error - missing from payload", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should return an error - get beneficiaries error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: []byte(`{"reference": "baseAcc"}`), + } + + m.EXPECT().GetRecipients(gomock.Any(), accRef, 0, 60).Return( + []*client.Recipient{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: []byte(`{"reference": "baseAcc"}`), + } + + m.EXPECT().GetRecipients(gomock.Any(), accRef, 0, 60).Return( + []*client.Recipient{}, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastCreatedAt.IsZero()).To(BeTrue()) + }) + + It("should fetch next external accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: []byte(`{"reference": "baseAcc"}`), + } + + m.EXPECT().GetRecipients(gomock.Any(), accRef, 0, 60).Return( + sampleExternalAccounts, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(0)) + createdAtTime, _ := time.Parse(time.RFC3339Nano, sampleExternalAccounts[49].Attributes.CreatedAt+"Z") + Expect(state.LastCreatedAt.UTC()).To(Equal(createdAtTime.UTC())) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(`{}`), + PageSize: 40, + FromPayload: []byte(`{"reference": "baseAcc"}`), + } + + m.EXPECT().GetRecipients(gomock.Any(), accRef, 0, 40).Return( + sampleExternalAccounts[:40], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(0)) + createdAtTime, _ := time.Parse(time.RFC3339Nano, sampleExternalAccounts[39].Attributes.CreatedAt+"Z") + Expect(state.LastCreatedAt.UTC()).To(Equal(createdAtTime.UTC())) + }) + + It("should fetch next external accounts - with state pageSize < total accounts", func(ctx SpecContext) { + lastCreaatedAt, _ := time.Parse(time.RFC3339Nano, sampleExternalAccounts[38].Attributes.CreatedAt+"Z") + req := models.FetchNextExternalAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastPage": %d, "lastCreatedAt": "%s"}`, 0, lastCreaatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + FromPayload: []byte(`{"reference": "baseAcc"}`), + } + + m.EXPECT().GetRecipients(gomock.Any(), accRef, 0, 40).Return( + sampleExternalAccounts[:40], + nil, + ) + + m.EXPECT().GetRecipients(gomock.Any(), accRef, 1, 40).Return( + sampleExternalAccounts[41:], + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastPage).To(Equal(1)) + createdAtTime, _ := time.Parse(time.RFC3339Nano, sampleExternalAccounts[49].Attributes.CreatedAt+"Z") + Expect(state.LastCreatedAt.UTC()).To(Equal(createdAtTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/payments.go b/internal/connectors/plugins/public/moneycorp/payments.go new file mode 100644 index 00000000..0aca5b2d --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/payments.go @@ -0,0 +1,206 @@ +package moneycorp + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type paymentsState struct { + LastCreatedAt time.Time `json:"lastCreatedAt"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextPaymentsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + newState := paymentsState{ + LastCreatedAt: oldState.LastCreatedAt, + } + + payments := make([]models.PSPPayment, 0, req.PageSize) + needMore := false + hasMore := false + for page := 0; ; page++ { + pagedTransactions, err := p.client.GetTransactions(ctx, from.Reference, page, req.PageSize, oldState.LastCreatedAt) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, err = p.toPSPPayments(ctx, oldState.LastCreatedAt, payments, pagedTransactions) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedTransactions, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + payments = payments[:req.PageSize] + } + + if len(payments) > 0 { + newState.LastCreatedAt = payments[len(payments)-1].CreatedAt + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func (p *Plugin) toPSPPayments( + ctx context.Context, + lastCreatedAt time.Time, + payments []models.PSPPayment, + transactions []*client.Transaction, +) ([]models.PSPPayment, error) { + for _, transaction := range transactions { + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transaction.Attributes.CreatedAt) + if err != nil { + return payments, fmt.Errorf("failed to parse transaction date: %v", err) + } + + switch createdAt.Compare(lastCreatedAt) { + case -1, 0: + continue + default: + } + + payment, err := p.transactionToPayment(ctx, transaction) + if err != nil { + return payments, err + } + if payment == nil { + continue + } + + payments = append(payments, *payment) + } + return payments, nil +} + +func (p *Plugin) transactionToPayment(ctx context.Context, transaction *client.Transaction) (*models.PSPPayment, error) { + switch transaction.Attributes.Type { + case "Transfer": + if transaction.Attributes.Direction == "Debit" { + return p.fetchAndTranslateTransfer(ctx, transaction) + } else { + // Do not fetch the transfer, it does not exists if we're trying to + // fetch it with the destination account. + return nil, nil + } + } + + rawData, err := json.Marshal(transaction) + if err != nil { + return nil, fmt.Errorf("failed to marshal transaction: %w", err) + } + + paymentType, shouldBeRecorded := matchPaymentType(transaction.Attributes.Type, transaction.Attributes.Direction) + if !shouldBeRecorded { + return nil, nil + } + + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transaction.Attributes.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse transaction date: %w", err) + } + + c, err := currency.GetPrecision(supportedCurrenciesWithDecimal, transaction.Attributes.Currency) + if err != nil { + return nil, err + } + + amount, err := currency.GetAmountWithPrecisionFromString(transaction.Attributes.Amount.String(), c) + if err != nil { + return nil, err + } + + reference := transaction.ID + if transaction.Attributes.Type == "Payment" { + // In case of payments (related to payouts), we want to take the real + // object id as a reference + reference = transaction.Relationships.Data.ID + } + + payment := models.PSPPayment{ + Reference: reference, + CreatedAt: createdAt, + Type: paymentType, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transaction.Attributes.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Raw: rawData, + } + + switch paymentType { + case models.PAYMENT_TYPE_PAYIN: + payment.DestinationAccountReference = pointer.For(strconv.Itoa(int(transaction.Attributes.AccountID))) + case models.PAYMENT_TYPE_PAYOUT: + payment.SourceAccountReference = pointer.For(strconv.Itoa(int(transaction.Attributes.AccountID))) + default: + if transaction.Attributes.Direction == "Debit" { + payment.SourceAccountReference = pointer.For(strconv.Itoa(int(transaction.Attributes.AccountID))) + } else { + payment.DestinationAccountReference = pointer.For(strconv.Itoa(int(transaction.Attributes.AccountID))) + } + } + + return &payment, nil +} + +func (p *Plugin) fetchAndTranslateTransfer(ctx context.Context, transaction *client.Transaction) (*models.PSPPayment, error) { + transfer, err := p.client.GetTransfer(ctx, fmt.Sprint(transaction.Attributes.AccountID), transaction.Relationships.Data.ID) + if err != nil { + return nil, err + } + + return transferToPayment(transfer) +} + +func matchPaymentType(transactionType string, transactionDirection string) (models.PaymentType, bool) { + switch transactionType { + case "Transfer": + return models.PAYMENT_TYPE_TRANSFER, true + case "Payment", "Exchange", "Charge", "Refund": + switch transactionDirection { + case "Debit": + return models.PAYMENT_TYPE_PAYOUT, true + case "Credit": + return models.PAYMENT_TYPE_PAYIN, true + } + } + + return models.PAYMENT_TYPE_OTHER, false +} diff --git a/internal/connectors/plugins/public/moneycorp/payments_test.go b/internal/connectors/plugins/public/moneycorp/payments_test.go new file mode 100644 index 00000000..a2f65c4a --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/payments_test.go @@ -0,0 +1,400 @@ +package moneycorp + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp *Plugin Payments - check types and minor conversion", func() { + var ( + plg *Plugin + ) + + Context("fetch next Payments", func() { + var ( + m *client.MockClient + + samplePayments []*client.Transaction + sampleTransfer *client.TransferResponse + accRef int32 + pageSize int + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + + pageSize = 5 + accRef = 3796 + sampleTransfer = &client.TransferResponse{ + ID: "transfer-1", + Attributes: client.TransferAttributes{ + SendingAccountID: 1234, + ReceivingAccountID: 4321, + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + UpdatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + TransferReference: "test1", + ClientReference: "test1", + TransferAmount: json.Number("65"), + TransferCurrency: "EUR", + TransferStatus: "Cleared", + }, + } + samplePayments = []*client.Transaction{ + { + ID: "transfer-1", + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Transfer", + Direction: "Debit", + Currency: "EUR", + Amount: json.Number("65"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + Relationships: client.RelationShips{ + Data: client.Data{ + ID: sampleTransfer.ID, + }, + }, + }, + { + ID: "payment-1", + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Payment", + Direction: "Debit", + Currency: "DKK", + Amount: json.Number("42"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + Relationships: client.RelationShips{ + Data: client.Data{ + ID: "Payout-1", + }, + }, + }, + { + ID: "exchange-1", + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Exchange", + Direction: "Debit", + Currency: "GBP", + Amount: json.Number("28"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "charge-1", + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Charge", + Direction: "Credit", + Currency: "JPY", + Amount: json.Number("6400"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "refund-1", + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Refund", + Direction: "Credit", + Currency: "MAD", + Amount: json.Number("64"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "unsupported-1", + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Unsupported", + Currency: "USD", + Amount: json.Number("29"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + } + + }) + + It("fails when payments contain unsupported currencies", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + p := []*client.Transaction{ + { + ID: "someid", + Attributes: client.TransactionAttributes{ + Direction: "Debit", + AccountID: accRef, + Type: "Payment", + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + Currency: "EEK", + }, + }, + } + m.EXPECT().GetTransactions(gomock.Any(), "3796", gomock.Any(), pageSize, gomock.Any()).Return( + p, + nil, + ) + + res, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(currency.ErrMissingCurrencies)) + Expect(res.HasMore).To(BeFalse()) + }) + + It("fetches payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + + m.EXPECT().GetTransactions(gomock.Any(), "3796", gomock.Any(), pageSize, gomock.Any()).Return( + samplePayments, + nil, + ) + + m.EXPECT().GetTransfer(gomock.Any(), "3796", sampleTransfer.ID).Return( + sampleTransfer, + nil, + ) + + res, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payments).To(HaveLen(len(samplePayments) - 1)) + Expect(res.HasMore).To(BeTrue()) + + // Transfer + Expect(res.Payments[0].Reference).To(Equal(samplePayments[0].ID)) + Expect(res.Payments[0].Type).To(Equal(models.PAYMENT_TYPE_TRANSFER)) + expectedAmount, err := samplePayments[0].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[0].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + // Payment + Expect(res.Payments[1].Reference).To(Equal(samplePayments[1].Relationships.Data.ID)) + Expect(res.Payments[1].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT)) + expectedAmount, err = samplePayments[1].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[1].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + // Exchange + Expect(res.Payments[2].Reference).To(Equal(samplePayments[2].ID)) + Expect(res.Payments[2].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT)) + expectedAmount, err = samplePayments[2].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[2].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + // Charge + Expect(res.Payments[3].Reference).To(Equal(samplePayments[3].ID)) + Expect(res.Payments[3].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + expectedAmount, err = samplePayments[3].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[3].Amount).To(Equal(big.NewInt(expectedAmount))) // currency already in minors + // Refund + Expect(res.Payments[4].Reference).To(Equal(samplePayments[4].ID)) + Expect(res.Payments[4].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + expectedAmount, err = samplePayments[4].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[4].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + + }) + }) +}) + +var _ = Describe("Moneycorp *Plugin Payments - check pagination", func() { + var ( + plg *Plugin + ) + + Context("fetch next Payments", func() { + var ( + m *client.MockClient + + samplePayments []*client.Transaction + accRef int32 + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + accRef = 3796 + now = time.Now().UTC() + + samplePayments = make([]*client.Transaction, 0) + for i := 0; i < 50; i++ { + samplePayments = append(samplePayments, &client.Transaction{ + ID: fmt.Sprintf("transaction-%d", i), + Attributes: client.TransactionAttributes{ + AccountID: accRef, + Type: "Payment", + Direction: "Debit", + Currency: "EUR", + Amount: json.Number("42"), + CreatedAt: strings.TrimSuffix(now.Add(-time.Duration(60-i)*time.Minute).UTC().Format(time.RFC3339Nano), "Z"), + }, + Relationships: client.RelationShips{ + Data: client.Data{ + ID: fmt.Sprintf("%d", i), + }, + }, + }) + } + }) + + It("should return an error - missing from payload", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + } + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should return an error - get transactions error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + } + + m.EXPECT().GetTransactions(gomock.Any(), fmt.Sprintf("%d", accRef), 0, 60, time.Time{}).Return( + []*client.Transaction{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + } + + m.EXPECT().GetTransactions(gomock.Any(), fmt.Sprintf("%d", accRef), 0, 60, time.Time{}).Return( + []*client.Transaction{}, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastCreatedAt.IsZero()).To(BeTrue()) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 60, + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + } + + m.EXPECT().GetTransactions(gomock.Any(), fmt.Sprintf("%d", accRef), 0, 60, time.Time{}).Return( + samplePayments, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdAtTime, _ := time.Parse(time.RFC3339Nano, samplePayments[49].Attributes.CreatedAt+"Z") + Expect(state.LastCreatedAt.UTC()).To(Equal(createdAtTime.UTC())) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{}`), + PageSize: 40, + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + } + + m.EXPECT().GetTransactions(gomock.Any(), fmt.Sprintf("%d", accRef), 0, 40, time.Time{}).Return( + samplePayments[:40], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + createdAtTime, _ := time.Parse(time.RFC3339Nano, samplePayments[39].Attributes.CreatedAt+"Z") + Expect(state.LastCreatedAt.UTC()).To(Equal(createdAtTime.UTC())) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + lastCreatedAt, _ := time.Parse(time.RFC3339Nano, samplePayments[38].Attributes.CreatedAt+"Z") + req := models.FetchNextPaymentsRequest{ + State: []byte(fmt.Sprintf(`{"lastCreatedAt": "%s"}`, lastCreatedAt.Format(time.RFC3339Nano))), + PageSize: 40, + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%d"}`, accRef)), + } + + m.EXPECT().GetTransactions(gomock.Any(), fmt.Sprintf("%d", accRef), 0, 40, lastCreatedAt.UTC()).Return( + samplePayments[:40], + nil, + ) + + m.EXPECT().GetTransactions(gomock.Any(), fmt.Sprintf("%d", accRef), 1, 40, lastCreatedAt.UTC()).Return( + samplePayments[41:], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(10)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + createdAtTime, _ := time.Parse(time.RFC3339Nano, samplePayments[49].Attributes.CreatedAt+"Z") + Expect(state.LastCreatedAt.UTC()).To(Equal(createdAtTime.UTC())) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/payouts.go b/internal/connectors/plugins/public/moneycorp/payouts.go new file mode 100644 index 00000000..5c0a518c --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/payouts.go @@ -0,0 +1,104 @@ +package moneycorp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (*models.PSPPayment, error) { + if err := p.validateTransferPayoutRequests(pi); err != nil { + return nil, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return nil, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return nil, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + resp, err := p.client.InitiatePayout( + ctx, + &client.PayoutRequest{ + IdempotencyKey: pi.Reference, + SourceAccountID: pi.SourceAccount.Reference, + RecipientID: pi.DestinationAccount.Reference, + PaymentAmount: json.Number(amount), + PaymentCurrency: curr, + PaymentMethod: "Standard", + PaymentReference: pi.Description, + ClientReference: pi.Description, + }, + ) + if err != nil { + return nil, err + } + + return payoutToPayment(resp) +} + +func payoutToPayment(from *client.PayoutResponse) (*models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return nil, fmt.Errorf("failed to marshal transaction: %w", err) + } + + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", from.Attributes.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse transaction date: %w", err) + } + + c, err := currency.GetPrecision(supportedCurrenciesWithDecimal, from.Attributes.PaymentCurrency) + if err != nil { + return nil, err + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.Attributes.PaymentAmount.String(), c) + if err != nil { + return nil, err + } + + return &models.PSPPayment{ + Reference: from.ID, + CreatedAt: createdAt, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Attributes.PaymentCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchPaymentStatus(from.Attributes.PaymentStatus), + SourceAccountReference: pointer.For(fmt.Sprintf("%d", from.Attributes.AccountID)), + DestinationAccountReference: func() *string { + if from.Attributes.RecipientDetails.RecipientID == 0 { + return nil + } + return pointer.For(fmt.Sprintf("%d", from.Attributes.RecipientDetails.RecipientID)) + }(), + Raw: raw, + }, nil +} + +func matchPaymentStatus(status string) models.PaymentStatus { + // Unauthorised, Awaiting Dispatch, Sent, Cleared, Failed, Cancelled or Query + switch status { + case "Unauthorised", "Failed", "Query": + return models.PAYMENT_STATUS_FAILED + case "Awaiting Dispatch", "Sent": + return models.PAYMENT_STATUS_PENDING + case "Cleared": + return models.PAYMENT_STATUS_SUCCEEDED + case "Cancelled": + return models.PAYMENT_STATUS_FAILED + } + + return models.PAYMENT_STATUS_UNKNOWN +} diff --git a/internal/connectors/plugins/public/moneycorp/payouts_test.go b/internal/connectors/plugins/public/moneycorp/payouts_test.go new file mode 100644 index 00000000..f97946c2 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/payouts_test.go @@ -0,0 +1,178 @@ +package moneycorp + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp *Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "123", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "321", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - initiate payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + RecipientID: samplePSPPaymentInitiation.DestinationAccount.Reference, + PaymentAmount: "1.00", + PaymentCurrency: "EUR", + PaymentMethod: "Standard", + PaymentReference: samplePSPPaymentInitiation.Description, + ClientReference: samplePSPPaymentInitiation.Description, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.PayoutResponse{ + ID: "1", + Attributes: client.PayoutAttributes{ + AccountID: 123, + PaymentAmount: "1.00", + PaymentCurrency: "EUR", + PaymentStatus: "Cleared", + PaymentMethod: "Standard", + RecipientDetails: client.RecipientDetails{ + RecipientID: 321, + }, + PaymentReference: samplePSPPaymentInitiation.Description, + ClientReference: samplePSPPaymentInitiation.Description, + CreatedAt: now.Format("2006-01-02T15:04:05.999999999"), + }, + } + m.EXPECT().InitiatePayout(gomock.Any(), &client.PayoutRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + RecipientID: samplePSPPaymentInitiation.DestinationAccount.Reference, + PaymentAmount: "1.00", + PaymentCurrency: "EUR", + PaymentMethod: "Standard", + PaymentReference: samplePSPPaymentInitiation.Description, + ClientReference: samplePSPPaymentInitiation.Description, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("123"), + DestinationAccountReference: pointer.For("321"), + Raw: raw, + }, + })) + }) + + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/plugin.go b/internal/connectors/plugins/public/moneycorp/plugin.go new file mode 100644 index 00000000..5a464ac2 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/plugin.go @@ -0,0 +1,143 @@ +package moneycorp + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("moneycorp", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New(config.ClientID, config.APIKey, config.Endpoint) + + return &Plugin{ + name: name, + client: client, + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + + return models.CreateTransferResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + + return models.CreatePayoutResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/moneycorp/plugin_test.go b/internal/connectors/plugins/public/moneycorp/plugin_test.go new file mode 100644 index 00000000..afdac5eb --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/plugin_test.go @@ -0,0 +1,87 @@ +package moneycorp_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Moneycorp *Plugin Suite") +} + +var _ = Describe("Moneycorp *Plugin", func() { + var ( + plg *moneycorp.Plugin + config json.RawMessage + ) + + BeforeEach(func() { + plg = &moneycorp.Plugin{} + config = json.RawMessage(`{"clientID":"1234","apiKey":"abc123","endpoint":"example.com"}`) + }) + + Context("install", func() { + It("reports validation errors in the config", func(ctx SpecContext) { + config := json.RawMessage(`{}`) + _, err := moneycorp.New("moneycorp", config) + Expect(err).To(MatchError(ContainSubstring("config"))) + }) + It("returns valid install response", func(ctx SpecContext) { + _, err := moneycorp.New("moneycorp", config) + Expect(err).To(BeNil()) + res, err := plg.Install(context.Background(), models.InstallRequest{}) + Expect(err).To(BeNil()) + Expect(res.Workflow[0].Name).To(Equal("fetch_accounts")) + }) + }) + + Context("uninstall", func() { + It("returns valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "dummyID"} + _, err := plg.Uninstall(context.Background(), req) + Expect(err).To(BeNil()) + }) + }) + + Context("calling functions on uninstalled plugins", func() { + It("fails when fetch next accounts is called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextAccounts(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next balances is called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextBalances(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next external accounts is called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextExternalAccounts(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when creating transfer is called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when creating payout is called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/transfers.go b/internal/connectors/plugins/public/moneycorp/transfers.go new file mode 100644 index 00000000..659a8f7e --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/transfers.go @@ -0,0 +1,96 @@ +package moneycorp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (*models.PSPPayment, error) { + if err := p.validateTransferPayoutRequests(pi); err != nil { + return nil, err + } + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return nil, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return nil, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest) + } + + resp, err := p.client.InitiateTransfer( + ctx, + &client.TransferRequest{ + IdempotencyKey: pi.Reference, + SourceAccountID: pi.SourceAccount.Reference, + ReceivingAccountID: pi.DestinationAccount.Reference, + TransferAmount: json.Number(amount), + TransferCurrency: curr, + TransferReference: pi.Description, + ClientReference: pi.Description, + }, + ) + if err != nil { + return nil, err + } + + return transferToPayment(resp) +} + +func transferToPayment(transfer *client.TransferResponse) (*models.PSPPayment, error) { + raw, err := json.Marshal(transfer) + if err != nil { + return nil, fmt.Errorf("failed to marshal transaction: %w", err) + } + + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transfer.Attributes.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse transaction date: %w", err) + } + + c, err := currency.GetPrecision(supportedCurrenciesWithDecimal, transfer.Attributes.TransferCurrency) + if err != nil { + return nil, err + } + + amount, err := currency.GetAmountWithPrecisionFromString(transfer.Attributes.TransferAmount.String(), c) + if err != nil { + return nil, err + } + + return &models.PSPPayment{ + Reference: transfer.ID, + CreatedAt: createdAt, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transfer.Attributes.TransferCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchTransferStatus(transfer.Attributes.TransferStatus), + SourceAccountReference: pointer.For(fmt.Sprintf("%d", transfer.Attributes.SendingAccountID)), + DestinationAccountReference: pointer.For(fmt.Sprintf("%d", transfer.Attributes.ReceivingAccountID)), + Raw: raw, + }, nil +} + +func matchTransferStatus(status string) models.PaymentStatus { + // Awaiting Dispatch, Cleared, or Cancelled + switch status { + case "Awaiting Dispatch": + return models.PAYMENT_STATUS_PENDING + case "Cleared": + return models.PAYMENT_STATUS_SUCCEEDED + case "Cancelled": + return models.PAYMENT_STATUS_FAILED + } + + return models.PAYMENT_STATUS_UNKNOWN +} diff --git a/internal/connectors/plugins/public/moneycorp/transfers_test.go b/internal/connectors/plugins/public/moneycorp/transfers_test.go new file mode 100644 index 00000000..fd31c7d0 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/transfers_test.go @@ -0,0 +1,172 @@ +package moneycorp + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp *Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "123", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + }, + DestinationAccount: &models.PSPAccount{ + Reference: "321", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - initiate transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().InitiateTransfer(gomock.Any(), &client.TransferRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + ReceivingAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + TransferAmount: "1.00", + TransferCurrency: "EUR", + TransferReference: samplePSPPaymentInitiation.Description, + ClientReference: samplePSPPaymentInitiation.Description, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.TransferResponse{ + ID: "1", + Attributes: client.TransferAttributes{ + SendingAccountID: 123, + ReceivingAccountID: 321, + CreatedAt: now.Format("2006-01-02T15:04:05.999999999"), + TransferReference: samplePSPPaymentInitiation.Description, + ClientReference: samplePSPPaymentInitiation.Description, + TransferAmount: "1.00", + TransferCurrency: "EUR", + TransferStatus: "Cleared", + }, + } + m.EXPECT().InitiateTransfer(gomock.Any(), &client.TransferRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + SourceAccountID: samplePSPPaymentInitiation.SourceAccount.Reference, + ReceivingAccountID: samplePSPPaymentInitiation.DestinationAccount.Reference, + TransferAmount: "1.00", + TransferCurrency: "EUR", + TransferReference: samplePSPPaymentInitiation.Description, + ClientReference: samplePSPPaymentInitiation.Description, + }).Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "1", + CreatedAt: now, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("123"), + DestinationAccountReference: pointer.For("321"), + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/utils.go b/internal/connectors/plugins/public/moneycorp/utils.go new file mode 100644 index 00000000..35764df4 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/utils.go @@ -0,0 +1,19 @@ +package moneycorp + +import ( + "fmt" + + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validateTransferPayoutRequests(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} diff --git a/internal/connectors/plugins/public/moneycorp/workflow.go b/internal/connectors/plugins/public/moneycorp/workflow.go new file mode 100644 index 00000000..9f89b4d5 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/workflow.go @@ -0,0 +1,33 @@ +package moneycorp + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_recipients", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + } +} diff --git a/internal/connectors/plugins/public/stripe/accounts.go b/internal/connectors/plugins/public/stripe/accounts.go new file mode 100644 index 00000000..15b7c572 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/accounts.go @@ -0,0 +1,90 @@ +package stripe + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" +) + +var ( + rootAccountReference = "root" +) + +// root account reference is internal so we don't pass it to Stripe API clients +func resolveAccount(ref string) string { + if ref == rootAccountReference { + return "" + } + return ref +} + +type accountsState struct { + RootCreated bool `json:"root_created"` + Timeline client.Timeline `json:"timeline"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + accounts := make([]models.PSPAccount, 0, req.PageSize) + if !oldState.RootCreated { + // create a root account if this is the first time this is being run + accounts = append(accounts, models.PSPAccount{ + Name: &rootAccountReference, + Reference: rootAccountReference, + CreatedAt: time.Now().UTC(), + Raw: json.RawMessage("{}"), + Metadata: map[string]string{}, + }) + oldState.RootCreated = true + } + + needed := req.PageSize - len(accounts) + + newState := oldState + rawAccounts, timeline, hasMore, err := p.client.GetAccounts(ctx, oldState.Timeline, int64(needed)) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + newState.Timeline = timeline + + for _, acc := range rawAccounts { + raw, err := json.Marshal(acc) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + metadata := make(map[string]string) + for k, v := range acc.Metadata { + metadata[k] = v + } + + defaultAsset := currency.FormatAsset(supportedCurrenciesWithDecimal, string(acc.DefaultCurrency)) + accounts = append(accounts, models.PSPAccount{ + Reference: acc.ID, + CreatedAt: time.Unix(acc.Created, 0).UTC(), + DefaultAsset: &defaultAsset, + Raw: raw, + Metadata: metadata, + }) + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} diff --git a/internal/connectors/plugins/public/stripe/accounts_test.go b/internal/connectors/plugins/public/stripe/accounts_test.go new file mode 100644 index 00000000..54f5a568 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/accounts_test.go @@ -0,0 +1,71 @@ +package stripe + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + stripesdk "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetch next accounts", func() { + var ( + m *client.MockClient + + pageSize int + sampleAccounts []*stripesdk.Account + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + pageSize = 20 + + sampleAccounts = make([]*stripesdk.Account, 0) + for i := 0; i < pageSize-1; i++ { + sampleAccounts = append(sampleAccounts, &stripesdk.Account{ + ID: fmt.Sprintf("some-reference-%d", i), + }) + } + + }) + It("fetches next accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + // pageSize passed to client is less when we generate a root account + m.EXPECT().GetAccounts(gomock.Any(), gomock.Any(), int64(pageSize-1)).Return( + sampleAccounts, + client.Timeline{LatestID: sampleAccounts[len(sampleAccounts)-1].ID}, + true, + nil, + ) + res, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(BeTrue()) + Expect(res.Accounts).To(HaveLen(req.PageSize)) + Expect(res.Accounts[0].Reference).To(Equal("root")) + + var state accountsState + + err = json.Unmarshal(res.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.Timeline.LatestID).To(Equal(res.Accounts[len(res.Accounts)-1].Reference)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/balances.go b/internal/connectors/plugins/public/stripe/balances.go new file mode 100644 index 00000000..66c2467c --- /dev/null +++ b/internal/connectors/plugins/public/stripe/balances.go @@ -0,0 +1,43 @@ +package stripe + +import ( + "context" + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, errors.New("missing from payload when fetching balances") + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balance, err := p.client.GetAccountBalances(ctx, resolveAccount(from.Reference)) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + var accountBalances []models.PSPBalance + for _, available := range balance.Available { + timestamp := time.Now() + accountBalances = append(accountBalances, models.PSPBalance{ + AccountReference: from.Reference, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, string(available.Currency)), + Amount: big.NewInt(available.Amount), + CreatedAt: timestamp, + }) + } + + return models.FetchNextBalancesResponse{ + Balances: accountBalances, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/stripe/balances_test.go b/internal/connectors/plugins/public/stripe/balances_test.go new file mode 100644 index 00000000..e3b1adec --- /dev/null +++ b/internal/connectors/plugins/public/stripe/balances_test.go @@ -0,0 +1,69 @@ +package stripe + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + stripesdk "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin Balances", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetch next balances", func() { + var ( + m *client.MockClient + + accRef string + sampleBalance *stripesdk.Balance + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + accRef = "abc" + sampleBalance = &stripesdk.Balance{ + Available: []*stripesdk.Amount{ + { + Currency: stripesdk.CurrencyAED, + Amount: 49999, + }, + }, + } + }) + It("fetches next balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + } + m.EXPECT().GetAccountBalances(gomock.Any(), accRef).Return( + sampleBalance, + nil, + ) + res, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Balances).To(HaveLen(len(sampleBalance.Available))) + + for i, available := range sampleBalance.Available { + Expect(res.Balances[i].AccountReference).To(Equal(accRef)) + Expect(res.Balances[i].Amount).To(BeEquivalentTo(big.NewInt(available.Amount))) + Expect(res.Balances[i].Asset).To(HavePrefix(strings.ToUpper(string(available.Currency)))) + } + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/capabilities.go b/internal/connectors/plugins/public/stripe/capabilities.go new file mode 100644 index 00000000..7bb52fd5 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/capabilities.go @@ -0,0 +1,13 @@ +package stripe + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, +} diff --git a/internal/connectors/plugins/public/stripe/client/accounts.go b/internal/connectors/plugins/public/stripe/client/accounts.go new file mode 100644 index 00000000..d3403585 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/accounts.go @@ -0,0 +1,46 @@ +package client + +import ( + "context" + "time" + + "github.com/stripe/stripe-go/v79" +) + +func (c *client) GetAccounts( + ctx context.Context, + timeline Timeline, + pageSize int64, +) (results []*stripe.Account, _ Timeline, hasMore bool, err error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_accounts") + + results = make([]*stripe.Account, 0, int(pageSize)) + + if !timeline.IsCaughtUp() { + var oldest interface{} + oldest, timeline, hasMore, err = scanForOldest(timeline, pageSize, func(params stripe.ListParams) (stripe.ListContainer, error) { + itr := c.accountClient.List(&stripe.AccountListParams{ListParams: params}) + return itr.AccountList(), wrapSDKErr(itr.Err()) + }) + if err != nil { + return results, timeline, false, err + } + // either there are no records or we haven't found the start yet + if !timeline.IsCaughtUp() { + return results, timeline, hasMore, nil + } + results = append(results, oldest.(*stripe.Account)) + } + + filters := stripe.ListParams{ + Limit: limit(pageSize, len(results)), + EndingBefore: &timeline.LatestID, + Single: true, // turn off autopagination + } + + itr := c.accountClient.List(&stripe.AccountListParams{ListParams: filters}) + results = append(results, itr.AccountList().Data...) + timeline.LatestID = results[len(results)-1].ID + return results, timeline, itr.AccountList().ListMeta.HasMore, wrapSDKErr(itr.Err()) +} diff --git a/internal/connectors/plugins/public/stripe/client/balances.go b/internal/connectors/plugins/public/stripe/client/balances.go new file mode 100644 index 00000000..5b94f5cd --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/balances.go @@ -0,0 +1,26 @@ +package client + +import ( + "context" + "fmt" + "time" + + "github.com/stripe/stripe-go/v79" +) + +func (c *client) GetAccountBalances(ctx context.Context, accountID string) (*stripe.Balance, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_balances") + + var filters stripe.Params + if accountID != "" { + filters.StripeAccount = &accountID + } + + balance, err := c.balanceClient.Get(&stripe.BalanceParams{Params: filters}) + err = wrapSDKErr(err) + if err != nil { + return nil, fmt.Errorf("failed to get stripe balance: %w", err) + } + return balance, nil +} diff --git a/internal/connectors/plugins/public/stripe/client/client.go b/internal/connectors/plugins/public/stripe/client/client.go new file mode 100644 index 00000000..d3ea4fac --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/client.go @@ -0,0 +1,103 @@ +package client + +import ( + "context" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/connectors/metrics" + "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/account" + "github.com/stripe/stripe-go/v79/balance" + "github.com/stripe/stripe-go/v79/balancetransaction" + "github.com/stripe/stripe-go/v79/bankaccount" + "github.com/stripe/stripe-go/v79/payout" + "github.com/stripe/stripe-go/v79/transfer" + "github.com/stripe/stripe-go/v79/transferreversal" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetAccounts(ctx context.Context, timeline Timeline, pageSize int64) ([]*stripe.Account, Timeline, bool, error) + GetAccountBalances(ctx context.Context, accountID string) (*stripe.Balance, error) + GetExternalAccounts(ctx context.Context, accountID string, timeline Timeline, pageSize int64) ([]*stripe.BankAccount, Timeline, bool, error) + GetPayments(ctx context.Context, accountID string, timeline Timeline, pageSize int64) ([]*stripe.BalanceTransaction, Timeline, bool, error) + CreatePayout(ctx context.Context, createPayoutRequest *CreatePayoutRequest) (*stripe.Payout, error) + CreateTransfer(ctx context.Context, createTransferRequest *CreateTransferRequest) (*stripe.Transfer, error) + ReverseTransfer(ctx context.Context, reverseTransferRequest ReverseTransferRequest) (*stripe.TransferReversal, error) +} + +type client struct { + accountClient account.Client + balanceClient balance.Client + transferClient transfer.Client + transferReversalClient transferreversal.Client + payoutClient payout.Client + bankAccountClient bankaccount.Client + balanceTransactionClient balancetransaction.Client + + commonMetricAttributes []attribute.KeyValue +} + +func New(backend stripe.Backend, apiKey string) Client { + if backend == nil { + backend = stripe.GetBackend(stripe.APIBackend) + } + + return &client{ + accountClient: account.Client{B: backend, Key: apiKey}, + balanceClient: balance.Client{B: backend, Key: apiKey}, + transferClient: transfer.Client{B: backend, Key: apiKey}, + payoutClient: payout.Client{B: backend, Key: apiKey}, + bankAccountClient: bankaccount.Client{B: backend, Key: apiKey}, + balanceTransactionClient: balancetransaction.Client{B: backend, Key: apiKey}, + commonMetricAttributes: CommonMetricsAttributes(), + } +} + +// recordMetrics is meant to be called in a defer +func (c *client) recordMetrics(ctx context.Context, start time.Time, operation string) { + registry := metrics.GetMetricsRegistry() + + attrs := c.commonMetricAttributes + attrs = append(attrs, attribute.String("operation", operation)) + opts := metric.WithAttributes(attrs...) + + registry.ConnectorPSPCalls().Add(ctx, 1, opts) + registry.ConnectorPSPCallLatencies().Record(ctx, time.Since(start).Milliseconds(), opts) +} + +func limit(wanted int64, have int) *int64 { + needed := wanted - int64(have) + return &needed +} + +// wrap a public error for cases that we don't want to retry +// so that activities can classify this error for temporal +func wrapSDKErr(err error) error { + if err == nil { + return nil + } + + stripeErr, ok := err.(*stripe.Error) + if !ok { + return err + } + + switch stripeErr.Type { + case stripe.ErrorTypeInvalidRequest, stripe.ErrorTypeIdempotency: + return fmt.Errorf("%w: %w", httpwrapper.ErrStatusCodeClientError, err) + + } + return err +} + +func CommonMetricsAttributes() []attribute.KeyValue { + metricsAttributes := []attribute.KeyValue{ + attribute.String("connector", "stripe"), + } + return metricsAttributes +} diff --git a/internal/connectors/plugins/public/stripe/client/client_generated.go b/internal/connectors/plugins/public/stripe/client/client_generated.go new file mode 100644 index 00000000..ff757b71 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/client_generated.go @@ -0,0 +1,152 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + stripe "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreatePayout mocks base method. +func (m *MockClient) CreatePayout(ctx context.Context, createPayoutRequest *CreatePayoutRequest) (*stripe.Payout, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePayout", ctx, createPayoutRequest) + ret0, _ := ret[0].(*stripe.Payout) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePayout indicates an expected call of CreatePayout. +func (mr *MockClientMockRecorder) CreatePayout(ctx, createPayoutRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePayout", reflect.TypeOf((*MockClient)(nil).CreatePayout), ctx, createPayoutRequest) +} + +// CreateTransfer mocks base method. +func (m *MockClient) CreateTransfer(ctx context.Context, createTransferRequest *CreateTransferRequest) (*stripe.Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTransfer", ctx, createTransferRequest) + ret0, _ := ret[0].(*stripe.Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTransfer indicates an expected call of CreateTransfer. +func (mr *MockClientMockRecorder) CreateTransfer(ctx, createTransferRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransfer", reflect.TypeOf((*MockClient)(nil).CreateTransfer), ctx, createTransferRequest) +} + +// GetAccountBalances mocks base method. +func (m *MockClient) GetAccountBalances(ctx context.Context, accountID string) (*stripe.Balance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountBalances", ctx, accountID) + ret0, _ := ret[0].(*stripe.Balance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountBalances indicates an expected call of GetAccountBalances. +func (mr *MockClientMockRecorder) GetAccountBalances(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountBalances", reflect.TypeOf((*MockClient)(nil).GetAccountBalances), ctx, accountID) +} + +// GetAccounts mocks base method. +func (m *MockClient) GetAccounts(ctx context.Context, timeline Timeline, pageSize int64) ([]*stripe.Account, Timeline, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccounts", ctx, timeline, pageSize) + ret0, _ := ret[0].([]*stripe.Account) + ret1, _ := ret[1].(Timeline) + ret2, _ := ret[2].(bool) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// GetAccounts indicates an expected call of GetAccounts. +func (mr *MockClientMockRecorder) GetAccounts(ctx, timeline, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccounts", reflect.TypeOf((*MockClient)(nil).GetAccounts), ctx, timeline, pageSize) +} + +// GetExternalAccounts mocks base method. +func (m *MockClient) GetExternalAccounts(ctx context.Context, accountID string, timeline Timeline, pageSize int64) ([]*stripe.BankAccount, Timeline, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExternalAccounts", ctx, accountID, timeline, pageSize) + ret0, _ := ret[0].([]*stripe.BankAccount) + ret1, _ := ret[1].(Timeline) + ret2, _ := ret[2].(bool) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// GetExternalAccounts indicates an expected call of GetExternalAccounts. +func (mr *MockClientMockRecorder) GetExternalAccounts(ctx, accountID, timeline, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExternalAccounts", reflect.TypeOf((*MockClient)(nil).GetExternalAccounts), ctx, accountID, timeline, pageSize) +} + +// GetPayments mocks base method. +func (m *MockClient) GetPayments(ctx context.Context, accountID string, timeline Timeline, pageSize int64) ([]*stripe.BalanceTransaction, Timeline, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayments", ctx, accountID, timeline, pageSize) + ret0, _ := ret[0].([]*stripe.BalanceTransaction) + ret1, _ := ret[1].(Timeline) + ret2, _ := ret[2].(bool) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// GetPayments indicates an expected call of GetPayments. +func (mr *MockClientMockRecorder) GetPayments(ctx, accountID, timeline, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayments", reflect.TypeOf((*MockClient)(nil).GetPayments), ctx, accountID, timeline, pageSize) +} + +// ReverseTransfer mocks base method. +func (m *MockClient) ReverseTransfer(ctx context.Context, reverseTransferRequest ReverseTransferRequest) (*stripe.TransferReversal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReverseTransfer", ctx, reverseTransferRequest) + ret0, _ := ret[0].(*stripe.TransferReversal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReverseTransfer indicates an expected call of ReverseTransfer. +func (mr *MockClientMockRecorder) ReverseTransfer(ctx, reverseTransferRequest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReverseTransfer", reflect.TypeOf((*MockClient)(nil).ReverseTransfer), ctx, reverseTransferRequest) +} diff --git a/internal/connectors/plugins/public/stripe/client/external_accounts.go b/internal/connectors/plugins/public/stripe/client/external_accounts.go new file mode 100644 index 00000000..f5862951 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/external_accounts.go @@ -0,0 +1,58 @@ +package client + +import ( + "context" + "time" + + "github.com/stripe/stripe-go/v79" +) + +func (c *client) GetExternalAccounts( + ctx context.Context, + accountID string, + timeline Timeline, + pageSize int64, +) (results []*stripe.BankAccount, _ Timeline, hasMore bool, err error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_bank_accounts") + + results = make([]*stripe.BankAccount, 0, int(pageSize)) + + // return 0 results because this endpoint cannot be used for root account + if accountID == "" { + return results, timeline, false, nil + } + + if !timeline.IsCaughtUp() { + var oldest interface{} + oldest, timeline, hasMore, err = scanForOldest(timeline, pageSize, func(params stripe.ListParams) (stripe.ListContainer, error) { + itr := c.bankAccountClient.List(&stripe.BankAccountListParams{ + Account: &accountID, + ListParams: params, + }) + return itr.BankAccountList(), wrapSDKErr(itr.Err()) + }) + if err != nil { + return results, timeline, false, err + } + // either there are no records or we haven't found the start yet + if !timeline.IsCaughtUp() { + return results, timeline, hasMore, nil + } + results = append(results, oldest.(*stripe.BankAccount)) + } + + itr := c.bankAccountClient.List(&stripe.BankAccountListParams{ + Account: &accountID, + ListParams: stripe.ListParams{ + Limit: &pageSize, + EndingBefore: &timeline.LatestID, + }, + }) + if err := itr.Err(); err != nil { + return nil, timeline, false, wrapSDKErr(err) + } + results = append(results, itr.BankAccountList().Data...) + timeline.LatestID = results[len(results)-1].ID + return results, timeline, itr.BankAccountList().ListMeta.HasMore, nil +} diff --git a/internal/connectors/plugins/public/stripe/client/payments.go b/internal/connectors/plugins/public/stripe/client/payments.go new file mode 100644 index 00000000..fc8cf247 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/payments.go @@ -0,0 +1,83 @@ +package client + +import ( + "context" + "time" + + "github.com/stripe/stripe-go/v79" +) + +const ( + expandSource = "data.source" + expandSourceCharge = "data.source.charge" + expandSourceDispute = "data.source.dispute" + expandSourcePayout = "data.source.payout" + expandSourceRefund = "data.source.refund" + expandSourceTransfer = "data.source.transfer" + expandSourcePaymentIntent = "data.source.payment_intent" + expandSourceRefundPaymentIntent = "data.source.refund.payment_intent" +) + +func (c *client) GetPayments( + ctx context.Context, + accountID string, + timeline Timeline, + pageSize int64, +) (results []*stripe.BalanceTransaction, _ Timeline, hasMore bool, err error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "list_transactions") + + results = make([]*stripe.BalanceTransaction, 0, int(pageSize)) + + if !timeline.IsCaughtUp() { + var oldest interface{} + oldest, timeline, hasMore, err = scanForOldest(timeline, pageSize, func(params stripe.ListParams) (stripe.ListContainer, error) { + if accountID != "" { + params.StripeAccount = &accountID + } + transactionParams := &stripe.BalanceTransactionListParams{ListParams: params} + expandBalanceTransactionParams(transactionParams) + itr := c.balanceTransactionClient.List(transactionParams) + return itr.BalanceTransactionList(), wrapSDKErr(itr.Err()) + }) + if err != nil { + return results, timeline, false, err + } + // either there are no records or we haven't found the start yet + if !timeline.IsCaughtUp() { + return results, timeline, hasMore, nil + } + results = append(results, oldest.(*stripe.BalanceTransaction)) + } + + filters := stripe.ListParams{ + Limit: limit(pageSize, len(results)), + EndingBefore: &timeline.LatestID, + Single: true, // turn off autopagination + } + + if accountID != "" { + filters.StripeAccount = &accountID + } + + params := &stripe.BalanceTransactionListParams{ + ListParams: filters, + } + expandBalanceTransactionParams(params) + + itr := c.balanceTransactionClient.List(params) + results = append(results, itr.BalanceTransactionList().Data...) + timeline.LatestID = results[len(results)-1].ID + return results, timeline, itr.BalanceTransactionList().ListMeta.HasMore, wrapSDKErr(itr.Err()) +} + +func expandBalanceTransactionParams(params *stripe.BalanceTransactionListParams) { + params.AddExpand(expandSource) + params.AddExpand(expandSourceCharge) + params.AddExpand(expandSourceDispute) + params.AddExpand(expandSourcePayout) + params.AddExpand(expandSourceRefund) + params.AddExpand(expandSourceTransfer) + params.AddExpand(expandSourcePaymentIntent) + params.AddExpand(expandSourceRefundPaymentIntent) +} diff --git a/internal/connectors/plugins/public/stripe/client/payouts.go b/internal/connectors/plugins/public/stripe/client/payouts.go new file mode 100644 index 00000000..c2297d60 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/payouts.go @@ -0,0 +1,53 @@ +package client + +import ( + "context" + "time" + + "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/payout" +) + +type CreatePayoutRequest struct { + IdempotencyKey string + Amount int64 + Currency string + Source *string + Destination string + Description string + Metadata map[string]string +} + +func (c *client) CreatePayout(ctx context.Context, createPayoutRequest *CreatePayoutRequest) (*stripe.Payout, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "initiate_payout") + + params := &stripe.PayoutParams{ + Params: stripe.Params{ + Context: ctx, + StripeAccount: createPayoutRequest.Source, + }, + Amount: stripe.Int64(createPayoutRequest.Amount), + Currency: stripe.String(createPayoutRequest.Currency), + Destination: stripe.String(createPayoutRequest.Destination), + Metadata: createPayoutRequest.Metadata, + Method: stripe.String("standard"), + } + + params.AddExpand("balance_transaction") + + if createPayoutRequest.IdempotencyKey != "" { + params.IdempotencyKey = stripe.String(createPayoutRequest.IdempotencyKey) + } + + if createPayoutRequest.Description != "" { + params.Description = stripe.String(createPayoutRequest.Description) + } + + payoutResponse, err := payout.New(params) + if err != nil { + return nil, wrapSDKErr(err) + } + + return payoutResponse, nil +} diff --git a/internal/connectors/plugins/public/stripe/client/reversals.go b/internal/connectors/plugins/public/stripe/client/reversals.go new file mode 100644 index 00000000..0c10399f --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/reversals.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "time" + + "github.com/stripe/stripe-go/v79" +) + +type ReverseTransferRequest struct { + IdempotencyKey string + StripeTransferID string + Account *string + Amount int64 + Description string + Metadata map[string]string +} + +func (c *client) ReverseTransfer(ctx context.Context, reverseTransferRequest ReverseTransferRequest) (*stripe.TransferReversal, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "reverse_transfer") + + params := &stripe.TransferReversalParams{ + Params: stripe.Params{ + Context: ctx, + StripeAccount: reverseTransferRequest.Account, + }, + ID: stripe.String(reverseTransferRequest.StripeTransferID), + Amount: stripe.Int64(reverseTransferRequest.Amount), + Description: stripe.String(reverseTransferRequest.Description), + Metadata: reverseTransferRequest.Metadata, + } + + params.AddExpand("balance_transaction") + params.AddExpand("transfer") + params.AddExpand("transfer.balance_transaction") + if reverseTransferRequest.IdempotencyKey != "" { + params.IdempotencyKey = stripe.String(reverseTransferRequest.IdempotencyKey) + } + + if reverseTransferRequest.Description != "" { + params.Description = stripe.String(reverseTransferRequest.Description) + } + + reversalResponse, err := c.transferReversalClient.New(params) + if err != nil { + return nil, wrapSDKErr(err) + } + + return reversalResponse, nil +} diff --git a/internal/connectors/plugins/public/stripe/client/timeline.go b/internal/connectors/plugins/public/stripe/client/timeline.go new file mode 100644 index 00000000..c9ee5dd9 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/timeline.go @@ -0,0 +1,77 @@ +package client + +import ( + "fmt" + + "github.com/stripe/stripe-go/v79" +) + +// Timeline allows the client to navigate the backlog and decide whether to fetch +// historical or recently added data +type Timeline struct { + LatestID string `json:"latest_id"` + BacklogCursor string `json:"backlog_cursor"` +} + +func (t Timeline) IsCaughtUp() bool { + return t.LatestID != "" +} + +func scanForOldest( + timeline Timeline, + pageSize int64, + listFn func(stripe.ListParams) (stripe.ListContainer, error), +) (interface{}, Timeline, bool, error) { + filters := stripe.ListParams{ + Limit: limit(pageSize, 0), + Single: true, // turn off autopagination + } + if timeline.BacklogCursor != "" { + filters.StartingAfter = &timeline.BacklogCursor + } + + var oldest interface{} + var oldestID string + + list, err := listFn(filters) + if err != nil { + return oldest, timeline, false, err + } + hasMore := list.GetListMeta().HasMore + + switch v := list.(type) { + case *stripe.AccountList: + if len(v.Data) == 0 { + return oldest, timeline, hasMore, nil + } + account := v.Data[len(v.Data)-1] + oldest = account + oldestID = account.ID + + case *stripe.BankAccountList: + if len(v.Data) == 0 { + return oldest, timeline, hasMore, nil + } + account := v.Data[len(v.Data)-1] + oldest = account + oldestID = account.ID + + case *stripe.BalanceTransactionList: + if len(v.Data) == 0 { + return oldest, timeline, hasMore, nil + } + trx := v.Data[len(v.Data)-1] + oldest = trx + oldestID = trx.ID + default: + return nil, timeline, hasMore, fmt.Errorf("failed to fetch backlog for type %T", list) + } + + // we haven't found the oldest yet + if hasMore { + timeline.BacklogCursor = oldestID + return nil, timeline, hasMore, nil + } + timeline.LatestID = oldestID + return oldest, timeline, hasMore, nil +} diff --git a/internal/connectors/plugins/public/stripe/client/transfers.go b/internal/connectors/plugins/public/stripe/client/transfers.go new file mode 100644 index 00000000..c08410cd --- /dev/null +++ b/internal/connectors/plugins/public/stripe/client/transfers.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "time" + + "github.com/stripe/stripe-go/v79" +) + +type CreateTransferRequest struct { + IdempotencyKey string + Amount int64 + Currency string + Source *string + Destination string + Description string + Metadata map[string]string +} + +func (c *client) CreateTransfer(ctx context.Context, createTransferRequest *CreateTransferRequest) (*stripe.Transfer, error) { + start := time.Now() + defer c.recordMetrics(ctx, start, "initiate_transfer") + + params := &stripe.TransferParams{ + Params: stripe.Params{ + Context: ctx, + StripeAccount: createTransferRequest.Source, + }, + Amount: stripe.Int64(createTransferRequest.Amount), + Currency: stripe.String(createTransferRequest.Currency), + Destination: stripe.String(createTransferRequest.Destination), + Metadata: createTransferRequest.Metadata, + } + + params.AddExpand("balance_transaction") + + if createTransferRequest.IdempotencyKey != "" { + params.IdempotencyKey = stripe.String(createTransferRequest.IdempotencyKey) + } + + if createTransferRequest.Description != "" { + params.Description = stripe.String(createTransferRequest.Description) + } + + transferResponse, err := c.transferClient.New(params) + if err != nil { + return nil, wrapSDKErr(err) + } + + return transferResponse, nil +} diff --git a/internal/connectors/plugins/public/stripe/config.go b/internal/connectors/plugins/public/stripe/config.go new file mode 100644 index 00000000..3da4ebd2 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/config.go @@ -0,0 +1,28 @@ +package stripe + +import ( + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + APIKey string `json:"apiKey"` +} + +func (c Config) validate() error { + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api key in config") + } + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/stripe/config.json b/internal/connectors/plugins/public/stripe/config.json new file mode 100644 index 00000000..7d3f82ab --- /dev/null +++ b/internal/connectors/plugins/public/stripe/config.json @@ -0,0 +1,7 @@ +{ + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} diff --git a/internal/connectors/plugins/public/stripe/create_payouts.go b/internal/connectors/plugins/public/stripe/create_payouts.go new file mode 100644 index 00000000..45caf868 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/create_payouts.go @@ -0,0 +1,90 @@ +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/stripe/stripe-go/v79" +) + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validatePayoutTransferRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + curr, _, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + var source *string = nil + if pi.SourceAccount != nil && pi.SourceAccount.Reference != rootAccountReference { + source = &pi.SourceAccount.Reference + } + + resp, err := p.client.CreatePayout( + ctx, + &client.CreatePayoutRequest{ + IdempotencyKey: pi.Reference, + Amount: pi.Amount.Int64(), + Currency: curr, + Source: source, + Destination: pi.DestinationAccount.Reference, + Description: pi.Description, + Metadata: pi.Metadata, + }, + ) + if err != nil { + return models.PSPPayment{}, err + } + + payment, err := fromPayoutToPayment(resp, source, &pi.DestinationAccount.Reference) + if err != nil { + return models.PSPPayment{}, err + } + + return payment, nil +} + +func fromPayoutToPayment(from *stripe.Payout, source, destination *string) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + return models.PSPPayment{ + Reference: from.BalanceTransaction.ID, + CreatedAt: time.Unix(from.Created, 0), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(from.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, strings.ToUpper(string(from.Currency))), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchPayoutStatus(from.Status), + SourceAccountReference: source, + DestinationAccountReference: destination, + Metadata: from.Metadata, + Raw: raw, + }, nil +} + +func matchPayoutStatus(status stripe.PayoutStatus) models.PaymentStatus { + switch status { + case stripe.PayoutStatusCanceled: + return models.PAYMENT_STATUS_CANCELLED + case stripe.PayoutStatusFailed: + return models.PAYMENT_STATUS_FAILED + case stripe.PayoutStatusInTransit, stripe.PayoutStatusPending: + return models.PAYMENT_STATUS_PENDING + case stripe.PayoutStatusPaid: + return models.PAYMENT_STATUS_SUCCEEDED + default: + return models.PAYMENT_STATUS_UNKNOWN + } +} diff --git a/internal/connectors/plugins/public/stripe/create_payouts_test.go b/internal/connectors/plugins/public/stripe/create_payouts_test.go new file mode 100644 index 00000000..0380e213 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/create_payouts_test.go @@ -0,0 +1,164 @@ +package stripe + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" + "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: uuid.New().String(), + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "userID": "u1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HHH/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - create payout error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().CreatePayout(gomock.Any(), &client.CreatePayoutRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + Amount: 100, + Currency: "EUR", + Source: pointer.For("acc1"), + Destination: "acc2", + Description: samplePSPPaymentInitiation.Description, + Metadata: samplePSPPaymentInitiation.Metadata, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := &stripe.Payout{ + Amount: 100, + BalanceTransaction: &stripe.BalanceTransaction{ + ID: "bt1", + }, + Created: now.Unix(), + Currency: "EUR", + Description: samplePSPPaymentInitiation.Description, + ID: "t1", + Status: stripe.PayoutStatusInTransit, + Metadata: samplePSPPaymentInitiation.Metadata, + } + m.EXPECT().CreatePayout(gomock.Any(), &client.CreatePayoutRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + Amount: 100, + Currency: "EUR", + Source: pointer.For("acc1"), + Destination: "acc2", + Description: samplePSPPaymentInitiation.Description, + Metadata: samplePSPPaymentInitiation.Metadata, + }).Return(trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "bt1", + CreatedAt: time.Unix(trResponse.Created, 0), + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_PENDING, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Metadata: samplePSPPaymentInitiation.Metadata, + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/create_transfers.go b/internal/connectors/plugins/public/stripe/create_transfers.go new file mode 100644 index 00000000..a49a72d8 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/create_transfers.go @@ -0,0 +1,75 @@ +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/stripe/stripe-go/v79" +) + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validatePayoutTransferRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + curr, _, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest) + } + + var source *string = nil + if pi.SourceAccount != nil && pi.SourceAccount.Reference != rootAccountReference { + source = &pi.SourceAccount.Reference + } + + resp, err := p.client.CreateTransfer( + ctx, + &client.CreateTransferRequest{ + IdempotencyKey: pi.Reference, + Amount: pi.Amount.Int64(), + Currency: curr, + Source: source, + Destination: pi.DestinationAccount.Reference, + Description: pi.Description, + Metadata: pi.Metadata, + }, + ) + if err != nil { + return models.PSPPayment{}, err + } + + payment, err := fromTransferToPayment(resp, source, &pi.DestinationAccount.Reference) + if err != nil { + return models.PSPPayment{}, err + } + + return payment, nil +} + +func fromTransferToPayment(from *stripe.Transfer, source, destination *string) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + return models.PSPPayment{ + Reference: from.BalanceTransaction.ID, + CreatedAt: time.Unix(from.Created, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(from.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, strings.ToUpper(string(from.Currency))), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: source, + DestinationAccountReference: destination, + Metadata: from.Metadata, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/stripe/create_transfers_test.go b/internal/connectors/plugins/public/stripe/create_transfers_test.go new file mode 100644 index 00000000..69d38424 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/create_transfers_test.go @@ -0,0 +1,163 @@ +package stripe + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" + "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: uuid.New().String(), + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "userID": "u1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HHH/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - create transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().CreateTransfer(gomock.Any(), &client.CreateTransferRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + Amount: 100, + Currency: "EUR", + Source: pointer.For("acc1"), + Destination: "acc2", + Description: samplePSPPaymentInitiation.Description, + Metadata: samplePSPPaymentInitiation.Metadata, + }).Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := &stripe.Transfer{ + Amount: 100, + BalanceTransaction: &stripe.BalanceTransaction{ + ID: "bt1", + }, + Created: now.Unix(), + Currency: "EUR", + Description: samplePSPPaymentInitiation.Description, + ID: "t1", + Metadata: samplePSPPaymentInitiation.Metadata, + } + m.EXPECT().CreateTransfer(gomock.Any(), &client.CreateTransferRequest{ + IdempotencyKey: samplePSPPaymentInitiation.Reference, + Amount: 100, + Currency: "EUR", + Source: pointer.For("acc1"), + Destination: "acc2", + Description: samplePSPPaymentInitiation.Description, + Metadata: samplePSPPaymentInitiation.Metadata, + }).Return(trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "bt1", + CreatedAt: time.Unix(trResponse.Created, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Metadata: samplePSPPaymentInitiation.Metadata, + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/currencies.go b/internal/connectors/plugins/public/stripe/currencies.go new file mode 100644 index 00000000..e791ba70 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/currencies.go @@ -0,0 +1,7 @@ +package stripe + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + supportedCurrenciesWithDecimal = currency.ISO4217Currencies +) diff --git a/internal/connectors/plugins/public/stripe/external_accounts.go b/internal/connectors/plugins/public/stripe/external_accounts.go new file mode 100644 index 00000000..0f02b8b2 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/external_accounts.go @@ -0,0 +1,86 @@ +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type externalAccountsState struct { + Timeline client.Timeline `json:"timeline"` +} + +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var ( + oldState externalAccountsState + from models.PSPAccount + ) + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + if req.FromPayload == nil { + return models.FetchNextExternalAccountsResponse{}, errors.New("missing from payload when fetching external accounts") + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + newState := oldState + var accounts []models.PSPAccount + + rawAccounts, timeline, hasMore, err := p.client.GetExternalAccounts( + ctx, + resolveAccount(from.Reference), + oldState.Timeline, + int64(req.PageSize), + ) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + newState.Timeline = timeline + + for _, acc := range rawAccounts { + raw, err := json.Marshal(acc) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + metadata := make(map[string]string) + for k, v := range acc.Metadata { + metadata[k] = v + } + + defaultAsset := currency.FormatAsset(supportedCurrenciesWithDecimal, string(acc.Currency)) + + if acc.Account == nil { + return models.FetchNextExternalAccountsResponse{}, fmt.Errorf("internal account %q is missing from response for %q", from.Reference, acc.ID) + } + + accounts = append(accounts, models.PSPAccount{ + Reference: acc.ID, + CreatedAt: time.Unix(acc.Account.Created, 0).UTC(), + DefaultAsset: &defaultAsset, + Raw: raw, + Metadata: metadata, + }) + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} diff --git a/internal/connectors/plugins/public/stripe/external_accounts_test.go b/internal/connectors/plugins/public/stripe/external_accounts_test.go new file mode 100644 index 00000000..52562009 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/external_accounts_test.go @@ -0,0 +1,75 @@ +package stripe + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + stripesdk "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin ExternalAccounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetch next ExternalAccounts", func() { + var ( + m *client.MockClient + + pageSize int + sampleExternalAccounts []*stripesdk.BankAccount + accRef string + created int64 + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + pageSize = 10 + accRef = "baseAcc" + created = 1483565364 + sampleExternalAccounts = make([]*stripesdk.BankAccount, 0) + for i := 0; i < pageSize; i++ { + sampleExternalAccounts = append(sampleExternalAccounts, &stripesdk.BankAccount{ + ID: fmt.Sprintf("some-reference-%d", i), + Account: &stripesdk.Account{Created: created}, + }) + } + + }) + It("fetches next ExternalAccounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + m.EXPECT().GetExternalAccounts(gomock.Any(), accRef, gomock.Any(), int64(pageSize)).Return( + sampleExternalAccounts, + client.Timeline{LatestID: sampleExternalAccounts[len(sampleExternalAccounts)-1].ID}, + true, + nil, + ) + res, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(BeTrue()) + Expect(res.ExternalAccounts).To(HaveLen(pageSize)) + + var state accountsState + + err = json.Unmarshal(res.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.Timeline.LatestID).To(Equal(res.ExternalAccounts[len(res.ExternalAccounts)-1].Reference)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/payments.go b/internal/connectors/plugins/public/stripe/payments.go new file mode 100644 index 00000000..1add0b63 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/payments.go @@ -0,0 +1,562 @@ +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/hashicorp/go-hclog" + "github.com/pkg/errors" + stripesdk "github.com/stripe/stripe-go/v79" +) + +var ( + ErrInvalidPaymentSource = errors.New("payment source is invalid") + ErrUnsupportedAdjustment = errors.New("unsupported adjustment") + ErrUnsupportedTransactionType = errors.New("unsupported TransactionType") + ErrUnsupportedCurrency = errors.New("unsupported currency") +) + +type paymentsState struct { + Timeline client.Timeline `json:"timeline"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextPaymentsResponse{}, errors.New("missing from payload when fetching payments") + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + var payments []models.PSPPayment + newState := oldState + rawPayments, timeline, hasMore, err := p.client.GetPayments( + ctx, + resolveAccount(from.Reference), + oldState.Timeline, + int64(req.PageSize), + ) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + newState.Timeline = timeline + + for _, rawPayment := range rawPayments { + payment, err := p.translatePayment(&from.Reference, rawPayment) + if err != nil { + return models.FetchNextPaymentsResponse{}, fmt.Errorf("failed to translate payment: %w", err) + } + + // skip unsupported payments + if payment == nil { + continue + } + payments = append(payments, *payment) + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func (p *Plugin) translatePayment(accountRef *string, balanceTransaction *stripesdk.BalanceTransaction) (payment *models.PSPPayment, err error) { + if balanceTransaction.Source == nil { + hclog.Default().Info("skipping balance transaction with nil source element", "type", balanceTransaction.Type, "id", balanceTransaction.ID) + return nil, nil + } + + rawData, err := json.Marshal(balanceTransaction) + if err != nil { + return nil, fmt.Errorf("failed to marshal raw data: %w", err) + } + metadata := make(map[string]string) + + switch balanceTransaction.Type { + case stripesdk.BalanceTransactionTypeCharge: + if balanceTransaction.Source.Charge == nil { + hclog.Default().Info("skipping balance transaction with nil charge element", "type", balanceTransaction.Type, "id", balanceTransaction.ID) + return nil, nil + } + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Charge.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + appendMetadata(metadata, balanceTransaction.Source.Charge.Metadata) + if balanceTransaction.Source.Charge.PaymentIntent != nil { + appendMetadata(metadata, balanceTransaction.Source.Charge.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(balanceTransaction.Source.Charge.Amount - balanceTransaction.Source.Charge.AmountRefunded), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: detailsToPaymentScheme(balanceTransaction.Source.Charge.PaymentMethodDetails), + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Metadata: metadata, + Raw: rawData, + } + + case stripesdk.BalanceTransactionTypeRefund: + // Refund a charge + // Created when a credit card charge refund is initiated. + // If you authorize and capture separately and the capture amount is + // less than the initial authorization, you see a balance transaction + // of type charge for the full authorization amount and another balance + // transaction of type refund for the uncaptured portion. + // cf https://stripe.com/docs/reports/balance-transaction-types + + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + if balanceTransaction.Source.Refund.Charge == nil { + return nil, fmt.Errorf("refund charge missing from refund payload: %q", balanceTransaction.ID) + } + + appendMetadata(metadata, balanceTransaction.Source.Refund.Metadata) + if balanceTransaction.Source.Refund.PaymentIntent != nil { + appendMetadata(metadata, balanceTransaction.Source.Refund.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: models.PAYMENT_STATUS_REFUNDED, + Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: detailsToPaymentScheme(balanceTransaction.Source.Refund.Charge.PaymentMethodDetails), + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypeRefundFailure: + // Refund a charge + // Created when a credit card charge refund fails, and Stripe returns the funds to your balance. + // This may occur if your customer’s bank or card issuer is unable to correctly process a refund + // (e.g., due to a closed bank account or a problem with the card). + // cf https://stripe.com/docs/reports/balance-transaction-types + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + if balanceTransaction.Source.Refund.Charge == nil { + return nil, fmt.Errorf("refund charge missing from refund payload: %q", balanceTransaction.ID) + } + + appendMetadata(metadata, balanceTransaction.Source.Refund.Metadata) + if balanceTransaction.Source.Refund.PaymentIntent != nil { + appendMetadata(metadata, balanceTransaction.Source.Refund.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: models.PAYMENT_STATUS_REFUNDED_FAILURE, + Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: detailsToPaymentScheme(balanceTransaction.Source.Refund.Charge.PaymentMethodDetails), + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypePayment: + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Charge.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + appendMetadata(metadata, balanceTransaction.Source.Charge.Metadata) + if balanceTransaction.Source.Charge.PaymentIntent != nil { + appendMetadata(metadata, balanceTransaction.Source.Charge.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(balanceTransaction.Source.Charge.Amount - balanceTransaction.Source.Charge.AmountRefunded), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypePaymentRefund: + // Refund a payment + // Created when a local payment method refund is initiated. + // Additionally, if your customer’s bank or card issuer is unable to correctly process a refund + // (e.g., due to a closed bank account or a problem with the card) Stripe returns the funds to your balance. + // The returned funds are represented as a Balance transaction with the type payment_refund. + // cf https://stripe.com/docs/reports/balance-transaction-types + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + if balanceTransaction.Source.Refund.Charge == nil { + return nil, fmt.Errorf("refund charge missing from payment refund payload: %q", balanceTransaction.ID) + } + + appendMetadata(metadata, balanceTransaction.Source.Refund.Charge.Metadata) + if balanceTransaction.Source.Refund.Charge.PaymentIntent != nil { + appendMetadata(balanceTransaction.Source.Refund.Charge.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: models.PAYMENT_STATUS_REFUNDED, + Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypePaymentFailureRefund: + // Refund a payment + // ACH, direct debit, and other delayed notification payment methods remain in a pending state + // until they either succeed or fail. You’ll see a pending Balance transaction of type payment + // when the payment is created. Another Balance transaction of type payment_failure_refund appears + // if the pending payment later fails. + // cf https://stripe.com/docs/reports/balance-transaction-types + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Refund.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + if balanceTransaction.Source.Refund.Charge == nil { + return nil, fmt.Errorf("refund charge missing from payment refund failure payload: %q", balanceTransaction.ID) + } + + appendMetadata(metadata, balanceTransaction.Source.Refund.Charge.Metadata) + if balanceTransaction.Source.Refund.Charge.PaymentIntent != nil { + appendMetadata(metadata, balanceTransaction.Source.Refund.Charge.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Refund.Charge.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: models.PAYMENT_STATUS_REFUNDED_FAILURE, + Amount: big.NewInt(balanceTransaction.Source.Refund.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypePayout: + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Payout.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + appendMetadata(metadata, balanceTransaction.Source.Payout.Metadata) + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYOUT, + Status: convertPayoutStatus(balanceTransaction.Source.Payout.Status), + Amount: big.NewInt(balanceTransaction.Source.Payout.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: func() models.PaymentScheme { + switch balanceTransaction.Source.Payout.Type { + case stripesdk.PayoutTypeBank: + return models.PAYMENT_SCHEME_SEPA_CREDIT + case stripesdk.PayoutTypeCard: + return destinationToPaymentScheme(balanceTransaction.Source.Payout.Destination) + } + return models.PAYMENT_SCHEME_UNKNOWN + }(), + CreatedAt: time.Unix(balanceTransaction.Created, 0), + SourceAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypePayoutFailure, stripesdk.BalanceTransactionTypePayoutCancel: + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Payout.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + status := models.PAYMENT_STATUS_FAILED + if balanceTransaction.Type == stripesdk.BalanceTransactionTypePayoutCancel { + status = models.PAYMENT_STATUS_CANCELLED + } + + appendMetadata(metadata, balanceTransaction.Source.Payout.Metadata) + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Payout.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYOUT, + Status: status, + Amount: big.NewInt(balanceTransaction.Source.Payout.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: func() models.PaymentScheme { + switch balanceTransaction.Source.Payout.Type { + case stripesdk.PayoutTypeBank: + return models.PAYMENT_SCHEME_SEPA_CREDIT + case stripesdk.PayoutTypeCard: + return destinationToPaymentScheme(balanceTransaction.Source.Payout.Destination) + } + return models.PAYMENT_SCHEME_UNKNOWN + }(), + CreatedAt: time.Unix(balanceTransaction.Created, 0), + SourceAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + case stripesdk.BalanceTransactionTypeTransfer: + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Transfer.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + appendMetadata(metadata, balanceTransaction.Source.Transfer.Metadata) + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + Type: models.PAYMENT_TYPE_TRANSFER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(balanceTransaction.Source.Transfer.Amount - balanceTransaction.Source.Transfer.AmountReversed), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + CreatedAt: time.Unix(balanceTransaction.Created, 0), + SourceAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + if balanceTransaction.Source.Transfer.Destination != nil { + payment.DestinationAccountReference = &balanceTransaction.Source.Transfer.Destination.ID + } + + case stripesdk.BalanceTransactionTypeTransferRefund: + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Transfer.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + appendMetadata(metadata, balanceTransaction.Source.Transfer.Metadata) + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_TRANSFER, + Status: models.PAYMENT_STATUS_REFUNDED, + Amount: big.NewInt(balanceTransaction.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + SourceAccountReference: accountRef, + CreatedAt: time.Unix(balanceTransaction.Created, 0), + Raw: rawData, + Metadata: metadata, + } + + if balanceTransaction.Source.Transfer.Destination != nil { + payment.DestinationAccountReference = &balanceTransaction.Source.Transfer.Destination.ID + } + + case stripesdk.BalanceTransactionTypeTransferCancel, stripesdk.BalanceTransactionTypeTransferFailure: + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Transfer.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + status := models.PAYMENT_STATUS_FAILED + if balanceTransaction.Type == stripesdk.BalanceTransactionTypeTransferCancel { + status = models.PAYMENT_STATUS_CANCELLED + } + + appendMetadata(metadata, balanceTransaction.Source.Transfer.Metadata) + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Transfer.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_TRANSFER, + Status: status, + Amount: big.NewInt(balanceTransaction.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + SourceAccountReference: accountRef, + CreatedAt: time.Unix(balanceTransaction.Created, 0), + Raw: rawData, + Metadata: metadata, + } + + if balanceTransaction.Source.Transfer.Destination != nil { + payment.DestinationAccountReference = &balanceTransaction.Source.Transfer.Destination.ID + } + + case stripesdk.BalanceTransactionTypeAdjustment: + if balanceTransaction.Source.Dispute == nil { + // We are only handling dispute adjustments + return nil, ErrUnsupportedAdjustment + } + + transactionCurrency := strings.ToUpper(string(balanceTransaction.Source.Dispute.Charge.Currency)) + _, ok := supportedCurrenciesWithDecimal[transactionCurrency] + if !ok { + return nil, fmt.Errorf("%w %q", ErrUnsupportedCurrency, transactionCurrency) + } + + disputeStatus := convertDisputeStatus(balanceTransaction.Source.Dispute.Status) + paymentStatus := models.PAYMENT_STATUS_DISPUTE + switch disputeStatus { + case models.PAYMENT_STATUS_DISPUTE_WON: + paymentStatus = models.PAYMENT_STATUS_SUCCEEDED + case models.PAYMENT_STATUS_DISPUTE_LOST: + paymentStatus = models.PAYMENT_STATUS_FAILED + } + + appendMetadata(metadata, balanceTransaction.Source.Dispute.Charge.Metadata) + if balanceTransaction.Source.Dispute.Charge.PaymentIntent != nil { + appendMetadata(metadata, balanceTransaction.Source.Dispute.Charge.PaymentIntent.Metadata) + } + + payment = &models.PSPPayment{ + Reference: balanceTransaction.ID, + ParentReference: balanceTransaction.Source.Dispute.Charge.BalanceTransaction.ID, + Type: models.PAYMENT_TYPE_PAYIN, + Status: paymentStatus, // Dispute is occuring, we don't know the outcome yet + Amount: big.NewInt(balanceTransaction.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, transactionCurrency), + Scheme: detailsToPaymentScheme(balanceTransaction.Source.Dispute.Charge.PaymentMethodDetails), + CreatedAt: time.Unix(balanceTransaction.Created, 0), + DestinationAccountReference: accountRef, + Raw: rawData, + Metadata: metadata, + } + + default: + hclog.Default().Info("unsupported balance transaction type", "err", ErrUnsupportedTransactionType, "type", balanceTransaction.Type) + return nil, nil + } + + return payment, err +} + +func convertDisputeStatus(status stripesdk.DisputeStatus) models.PaymentStatus { + switch status { + case stripesdk.DisputeStatusNeedsResponse, stripesdk.DisputeStatusUnderReview: + return models.PAYMENT_STATUS_DISPUTE + case stripesdk.DisputeStatusLost: + return models.PAYMENT_STATUS_DISPUTE_LOST + case stripesdk.DisputeStatusWon: + return models.PAYMENT_STATUS_DISPUTE_WON + default: + return models.PAYMENT_STATUS_DISPUTE + } +} + +func convertPayoutStatus(status stripesdk.PayoutStatus) models.PaymentStatus { + switch status { + case stripesdk.PayoutStatusCanceled: + return models.PAYMENT_STATUS_CANCELLED + case stripesdk.PayoutStatusFailed: + return models.PAYMENT_STATUS_FAILED + case stripesdk.PayoutStatusInTransit, stripesdk.PayoutStatusPending: + return models.PAYMENT_STATUS_PENDING + case stripesdk.PayoutStatusPaid: + return models.PAYMENT_STATUS_SUCCEEDED + } + + return models.PAYMENT_STATUS_OTHER +} + +func appendMetadata(s map[string]string, vs ...map[string]string) { + for _, in := range vs { + if in == nil { + continue + } + for k, v := range in { + s[k] = v + } + } +} + +func detailsToPaymentScheme(details *stripesdk.ChargePaymentMethodDetails) models.PaymentScheme { + if details == nil || details.Card == nil { + return models.PAYMENT_SCHEME_UNKNOWN + } + scheme, _ := models.PaymentSchemeFromString( + fmt.Sprintf("CARD_%s", strings.ToUpper(string(details.Card.Brand))), + ) + return scheme +} + +func destinationToPaymentScheme(dest *stripesdk.PayoutDestination) models.PaymentScheme { + if dest == nil || dest.Card == nil { + return models.PAYMENT_SCHEME_UNKNOWN + } + + switch dest.Card.Brand { + case stripesdk.CardBrandAmericanExpress: + return models.PAYMENT_SCHEME_CARD_AMEX + case stripesdk.CardBrandDiscover: + return models.PAYMENT_SCHEME_CARD_DISCOVER + case stripesdk.CardBrandDinersClub: + return models.PAYMENT_SCHEME_CARD_DINERS + case stripesdk.CardBrandJCB: + return models.PAYMENT_SCHEME_CARD_JCB + case stripesdk.CardBrandMasterCard: + return models.PAYMENT_SCHEME_CARD_MASTERCARD + case stripesdk.CardBrandUnionPay: + return models.PAYMENT_SCHEME_CARD_UNION_PAY + case stripesdk.CardBrandVisa: + return models.PAYMENT_SCHEME_CARD_VISA + default: + return models.PAYMENT_SCHEME_UNKNOWN + } +} diff --git a/internal/connectors/plugins/public/stripe/payments_test.go b/internal/connectors/plugins/public/stripe/payments_test.go new file mode 100644 index 00000000..536461aa --- /dev/null +++ b/internal/connectors/plugins/public/stripe/payments_test.go @@ -0,0 +1,292 @@ +package stripe + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + stripesdk "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetch next Payments", func() { + var ( + m *client.MockClient + + samplePayments []*stripesdk.BalanceTransaction + accRef string + pageSize int + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + pageSize = 15 + accRef = "baseAcc" + samplePayments = []*stripesdk.BalanceTransaction{ + { + ID: "charge", + Type: stripesdk.BalanceTransactionTypeCharge, + Source: &stripesdk.BalanceTransactionSource{ + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyBIF, + PaymentMethodDetails: &stripesdk.ChargePaymentMethodDetails{Card: &stripesdk.ChargePaymentMethodDetailsCard{Brand: stripesdk.PaymentMethodCardBrandVisa}}, + }, + }, + }, + { + ID: "refund", + Type: stripesdk.BalanceTransactionTypeRefund, + Source: &stripesdk.BalanceTransactionSource{ + Refund: &stripesdk.Refund{ + Currency: stripesdk.CurrencyEUR, + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyGBP, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "refund_original"}, + PaymentMethodDetails: &stripesdk.ChargePaymentMethodDetails{Card: &stripesdk.ChargePaymentMethodDetailsCard{Brand: stripesdk.PaymentMethodCardBrandJCB}}, + }, + }, + }, + }, + { + ID: "refund_failure", + Type: stripesdk.BalanceTransactionTypeRefundFailure, + Source: &stripesdk.BalanceTransactionSource{ + Refund: &stripesdk.Refund{ + Currency: stripesdk.CurrencyGEL, + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyGIP, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "refund_failure_original"}, + PaymentMethodDetails: &stripesdk.ChargePaymentMethodDetails{Card: &stripesdk.ChargePaymentMethodDetailsCard{Brand: stripesdk.PaymentMethodCardBrandAmex}}, + }, + }, + }, + }, + { + ID: "payment", + Type: stripesdk.BalanceTransactionTypePayment, + Source: &stripesdk.BalanceTransactionSource{ + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyHKD, + }, + }, + }, + { + ID: "payment_refund", + Type: stripesdk.BalanceTransactionTypePaymentRefund, + Source: &stripesdk.BalanceTransactionSource{ + Refund: &stripesdk.Refund{ + Currency: stripesdk.CurrencyEUR, + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyGBP, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "payment_refund_original"}, + }, + }, + }, + }, + { + ID: "payment_refund_failure", + Type: stripesdk.BalanceTransactionTypePaymentFailureRefund, + Source: &stripesdk.BalanceTransactionSource{ + Refund: &stripesdk.Refund{ + Currency: stripesdk.CurrencyGEL, + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyGIP, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "payment_refund_failure_original"}, + }, + }, + }, + }, + { + ID: "payout", + Type: stripesdk.BalanceTransactionTypePayout, + Source: &stripesdk.BalanceTransactionSource{ + Payout: &stripesdk.Payout{ + Currency: stripesdk.CurrencyLKR, + Status: stripesdk.PayoutStatusPaid, + Destination: &stripesdk.PayoutDestination{Card: &stripesdk.Card{Brand: stripesdk.CardBrandJCB}}, + }, + }, + }, + { + ID: "payout_failure", + Type: stripesdk.BalanceTransactionTypePayoutFailure, + Source: &stripesdk.BalanceTransactionSource{ + Payout: &stripesdk.Payout{ + Currency: stripesdk.CurrencyMYR, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "payout_failure_original"}, + Destination: &stripesdk.PayoutDestination{Card: &stripesdk.Card{Brand: stripesdk.CardBrandUnionPay}}, + }, + }, + }, + { + ID: "transfer", + Type: stripesdk.BalanceTransactionTypeTransfer, + Source: &stripesdk.BalanceTransactionSource{ + Transfer: &stripesdk.Transfer{ + Currency: stripesdk.CurrencyCLP, + }, + }, + }, + { + ID: "transfer_refund", + Type: stripesdk.BalanceTransactionTypeTransferRefund, + Source: &stripesdk.BalanceTransactionSource{ + Transfer: &stripesdk.Transfer{ + Currency: stripesdk.CurrencyCOP, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "transfer_refund_original"}, + }, + }, + }, + { + ID: "transfer_failed", + Type: stripesdk.BalanceTransactionTypeTransferFailure, + Source: &stripesdk.BalanceTransactionSource{ + Transfer: &stripesdk.Transfer{ + Currency: stripesdk.CurrencyCAD, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "transfer_failed_original"}, + }, + }, + }, + { + ID: "adjustment_with_dispute", + Type: stripesdk.BalanceTransactionTypeAdjustment, + Source: &stripesdk.BalanceTransactionSource{ + Dispute: &stripesdk.Dispute{ + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyDZD, + BalanceTransaction: &stripesdk.BalanceTransaction{ID: "adjustment_with_dispute_original"}, + }, + }, + }, + }, + { + ID: "skipped", // unsupported types are skipped + Type: stripesdk.BalanceTransactionTypeStripeFee, + Source: &stripesdk.BalanceTransactionSource{ + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyJPY, + }, + }, + }, + } + + }) + + It("fails when payments contain unsupported currencies", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + p := []*stripesdk.BalanceTransaction{ + { + ID: "someid", + Type: stripesdk.BalanceTransactionTypeCharge, + Source: &stripesdk.BalanceTransactionSource{ + Charge: &stripesdk.Charge{ + Currency: stripesdk.CurrencyEEK, + }, + }, + }, + } + m.EXPECT().GetPayments(gomock.Any(), accRef, gomock.Any(), int64(pageSize)).Return( + p, + client.Timeline{}, + true, + nil, + ) + res, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(ContainSubstring(ErrUnsupportedCurrency.Error()))) + Expect(res.HasMore).To(BeFalse()) + }) + + It("fetches payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + m.EXPECT().GetPayments(gomock.Any(), accRef, gomock.Any(), int64(pageSize)).Return( + samplePayments, + client.Timeline{LatestID: samplePayments[len(samplePayments)-1].ID}, + true, + nil, + ) + res, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payments).To(HaveLen(len(samplePayments) - 1)) + Expect(res.HasMore).To(BeTrue()) + + // Charges + Expect(res.Payments[0].Reference).To(Equal(samplePayments[0].ID)) + Expect(res.Payments[0].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[0].Status).To(Equal(models.PAYMENT_STATUS_SUCCEEDED)) + Expect(res.Payments[1].Reference).To(Equal(samplePayments[1].ID)) + Expect(res.Payments[1].ParentReference).To(Equal(samplePayments[1].Source.Refund.Charge.BalanceTransaction.ID)) + Expect(res.Payments[1].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[1].Status).To(Equal(models.PAYMENT_STATUS_REFUNDED)) + Expect(res.Payments[2].Reference).To(Equal(samplePayments[2].ID)) + Expect(res.Payments[2].ParentReference).To(Equal(samplePayments[2].Source.Refund.Charge.BalanceTransaction.ID)) + Expect(res.Payments[2].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[2].Status).To(Equal(models.PAYMENT_STATUS_REFUNDED_FAILURE)) + // Payments + Expect(res.Payments[3].Reference).To(Equal(samplePayments[3].ID)) + Expect(res.Payments[3].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[3].Status).To(Equal(models.PAYMENT_STATUS_SUCCEEDED)) + Expect(res.Payments[4].Reference).To(Equal(samplePayments[4].ID)) + Expect(res.Payments[4].ParentReference).To(Equal(samplePayments[4].Source.Refund.Charge.BalanceTransaction.ID)) + Expect(res.Payments[4].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[4].Status).To(Equal(models.PAYMENT_STATUS_REFUNDED)) + Expect(res.Payments[5].Reference).To(Equal(samplePayments[5].ID)) + Expect(res.Payments[5].ParentReference).To(Equal(samplePayments[5].Source.Refund.Charge.BalanceTransaction.ID)) + Expect(res.Payments[5].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[5].Status).To(Equal(models.PAYMENT_STATUS_REFUNDED_FAILURE)) + // Payouts + Expect(res.Payments[6].Reference).To(Equal(samplePayments[6].ID)) + Expect(res.Payments[6].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT)) + Expect(res.Payments[6].Status).To(Equal(models.PAYMENT_STATUS_SUCCEEDED)) + Expect(res.Payments[7].Reference).To(Equal(samplePayments[7].ID)) + Expect(res.Payments[7].ParentReference).To(Equal(samplePayments[7].Source.Payout.BalanceTransaction.ID)) + Expect(res.Payments[7].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT)) + Expect(res.Payments[7].Status).To(Equal(models.PAYMENT_STATUS_FAILED)) + // Transfers + Expect(res.Payments[8].Reference).To(Equal(samplePayments[8].ID)) + Expect(res.Payments[8].Type).To(Equal(models.PAYMENT_TYPE_TRANSFER)) + Expect(res.Payments[8].Status).To(Equal(models.PAYMENT_STATUS_SUCCEEDED)) + Expect(res.Payments[9].Reference).To(Equal(samplePayments[9].ID)) + Expect(res.Payments[9].ParentReference).To(Equal(samplePayments[9].Source.Transfer.BalanceTransaction.ID)) + Expect(res.Payments[9].Type).To(Equal(models.PAYMENT_TYPE_TRANSFER)) + Expect(res.Payments[9].Status).To(Equal(models.PAYMENT_STATUS_REFUNDED)) + Expect(res.Payments[10].Reference).To(Equal(samplePayments[10].ID)) + Expect(res.Payments[10].ParentReference).To(Equal(samplePayments[10].Source.Transfer.BalanceTransaction.ID)) + Expect(res.Payments[10].Type).To(Equal(models.PAYMENT_TYPE_TRANSFER)) + Expect(res.Payments[10].Status).To(Equal(models.PAYMENT_STATUS_FAILED)) + // Adjustments + Expect(res.Payments[11].Reference).To(Equal(samplePayments[11].ID)) + Expect(res.Payments[11].ParentReference).To(Equal(samplePayments[11].Source.Dispute.Charge.BalanceTransaction.ID)) + Expect(res.Payments[11].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + Expect(res.Payments[11].Status).To(Equal(models.PAYMENT_STATUS_DISPUTE)) + + var state paymentsState + + err = json.Unmarshal(res.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.Timeline.LatestID).To(Equal(samplePayments[len(samplePayments)-1].ID)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/plugin.go b/internal/connectors/plugins/public/stripe/plugin.go new file mode 100644 index 00000000..a56fefb5 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/plugin.go @@ -0,0 +1,153 @@ +package stripe + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("stripe", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +type Plugin struct { + name string + client client.Client +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New(nil, config.APIKey) + + return &Plugin{ + name: name, + client: client, + }, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { + return models.InstallResponse{ + Workflow: workflow(), + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + return models.UninstallResponse{}, nil +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + + return models.CreateTransferResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + if p.client == nil { + return models.ReverseTransferResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.reverseTransfer(ctx, req.PaymentInitiationReversal) + if err != nil { + return models.ReverseTransferResponse{}, err + } + + return models.ReverseTransferResponse{ + Payment: payment, + }, nil +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + + return models.CreatePayoutResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/stripe/plugin_test.go b/internal/connectors/plugins/public/stripe/plugin_test.go new file mode 100644 index 00000000..0f51108f --- /dev/null +++ b/internal/connectors/plugins/public/stripe/plugin_test.go @@ -0,0 +1,179 @@ +package stripe + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Stripe Plugin Suite") +} + +var _ = Describe("Stripe Plugin", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("install", func() { + It("should report errors in config - apiKey", func(ctx SpecContext) { + config := json.RawMessage(`{}`) + _, err := New("stripe", config) + Expect(err).To(MatchError("missing api key in config: invalid config")) + }) + + It("should return valid install response", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey": "test"}`) + _, err := New("stripe", config) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(ctx, req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow).To(Equal(workflow())) + }) + }) + + Context("uninstall", func() { + It("should return valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "test"} + resp, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.UninstallResponse{})) + }) + }) + + Context("fetch next accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in accounts_test.go + }) + + Context("fetch next balances", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in balances_test.go + }) + + Context("fetch next external accounts", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in external_accounts_test.go + }) + + Context("fetch next payments", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in payments_test.go + }) + + Context("fetch next others", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{State: json.RawMessage(`{}`)} + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create bank account", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateBankAccountRequest{} + _, err := plg.CreateBankAccount(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create transfer", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in create_transfers_test.go + }) + + Context("reverse transfer", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.ReverseTransferRequest{} + _, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + // Other tests will be in reverse_transfer_test.go + }) + + Context("poll transfer status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollTransferStatusRequest{} + _, err := plg.PollTransferStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create payout", func() { + It("should fail when called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + // Other tests will be in create_payouts_test.go + }) + + Context("reverse payout", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.ReversePayoutRequest{} + _, err := plg.ReversePayout(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("poll payout status", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.PollPayoutStatusRequest{} + _, err := plg.PollPayoutStatus(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("create webhooks", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) + + Context("translate webhook", func() { + It("should fail because not implemented", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/reverse_transfers.go b/internal/connectors/plugins/public/stripe/reverse_transfers.go new file mode 100644 index 00000000..a88de0d4 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/reverse_transfers.go @@ -0,0 +1,75 @@ +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/stripe/stripe-go/v79" +) + +const ( + transferIDMetadataKey = "com.stripe.spec/transfer_id" +) + +func validateReverseTransferRequest(pir models.PSPPaymentInitiationReversal) error { + _, ok := pir.Metadata[transferIDMetadataKey] + if !ok { + return fmt.Errorf("transfer id is required in metadata: %w", models.ErrInvalidRequest) + } + return nil +} +func (p *Plugin) reverseTransfer(ctx context.Context, pir models.PSPPaymentInitiationReversal) (models.PSPPayment, error) { + if err := validateReverseTransferRequest(pir); err != nil { + return models.PSPPayment{}, err + } + var account *string = nil + if pir.RelatedPaymentInitiation.SourceAccount != nil && pir.RelatedPaymentInitiation.SourceAccount.Reference != rootAccountReference { + account = &pir.RelatedPaymentInitiation.SourceAccount.Reference + } + resp, err := p.client.ReverseTransfer( + ctx, + client.ReverseTransferRequest{ + IdempotencyKey: pir.Reference, + StripeTransferID: pir.Metadata[transferIDMetadataKey], + Account: account, + Amount: pir.Amount.Int64(), + Description: pir.Description, + Metadata: pir.Metadata, + }, + ) + if err != nil { + return models.PSPPayment{}, err + } + payment, err := fromTransferReversalToPayment(resp, account, &pir.RelatedPaymentInitiation.DestinationAccount.Reference) + if err != nil { + return models.PSPPayment{}, err + } + return payment, nil +} +func fromTransferReversalToPayment(from *stripe.TransferReversal, source, destination *string) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + return models.PSPPayment{ + ParentReference: from.Transfer.BalanceTransaction.ID, + Reference: from.BalanceTransaction.ID, + CreatedAt: time.Unix(from.Created, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(from.Amount), + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, strings.ToUpper(string(from.Currency))), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + SourceAccountReference: source, + DestinationAccountReference: destination, + Metadata: from.Metadata, + Raw: raw, + }, nil +} diff --git a/internal/connectors/plugins/public/stripe/reverse_transfers_test.go b/internal/connectors/plugins/public/stripe/reverse_transfers_test.go new file mode 100644 index 00000000..869b5f3c --- /dev/null +++ b/internal/connectors/plugins/public/stripe/reverse_transfers_test.go @@ -0,0 +1,153 @@ +package stripe + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" + "github.com/stripe/stripe-go/v79" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Stripe Plugin Transfers Reversal", func() { + var ( + plg *Plugin + ) + BeforeEach(func() { + plg = &Plugin{} + }) + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiationReversal models.PSPPaymentInitiationReversal + now time.Time + ) + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + samplePSPPaymentInitiationReversal = models.PSPPaymentInitiationReversal{ + Reference: "test_reversal_1", + CreatedAt: now.UTC(), + Description: "test_reversal_1", + RelatedPaymentInitiation: models.PSPPaymentInitiation{ + Reference: uuid.New().String(), + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{"userID": "u1"}, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{"foo": "bar"}, + }, + Amount: big.NewInt(50), + Asset: "EUR/2", + Metadata: map[string]string{ + "com.stripe.spec/transfer_id": "acc_test", + }, + } + }) + + It("should return an error - validation error - missing metadata", func(ctx SpecContext) { + c := samplePSPPaymentInitiationReversal + delete(c.Metadata, "com.stripe.spec/transfer_id") + req := models.ReverseTransferRequest{ + PaymentInitiationReversal: samplePSPPaymentInitiationReversal, + } + resp, err := plg.ReverseTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("transfer id is required in metadata: invalid request")) + Expect(resp).To(Equal(models.ReverseTransferResponse{})) + }) + It("should return an error - reverse transfer error", func(ctx SpecContext) { + req := models.ReverseTransferRequest{ + PaymentInitiationReversal: samplePSPPaymentInitiationReversal, + } + m.EXPECT().ReverseTransfer(gomock.Any(), client.ReverseTransferRequest{ + IdempotencyKey: samplePSPPaymentInitiationReversal.Reference, + StripeTransferID: samplePSPPaymentInitiationReversal.Metadata["com.stripe.spec/transfer_id"], + Account: &samplePSPPaymentInitiationReversal.RelatedPaymentInitiation.SourceAccount.Reference, + Amount: samplePSPPaymentInitiationReversal.Amount.Int64(), + Description: samplePSPPaymentInitiationReversal.Description, + Metadata: samplePSPPaymentInitiationReversal.Metadata, + }).Return(nil, errors.New("test error")) + resp, err := plg.ReverseTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.ReverseTransferResponse{})) + }) + It("should be ok", func(ctx SpecContext) { + req := models.ReverseTransferRequest{ + PaymentInitiationReversal: samplePSPPaymentInitiationReversal, + } + trReversal := &stripe.TransferReversal{ + Amount: 100, + BalanceTransaction: &stripe.BalanceTransaction{ + ID: "bt2", + }, + Created: now.Unix(), + Currency: "eur", + ID: "tr1", + Metadata: samplePSPPaymentInitiationReversal.Metadata, + Transfer: &stripe.Transfer{ + Amount: 100, + BalanceTransaction: &stripe.BalanceTransaction{ + ID: "bt1", + }, + Created: now.Unix(), + Currency: "EUR", + Description: samplePSPPaymentInitiationReversal.RelatedPaymentInitiation.Description, + ID: "t1", + Metadata: samplePSPPaymentInitiationReversal.RelatedPaymentInitiation.Metadata, + }, + } + m.EXPECT().ReverseTransfer(gomock.Any(), client.ReverseTransferRequest{ + IdempotencyKey: samplePSPPaymentInitiationReversal.Reference, + StripeTransferID: samplePSPPaymentInitiationReversal.Metadata["com.stripe.spec/transfer_id"], + Account: &samplePSPPaymentInitiationReversal.RelatedPaymentInitiation.SourceAccount.Reference, + Amount: samplePSPPaymentInitiationReversal.Amount.Int64(), + Description: samplePSPPaymentInitiationReversal.Description, + Metadata: samplePSPPaymentInitiationReversal.Metadata, + }).Return(trReversal, nil) + raw, err := json.Marshal(&trReversal) + Expect(err).To(BeNil()) + resp, err := plg.ReverseTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.ReverseTransferResponse{ + Payment: models.PSPPayment{ + ParentReference: "bt1", + Reference: "bt2", + CreatedAt: time.Unix(trReversal.Created, 0), + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + SourceAccountReference: pointer.For("acc1"), + DestinationAccountReference: pointer.For("acc2"), + Metadata: samplePSPPaymentInitiationReversal.Metadata, + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/stripe/utils.go b/internal/connectors/plugins/public/stripe/utils.go new file mode 100644 index 00000000..99c43189 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/utils.go @@ -0,0 +1,15 @@ +package stripe + +import ( + "fmt" + + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validatePayoutTransferRequest(pi models.PSPPaymentInitiation) error { + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + return nil +} diff --git a/internal/connectors/plugins/public/stripe/workflow.go b/internal/connectors/plugins/public/stripe/workflow.go new file mode 100644 index 00000000..38f49020 --- /dev/null +++ b/internal/connectors/plugins/public/stripe/workflow.go @@ -0,0 +1,33 @@ +package stripe + +import "github.com/formancehq/payments/internal/models" + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_recipients", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + } +} diff --git a/internal/connectors/plugins/public/wise/accounts.go b/internal/connectors/plugins/public/wise/accounts.go new file mode 100644 index 00000000..e6976c8d --- /dev/null +++ b/internal/connectors/plugins/public/wise/accounts.go @@ -0,0 +1,86 @@ +package wise + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" +) + +type accountsState struct { + // Accounts are ordered by their ID + LastAccountID uint64 `json:"lastAccountID"` +} + +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + var oldState accountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextAccountsResponse{}, err + } + } + + var from client.Profile + if req.FromPayload == nil { + return models.FetchNextAccountsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextAccountsResponse{}, err + } + + newState := accountsState{ + LastAccountID: oldState.LastAccountID, + } + + var accounts []models.PSPAccount + hasMore := false + // Wise balances are considered as accounts on our side. + balances, err := p.client.GetBalances(ctx, from.ID) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + for _, balance := range balances { + if oldState.LastAccountID != 0 && balance.ID <= oldState.LastAccountID { + continue + } + + raw, err := json.Marshal(balance) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: strconv.FormatUint(balance.ID, 10), + CreatedAt: balance.CreationTime, + Name: &balance.Name, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Amount.Currency)), + Metadata: map[string]string{ + metadataProfileIDKey: strconv.FormatUint(from.ID, 10), + }, + Raw: raw, + }) + + newState.LastAccountID = balance.ID + + if len(accounts) >= req.PageSize { + hasMore = true + break + } + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextAccountsResponse{}, err + } + + return models.FetchNextAccountsResponse{ + Accounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} diff --git a/internal/connectors/plugins/public/wise/accounts_test.go b/internal/connectors/plugins/public/wise/accounts_test.go new file mode 100644 index 00000000..6eb5d558 --- /dev/null +++ b/internal/connectors/plugins/public/wise/accounts_test.go @@ -0,0 +1,166 @@ +package wise + +import ( + "encoding/json" + "errors" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "go.uber.org/mock/gomock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wise Plugin Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleBalances []client.Balance + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleBalances = make([]client.Balance, 0) + for i := 0; i < 50; i++ { + sampleBalances = append(sampleBalances, client.Balance{ + ID: uint64(i), + Currency: "USD", + Name: "test1", + Amount: client.BalanceAmount{ + Value: "100", + Currency: "USD", + }, + CreationTime: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + }) + } + }) + + It("should return an error - get accounts error", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetBalances(gomock.Any(), uint64(0)).Return( + []client.Balance{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextAccountsResponse{})) + }) + + It("should fetch next accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetBalances(gomock.Any(), uint64(0)).Return( + []client.Balance{}, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastAccountID).To(Equal(uint64(0))) + }) + + It("should fetch next accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetBalances(gomock.Any(), uint64(0)).Return( + sampleBalances, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastAccountID).To(Equal(uint64(49))) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + PageSize: 40, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetBalances(gomock.Any(), uint64(0)).Return( + sampleBalances[:40], + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastAccountID).To(Equal(uint64(39))) + }) + + It("should fetch next accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: []byte(`{"lastAccountID": 38}`), + PageSize: 40, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetBalances(gomock.Any(), uint64(0)).Return( + sampleBalances, + nil, + ) + + resp, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Accounts).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state accountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastAccountID).To(Equal(uint64(49))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/balances.go b/internal/connectors/plugins/public/wise/balances.go new file mode 100644 index 00000000..1e95dd1b --- /dev/null +++ b/internal/connectors/plugins/public/wise/balances.go @@ -0,0 +1,63 @@ +package wise + +import ( + "context" + "encoding/json" + "errors" + "strconv" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + var from models.PSPAccount + if req.FromPayload == nil { + return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balanceID, err := strconv.ParseUint(from.Reference, 10, 64) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + pID, ok := from.Metadata[metadataProfileIDKey] + if !ok { + return models.FetchNextBalancesResponse{}, errors.New("missing profile ID in from payload when fetching balances") + } + + profileID, err := strconv.ParseUint(pID, 10, 64) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + balance, err := p.client.GetBalance(ctx, profileID, balanceID) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + precision, ok := supportedCurrenciesWithDecimal[balance.Amount.Currency] + if !ok { + return models.FetchNextBalancesResponse{}, errors.New("unsupported currency") + } + + amount, err := currency.GetAmountWithPrecisionFromString(balance.Amount.Value.String(), precision) + if err != nil { + return models.FetchNextBalancesResponse{}, err + } + + return models.FetchNextBalancesResponse{ + Balances: []models.PSPBalance{ + { + AccountReference: from.Reference, + CreatedAt: balance.ModificationTime, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, balance.Amount.Currency), + }, + }, + HasMore: false, + }, nil +} diff --git a/internal/connectors/plugins/public/wise/balances_test.go b/internal/connectors/plugins/public/wise/balances_test.go new file mode 100644 index 00000000..a497ccfd --- /dev/null +++ b/internal/connectors/plugins/public/wise/balances_test.go @@ -0,0 +1,73 @@ +package wise + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "go.uber.org/mock/gomock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wise Plugin Balances", func() { + var ( + plg *Plugin + m *client.MockClient + ) + + BeforeEach(func() { + plg = &Plugin{} + + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.SetClient(m) + }) + + Context("fetch next balances", func() { + var ( + balance client.Balance + expectedProfileID uint64 + profileVal uint64 + ) + + BeforeEach(func() { + expectedProfileID = 123454 + profileVal = 999999 + balance = client.Balance{ + ID: 14556, + Type: "type1", + Amount: client.BalanceAmount{Value: json.Number("44.99"), Currency: "USD"}, + } + }) + + It("fetches balances from wise client", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + State: json.RawMessage(`{}`), + FromPayload: json.RawMessage(fmt.Sprintf( + `{"Reference":"%d","Metadata":{"%s":"%d"}}`, + expectedProfileID, + metadataProfileIDKey, + profileVal, + )), + PageSize: 10, + } + m.EXPECT().GetBalance(gomock.Any(), profileVal, expectedProfileID).Return( + &balance, + nil, + ) + + res, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(BeFalse()) + Expect(res.Balances).To(HaveLen(1)) // always returns 1 + Expect(res.Balances[0].AccountReference).To(Equal(fmt.Sprint(expectedProfileID))) + expectedBalance, err := balance.Amount.Value.Float64() + Expect(err).To(BeNil()) + Expect(res.Balances[0].Amount).To(BeEquivalentTo(big.NewInt(int64(expectedBalance * 100)))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/capabilities.go b/internal/connectors/plugins/public/wise/capabilities.go new file mode 100644 index 00000000..5ca19fd0 --- /dev/null +++ b/internal/connectors/plugins/public/wise/capabilities.go @@ -0,0 +1,17 @@ +package wise + +import "github.com/formancehq/payments/internal/models" + +var capabilities = []models.Capability{ + models.CAPABILITY_FETCH_ACCOUNTS, + models.CAPABILITY_FETCH_BALANCES, + models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS, + models.CAPABILITY_FETCH_PAYMENTS, + models.CAPABILITY_FETCH_OTHERS, + + models.CAPABILITY_CREATE_TRANSFER, + models.CAPABILITY_CREATE_PAYOUT, + + models.CAPABILITY_CREATE_WEBHOOKS, + models.CAPABILITY_TRANSLATE_WEBHOOKS, +} diff --git a/internal/connectors/plugins/public/wise/client/balances.go b/internal/connectors/plugins/public/wise/client/balances.go new file mode 100644 index 00000000..278ee941 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/balances.go @@ -0,0 +1,75 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type BalanceAmount struct { + Value json.Number `json:"value"` + Currency string `json:"currency"` +} + +type Balance struct { + ID uint64 `json:"id"` + Currency string `json:"currency"` + Type string `json:"type"` + Name string `json:"name"` + Amount BalanceAmount `json:"amount"` + ReservedAmount struct { + Value json.Number `json:"value"` + Currency string `json:"currency"` + } `json:"reservedAmount"` + CashAmount struct { + Value json.Number `json:"value"` + Currency string `json:"currency"` + } `json:"cashAmount"` + TotalWorth struct { + Value json.Number `json:"value"` + Currency string `json:"currency"` + } `json:"totalWorth"` + CreationTime time.Time `json:"creationTime"` + ModificationTime time.Time `json:"modificationTime"` + Visible bool `json:"visible"` +} + +func (c *client) GetBalances(ctx context.Context, profileID uint64) ([]Balance, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_balances") + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint(fmt.Sprintf("v4/profiles/%d/balances?types=STANDARD", profileID)), http.NoBody) + if err != nil { + return nil, err + } + + var balances []Balance + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &balances, &errRes) + if err != nil { + return balances, fmt.Errorf("failed to get balances: %w %w", err, errRes.Error(statusCode).Error()) + } + return balances, nil +} + +func (c *client) GetBalance(ctx context.Context, profileID uint64, balanceID uint64) (*Balance, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_balance") + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint(fmt.Sprintf("v4/profiles/%d/balances/%d", profileID, balanceID)), http.NoBody) + if err != nil { + return nil, err + } + + var balance Balance + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &balance, &errRes) + if err != nil { + return &balance, fmt.Errorf("failed to get balance: %w %w", err, errRes.Error(statusCode).Error()) + } + return &balance, nil +} diff --git a/internal/connectors/plugins/public/wise/client/client.go b/internal/connectors/plugins/public/wise/client/client.go new file mode 100644 index 00000000..b54c72b0 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/client.go @@ -0,0 +1,74 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + lru "github.com/hashicorp/golang-lru/v2" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +const apiEndpoint = "https://api.wise.com" + +type apiTransport struct { + APIKey string + underlying http.RoundTripper +} + +func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", t.APIKey)) + + return t.underlying.RoundTrip(req) +} + +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetBalance(ctx context.Context, profileID uint64, balanceID uint64) (*Balance, error) + GetBalances(ctx context.Context, profileID uint64) ([]Balance, error) + GetPayout(ctx context.Context, payoutID string) (*Payout, error) + CreatePayout(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Payout, error) + GetProfiles(ctx context.Context) ([]Profile, error) + CreateQuote(ctx context.Context, profileID, currency string, amount json.Number) (Quote, error) + GetRecipientAccounts(ctx context.Context, profileID uint64, pageSize int, seekPositionForNext uint64) (*RecipientAccountsResponse, error) + GetRecipientAccount(ctx context.Context, accountID uint64) (*RecipientAccount, error) + GetTransfers(ctx context.Context, profileID uint64, offset int, limit int) ([]Transfer, error) + GetTransfer(ctx context.Context, transferID string) (*Transfer, error) + CreateTransfer(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Transfer, error) + CreateWebhook(ctx context.Context, profileID uint64, name, triggerOn, url, version string) (*WebhookSubscriptionResponse, error) + ListWebhooksSubscription(ctx context.Context, profileID uint64) ([]WebhookSubscriptionResponse, error) + DeleteWebhooks(ctx context.Context, profileID uint64, subscriptionID string) error + TranslateTransferStateChangedWebhook(ctx context.Context, payload []byte) (Transfer, error) + TranslateBalanceUpdateWebhook(ctx context.Context, payload []byte) (BalanceUpdateWebhookPayload, error) +} + +type client struct { + httpClient httpwrapper.Client + + mux *sync.Mutex + recipientAccountsCache *lru.Cache[uint64, *RecipientAccount] +} + +func (c *client) endpoint(path string) string { + return fmt.Sprintf("%s/%s", apiEndpoint, path) +} + +func New(apiKey string) Client { + recipientsCache, _ := lru.New[uint64, *RecipientAccount](2048) + config := &httpwrapper.Config{ + CommonMetricsAttributes: httpwrapper.CommonMetricsAttributesFor("wise"), + Transport: &apiTransport{ + APIKey: apiKey, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + } + + return &client{ + httpClient: httpwrapper.NewClient(config), + mux: &sync.Mutex{}, + recipientAccountsCache: recipientsCache, + } +} diff --git a/internal/connectors/plugins/public/wise/client/client_generated.go b/internal/connectors/plugins/public/wise/client/client_generated.go new file mode 100644 index 00000000..6fe8d932 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/client_generated.go @@ -0,0 +1,281 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + json "encoding/json" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreatePayout mocks base method. +func (m *MockClient) CreatePayout(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Payout, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePayout", ctx, quote, targetAccount, transactionID) + ret0, _ := ret[0].(*Payout) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePayout indicates an expected call of CreatePayout. +func (mr *MockClientMockRecorder) CreatePayout(ctx, quote, targetAccount, transactionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePayout", reflect.TypeOf((*MockClient)(nil).CreatePayout), ctx, quote, targetAccount, transactionID) +} + +// CreateQuote mocks base method. +func (m *MockClient) CreateQuote(ctx context.Context, profileID, currency string, amount json.Number) (Quote, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateQuote", ctx, profileID, currency, amount) + ret0, _ := ret[0].(Quote) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateQuote indicates an expected call of CreateQuote. +func (mr *MockClientMockRecorder) CreateQuote(ctx, profileID, currency, amount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateQuote", reflect.TypeOf((*MockClient)(nil).CreateQuote), ctx, profileID, currency, amount) +} + +// CreateTransfer mocks base method. +func (m *MockClient) CreateTransfer(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTransfer", ctx, quote, targetAccount, transactionID) + ret0, _ := ret[0].(*Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTransfer indicates an expected call of CreateTransfer. +func (mr *MockClientMockRecorder) CreateTransfer(ctx, quote, targetAccount, transactionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransfer", reflect.TypeOf((*MockClient)(nil).CreateTransfer), ctx, quote, targetAccount, transactionID) +} + +// CreateWebhook mocks base method. +func (m *MockClient) CreateWebhook(ctx context.Context, profileID uint64, name, triggerOn, url, version string) (*WebhookSubscriptionResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateWebhook", ctx, profileID, name, triggerOn, url, version) + ret0, _ := ret[0].(*WebhookSubscriptionResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateWebhook indicates an expected call of CreateWebhook. +func (mr *MockClientMockRecorder) CreateWebhook(ctx, profileID, name, triggerOn, url, version any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWebhook", reflect.TypeOf((*MockClient)(nil).CreateWebhook), ctx, profileID, name, triggerOn, url, version) +} + +// DeleteWebhooks mocks base method. +func (m *MockClient) DeleteWebhooks(ctx context.Context, profileID uint64, subscriptionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWebhooks", ctx, profileID, subscriptionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWebhooks indicates an expected call of DeleteWebhooks. +func (mr *MockClientMockRecorder) DeleteWebhooks(ctx, profileID, subscriptionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebhooks", reflect.TypeOf((*MockClient)(nil).DeleteWebhooks), ctx, profileID, subscriptionID) +} + +// GetBalance mocks base method. +func (m *MockClient) GetBalance(ctx context.Context, profileID, balanceID uint64) (*Balance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBalance", ctx, profileID, balanceID) + ret0, _ := ret[0].(*Balance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBalance indicates an expected call of GetBalance. +func (mr *MockClientMockRecorder) GetBalance(ctx, profileID, balanceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBalance", reflect.TypeOf((*MockClient)(nil).GetBalance), ctx, profileID, balanceID) +} + +// GetBalances mocks base method. +func (m *MockClient) GetBalances(ctx context.Context, profileID uint64) ([]Balance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBalances", ctx, profileID) + ret0, _ := ret[0].([]Balance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBalances indicates an expected call of GetBalances. +func (mr *MockClientMockRecorder) GetBalances(ctx, profileID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBalances", reflect.TypeOf((*MockClient)(nil).GetBalances), ctx, profileID) +} + +// GetPayout mocks base method. +func (m *MockClient) GetPayout(ctx context.Context, payoutID string) (*Payout, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayout", ctx, payoutID) + ret0, _ := ret[0].(*Payout) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayout indicates an expected call of GetPayout. +func (mr *MockClientMockRecorder) GetPayout(ctx, payoutID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayout", reflect.TypeOf((*MockClient)(nil).GetPayout), ctx, payoutID) +} + +// GetProfiles mocks base method. +func (m *MockClient) GetProfiles(ctx context.Context) ([]Profile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProfiles", ctx) + ret0, _ := ret[0].([]Profile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProfiles indicates an expected call of GetProfiles. +func (mr *MockClientMockRecorder) GetProfiles(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfiles", reflect.TypeOf((*MockClient)(nil).GetProfiles), ctx) +} + +// GetRecipientAccount mocks base method. +func (m *MockClient) GetRecipientAccount(ctx context.Context, accountID uint64) (*RecipientAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRecipientAccount", ctx, accountID) + ret0, _ := ret[0].(*RecipientAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRecipientAccount indicates an expected call of GetRecipientAccount. +func (mr *MockClientMockRecorder) GetRecipientAccount(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecipientAccount", reflect.TypeOf((*MockClient)(nil).GetRecipientAccount), ctx, accountID) +} + +// GetRecipientAccounts mocks base method. +func (m *MockClient) GetRecipientAccounts(ctx context.Context, profileID uint64, pageSize int, seekPositionForNext uint64) (*RecipientAccountsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRecipientAccounts", ctx, profileID, pageSize, seekPositionForNext) + ret0, _ := ret[0].(*RecipientAccountsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRecipientAccounts indicates an expected call of GetRecipientAccounts. +func (mr *MockClientMockRecorder) GetRecipientAccounts(ctx, profileID, pageSize, seekPositionForNext any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecipientAccounts", reflect.TypeOf((*MockClient)(nil).GetRecipientAccounts), ctx, profileID, pageSize, seekPositionForNext) +} + +// GetTransfer mocks base method. +func (m *MockClient) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransfer", ctx, transferID) + ret0, _ := ret[0].(*Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransfer indicates an expected call of GetTransfer. +func (mr *MockClientMockRecorder) GetTransfer(ctx, transferID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransfer", reflect.TypeOf((*MockClient)(nil).GetTransfer), ctx, transferID) +} + +// GetTransfers mocks base method. +func (m *MockClient) GetTransfers(ctx context.Context, profileID uint64, offset, limit int) ([]Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransfers", ctx, profileID, offset, limit) + ret0, _ := ret[0].([]Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransfers indicates an expected call of GetTransfers. +func (mr *MockClientMockRecorder) GetTransfers(ctx, profileID, offset, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransfers", reflect.TypeOf((*MockClient)(nil).GetTransfers), ctx, profileID, offset, limit) +} + +// ListWebhooksSubscription mocks base method. +func (m *MockClient) ListWebhooksSubscription(ctx context.Context, profileID uint64) ([]WebhookSubscriptionResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListWebhooksSubscription", ctx, profileID) + ret0, _ := ret[0].([]WebhookSubscriptionResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListWebhooksSubscription indicates an expected call of ListWebhooksSubscription. +func (mr *MockClientMockRecorder) ListWebhooksSubscription(ctx, profileID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWebhooksSubscription", reflect.TypeOf((*MockClient)(nil).ListWebhooksSubscription), ctx, profileID) +} + +// TranslateBalanceUpdateWebhook mocks base method. +func (m *MockClient) TranslateBalanceUpdateWebhook(ctx context.Context, payload []byte) (BalanceUpdateWebhookPayload, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TranslateBalanceUpdateWebhook", ctx, payload) + ret0, _ := ret[0].(BalanceUpdateWebhookPayload) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TranslateBalanceUpdateWebhook indicates an expected call of TranslateBalanceUpdateWebhook. +func (mr *MockClientMockRecorder) TranslateBalanceUpdateWebhook(ctx, payload any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TranslateBalanceUpdateWebhook", reflect.TypeOf((*MockClient)(nil).TranslateBalanceUpdateWebhook), ctx, payload) +} + +// TranslateTransferStateChangedWebhook mocks base method. +func (m *MockClient) TranslateTransferStateChangedWebhook(ctx context.Context, payload []byte) (Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TranslateTransferStateChangedWebhook", ctx, payload) + ret0, _ := ret[0].(Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TranslateTransferStateChangedWebhook indicates an expected call of TranslateTransferStateChangedWebhook. +func (mr *MockClientMockRecorder) TranslateTransferStateChangedWebhook(ctx, payload any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TranslateTransferStateChangedWebhook", reflect.TypeOf((*MockClient)(nil).TranslateTransferStateChangedWebhook), ctx, payload) +} diff --git a/internal/connectors/plugins/public/wise/client/error.go b/internal/connectors/plugins/public/wise/client/error.go new file mode 100644 index 00000000..61d3b471 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/error.go @@ -0,0 +1,31 @@ +package client + +import ( + "fmt" +) + +type wiseErrors struct { + Errors []*wiseError `json:"errors"` +} + +func (we *wiseErrors) Error(statusCode int) *wiseError { + if len(we.Errors) == 0 { + return &wiseError{StatusCode: statusCode} + } + we.Errors[0].StatusCode = statusCode + return we.Errors[0] +} + +type wiseError struct { + StatusCode int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (me *wiseError) Error() error { + if me.Message == "" { + return fmt.Errorf("unexpected status code: %d", me.StatusCode) + } + + return fmt.Errorf("%s: %s", me.Code, me.Message) +} diff --git a/internal/connectors/plugins/public/wise/client/payouts.go b/internal/connectors/plugins/public/wise/client/payouts.go new file mode 100644 index 00000000..374c66d7 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/payouts.go @@ -0,0 +1,107 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Payout struct { + ID uint64 `json:"id"` + Reference string `json:"reference"` + Status string `json:"status"` + SourceAccount uint64 `json:"sourceAccount"` + SourceCurrency string `json:"sourceCurrency"` + SourceValue json.Number `json:"sourceValue"` + TargetAccount uint64 `json:"targetAccount"` + TargetCurrency string `json:"targetCurrency"` + TargetValue json.Number `json:"targetValue"` + Business uint64 `json:"business"` + Created string `json:"created"` + //nolint:tagliatelle // allow for clients + CustomerTransactionID string `json:"customerTransactionId"` + Details struct { + Reference string `json:"reference"` + } `json:"details"` + Rate float64 `json:"rate"` + User uint64 `json:"user"` + + SourceBalanceID uint64 `json:"-"` + DestinationBalanceID uint64 `json:"-"` + + CreatedAt time.Time `json:"-"` +} + +func (t *Payout) UnmarshalJSON(data []byte) error { + type Alias Transfer + + aux := &struct { + Created string `json:"created"` + *Alias + }{ + Alias: (*Alias)(t), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + var err error + + t.CreatedAt, err = time.Parse("2006-01-02 15:04:05", aux.Created) + if err != nil { + return fmt.Errorf("failed to parse created time: %w", err) + } + + return nil +} + +func (c *client) GetPayout(ctx context.Context, payoutID string) (*Payout, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_payout") + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint("v1/transfers/"+payoutID), http.NoBody) + if err != nil { + return nil, err + } + + var payout Payout + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &payout, &errRes) + if err != nil { + return &payout, fmt.Errorf("failed to get payout: %w %w", err, errRes.Error(statusCode).Error()) + } + return &payout, nil +} + +func (c *client) CreatePayout(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Payout, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_payout") + + reqBody, err := json.Marshal(map[string]interface{}{ + "targetAccount": targetAccount, + "quoteUuid": quote.ID.String(), + "customerTransactionId": transactionID, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("v1/transfers"), bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + var payout Payout + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &payout, &errRes) + if err != nil { + return &payout, fmt.Errorf("failed to make payout: %w %w", err, errRes.Error(statusCode).Error()) + } + return &payout, nil +} diff --git a/internal/connectors/plugins/public/wise/client/profiles.go b/internal/connectors/plugins/public/wise/client/profiles.go new file mode 100644 index 00000000..0aa32b24 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/profiles.go @@ -0,0 +1,31 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Profile struct { + ID uint64 `json:"id"` + Type string `json:"type"` +} + +func (c *client) GetProfiles(ctx context.Context) ([]Profile, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_profiles") + + var profiles []Profile + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("v2/profiles"), http.NoBody) + if err != nil { + return profiles, err + } + + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &profiles, &errRes) + if err != nil { + return profiles, fmt.Errorf("failed to make profiles: %w %w", err, errRes.Error(statusCode).Error()) + } + return profiles, nil +} diff --git a/internal/connectors/plugins/public/wise/client/quotes.go b/internal/connectors/plugins/public/wise/client/quotes.go new file mode 100644 index 00000000..c011c846 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/quotes.go @@ -0,0 +1,49 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/google/uuid" +) + +type Quote struct { + ID uuid.UUID `json:"id"` +} + +func (c *client) CreateQuote(ctx context.Context, profileID, currency string, amount json.Number) (Quote, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "create_quote") + + var quote Quote + + reqBody, err := json.Marshal(map[string]interface{}{ + "sourceCurrency": currency, + "targetCurrency": currency, + "sourceAmount": amount, + }) + if err != nil { + return quote, err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.endpoint("v3/profiles/"+profileID+"/quotes"), + bytes.NewBuffer(reqBody), + ) + if err != nil { + return quote, err + } + req.Header.Set("Content-Type", "application/json") + + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, "e, &errRes) + if err != nil { + return quote, fmt.Errorf("failed to get response from quote: %w %w", err, errRes.Error(statusCode).Error()) + } + return quote, nil +} diff --git a/internal/connectors/plugins/public/wise/client/recipient_accounts.go b/internal/connectors/plugins/public/wise/client/recipient_accounts.go new file mode 100644 index 00000000..89d7fa3d --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/recipient_accounts.go @@ -0,0 +1,86 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type RecipientAccountsResponse struct { + Content []*RecipientAccount `json:"content"` + SeekPositionForCurrent uint64 `json:"seekPositionForCurrent"` + SeekPositionForNext uint64 `json:"seekPositionForNext"` + Size int `json:"size"` +} + +type Name struct { + FullName string `json:"fullName"` +} + +type RecipientAccount struct { + ID uint64 `json:"id"` + Profile uint64 `json:"profileId"` + Currency string `json:"currency"` + Name Name `json:"name"` +} + +func (c *client) GetRecipientAccounts(ctx context.Context, profileID uint64, pageSize int, seekPositionForNext uint64) (*RecipientAccountsResponse, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_recipient_accounts") + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint("v2/accounts"), http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("profile", fmt.Sprintf("%d", profileID)) + q.Add("size", fmt.Sprintf("%d", pageSize)) + q.Add("sort", "id,asc") + if seekPositionForNext > 0 { + q.Add("seekPosition", fmt.Sprintf("%d", seekPositionForNext)) + } + req.URL.RawQuery = q.Encode() + + var accounts RecipientAccountsResponse + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &accounts, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get recipient accounts: %w %w", err, errRes.Error(statusCode).Error()) + } + return &accounts, nil +} + +func (c *client) GetRecipientAccount(ctx context.Context, accountID uint64) (*RecipientAccount, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_recipient_accounts") + + c.mux.Lock() + defer c.mux.Unlock() + if rc, ok := c.recipientAccountsCache.Get(accountID); ok { + return rc, nil + } + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint(fmt.Sprintf("v1/accounts/%d", accountID)), http.NoBody) + if err != nil { + return nil, err + } + + var res RecipientAccount + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + e := errRes.Error(statusCode) + if e.Code == "RECIPIENT_MISSING" { + // This is a valid response, we just don't have the account amongst + // our recipients. + return &RecipientAccount{}, nil + } + return nil, fmt.Errorf("failed to get recipient account: %w %w", err, e.Error()) + } + + c.recipientAccountsCache.Add(accountID, &res) + return &res, nil +} diff --git a/internal/connectors/plugins/public/wise/client/transfers.go b/internal/connectors/plugins/public/wise/client/transfers.go new file mode 100644 index 00000000..3d0f7b13 --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/transfers.go @@ -0,0 +1,201 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" +) + +type Transfer struct { + ID uint64 `json:"id"` + Reference string `json:"reference"` + Status string `json:"status"` + SourceAccount uint64 `json:"sourceAccount"` + SourceCurrency string `json:"sourceCurrency"` + SourceValue json.Number `json:"sourceValue"` + TargetAccount uint64 `json:"targetAccount"` + TargetCurrency string `json:"targetCurrency"` + TargetValue json.Number `json:"targetValue"` + Business uint64 `json:"business"` + Created string `json:"created"` + //nolint:tagliatelle // allow for clients + CustomerTransactionID string `json:"customerTransactionId"` + Details struct { + Reference string `json:"reference"` + } `json:"details"` + Rate float64 `json:"rate"` + User uint64 `json:"user"` + + SourceBalanceID uint64 `json:"-"` + DestinationBalanceID uint64 `json:"-"` + + CreatedAt time.Time `json:"-"` +} + +func (t *Transfer) UnmarshalJSON(data []byte) error { + type Alias Transfer + + aux := &struct { + Created string `json:"created"` + *Alias + }{ + Alias: (*Alias)(t), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + var err error + + t.CreatedAt, err = time.Parse("2006-01-02 15:04:05", aux.Created) + if err != nil { + return fmt.Errorf("failed to parse created time: %w", err) + } + + return nil +} + +func (c *client) GetTransfers(ctx context.Context, profileID uint64, offset int, limit int) ([]Transfer, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "list_transfers") + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint("v1/transfers"), http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("limit", fmt.Sprintf("%d", limit)) + q.Add("profile", fmt.Sprintf("%d", profileID)) + q.Add("offset", fmt.Sprintf("%d", offset)) + req.URL.RawQuery = q.Encode() + + var transfers []Transfer + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &transfers, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get transfers: %w %w", err, errRes.Error(statusCode).Error()) + } + + for i, transfer := range transfers { + var sourceProfileID, targetProfileID uint64 + if transfer.SourceAccount != 0 { + recipientAccount, err := c.GetRecipientAccount(ctx, transfer.SourceAccount) + if err != nil { + return nil, fmt.Errorf("failed to get source profile id: %w", err) + } + + sourceProfileID = recipientAccount.Profile + } + + if transfer.TargetAccount != 0 { + recipientAccount, err := c.GetRecipientAccount(ctx, transfer.TargetAccount) + if err != nil { + return nil, fmt.Errorf("failed to get target profile id: %w", err) + } + + targetProfileID = recipientAccount.Profile + } + + // TODO(polo): fetching balances for each transfer is not efficient + // and can be quite long. We should consider caching balances, but + // at the same time we will develop a feature soon to get balances + // for every accounts, so caching is not a solution. + switch { + case sourceProfileID == 0 && targetProfileID == 0: + // Do nothing + case sourceProfileID == targetProfileID && sourceProfileID != 0: + // Same profile id for target and source + balances, err := c.GetBalances(ctx, sourceProfileID) + if err != nil { + return nil, fmt.Errorf("failed to get balances: %w", err) + } + for _, balance := range balances { + if balance.Currency == transfer.SourceCurrency { + transfers[i].SourceBalanceID = balance.ID + } + + if balance.Currency == transfer.TargetCurrency { + transfers[i].DestinationBalanceID = balance.ID + } + } + default: + if sourceProfileID != 0 { + balances, err := c.GetBalances(ctx, sourceProfileID) + if err != nil { + return nil, fmt.Errorf("failed to get balances: %w", err) + } + for _, balance := range balances { + if balance.Currency == transfer.SourceCurrency { + transfers[i].SourceBalanceID = balance.ID + } + } + } + + if targetProfileID != 0 { + balances, err := c.GetBalances(ctx, targetProfileID) + if err != nil { + return nil, fmt.Errorf("failed to get balances: %w", err) + } + for _, balance := range balances { + if balance.Currency == transfer.TargetCurrency { + transfers[i].DestinationBalanceID = balance.ID + } + } + } + + } + } + return transfers, nil +} + +func (c *client) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "get_transfer") + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint("v1/transfers/"+transferID), http.NoBody) + if err != nil { + return nil, err + } + + var transfer Transfer + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &transfer, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get transfer: %w %w", err, errRes.Error(statusCode).Error()) + } + return &transfer, nil +} + +func (c *client) CreateTransfer(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Transfer, error) { + ctx = context.WithValue(ctx, httpwrapper.MetricOperationContextKey, "initiate_transfer") + + reqBody, err := json.Marshal(map[string]interface{}{ + "targetAccount": targetAccount, + "quoteUuid": quote.ID.String(), + "customerTransactionId": transactionID, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, + http.MethodPost, c.endpoint("v1/transfers"), bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + + var transfer Transfer + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &transfer, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to create transfer: %w %w", err, errRes.Error(statusCode).Error()) + } + return &transfer, nil +} diff --git a/internal/connectors/plugins/public/wise/client/webhooks.go b/internal/connectors/plugins/public/wise/client/webhooks.go new file mode 100644 index 00000000..18ac063e --- /dev/null +++ b/internal/connectors/plugins/public/wise/client/webhooks.go @@ -0,0 +1,175 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type WebhookDelivery struct { + Version string `json:"version"` + URL string `json:"url"` +} + +type webhookSubscription struct { + Name string `json:"name"` + TriggerOn string `json:"trigger_on"` + Delivery WebhookDelivery `json:"delivery"` +} + +type WebhookSubscriptionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Delivery WebhookDelivery `json:"delivery"` + TriggerOn string `json:"trigger_on"` + Scope struct { + Domain string `json:"domain"` + } `json:"scope"` + CreatedBy struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"created_by"` + CreatedAt string `json:"created_at"` +} + +func (c *client) CreateWebhook(ctx context.Context, profileID uint64, name, triggerOn, url, version string) (*WebhookSubscriptionResponse, error) { + reqBody, err := json.Marshal(webhookSubscription{ + Name: name, + TriggerOn: triggerOn, + Delivery: struct { + Version string `json:"version"` + URL string `json:"url"` + }{ + Version: version, + URL: url, + }, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions", profileID)), + bytes.NewBuffer(reqBody), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + var res WebhookSubscriptionResponse + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to create subscription: %w %w", err, errRes.Error(statusCode).Error()) + } + return &res, nil +} + +func (c *client) ListWebhooksSubscription(ctx context.Context, profileID uint64) ([]WebhookSubscriptionResponse, error) { + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, c.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions", profileID)), http.NoBody) + if err != nil { + return nil, err + } + + var res []WebhookSubscriptionResponse + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, &res, &errRes) + if err != nil { + return nil, fmt.Errorf("failed to get subscription: %w %w", err, errRes.Error(statusCode).Error()) + } + return res, nil +} + +func (c *client) DeleteWebhooks(ctx context.Context, profileID uint64, subscriptionID string) error { + req, err := http.NewRequestWithContext(ctx, + http.MethodDelete, c.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions/%s", profileID, subscriptionID)), http.NoBody) + if err != nil { + return err + } + + var errRes wiseErrors + statusCode, err := c.httpClient.Do(ctx, req, nil, &errRes) + if err != nil { + return fmt.Errorf("failed to delete webhooks: %w %w", err, errRes.Error(statusCode).Error()) + } + return nil +} + +type transferStateChangedWebhookPayload struct { + Data struct { + Resource struct { + Type string `json:"type"` + ID uint64 `json:"id"` + ProfileID uint64 `json:"profile_id"` + AccountID uint64 `json:"account_id"` + } `json:"resource"` + CurrentState string `json:"current_state"` + PreviousState string `json:"previous_state"` + OccurredAt string `json:"occurred_at"` + } `json:"data"` + SubscriptionID string `json:"subscription_id"` + EventType string `json:"event_type"` + SchemaVersion string `json:"schema_version"` + SentAt string `json:"sent_at"` +} + +func (c *client) TranslateTransferStateChangedWebhook(ctx context.Context, payload []byte) (Transfer, error) { + var transferStatedChangedEvent transferStateChangedWebhookPayload + err := json.Unmarshal(payload, &transferStatedChangedEvent) + if err != nil { + return Transfer{}, err + } + + transfer, err := c.GetTransfer(ctx, fmt.Sprint(transferStatedChangedEvent.Data.Resource.ID)) + if err != nil { + return Transfer{}, err + } + + transfer.Created = transferStatedChangedEvent.Data.OccurredAt + transfer.CreatedAt, err = time.Parse("2006-01-02 15:04:05", transfer.Created) + if err != nil { + return Transfer{}, fmt.Errorf("failed to parse created time: %w", err) + } + + return *transfer, nil +} + +type BalanceUpdateWebhookPayload struct { + Data BalanceUpdateWebhookData `json:"data"` + SubscriptionID string `json:"subscription_id"` + EventType string `json:"event_type"` + SchemaVersion string `json:"schema_version"` + SentAt string `json:"sent_at"` +} + +type BalanceUpdateWebhookData struct { + Resource BalanceUpdateWebhookResource `json:"resource"` + Amount json.Number `json:"amount"` + BalanceID uint64 `json:"balance_id"` + Currency string `json:"currency"` + TransactionType string `json:"transaction_type"` + OccurredAt string `json:"occurred_at"` + TransferReference string `json:"transfer_reference"` + ChannelName string `json:"channel_name"` +} + +type BalanceUpdateWebhookResource struct { + ID uint64 `json:"id"` + ProfileID uint64 `json:"profile_id"` + Type string `json:"type"` +} + +func (c *client) TranslateBalanceUpdateWebhook(ctx context.Context, payload []byte) (BalanceUpdateWebhookPayload, error) { + var balanceUpdateEvent BalanceUpdateWebhookPayload + err := json.Unmarshal(payload, &balanceUpdateEvent) + if err != nil { + return BalanceUpdateWebhookPayload{}, err + } + + return balanceUpdateEvent, nil +} diff --git a/internal/connectors/plugins/public/wise/config.go b/internal/connectors/plugins/public/wise/config.go new file mode 100644 index 00000000..5d26afac --- /dev/null +++ b/internal/connectors/plugins/public/wise/config.go @@ -0,0 +1,57 @@ +package wise + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" +) + +type Config struct { + APIKey string `json:"apiKey"` + WebhookPublicKey string `json:"webhookPublicKey"` + + webhookPublicKey *rsa.PublicKey `json:"-"` +} + +func (c *Config) validate() error { + if c.APIKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing api key in config") + } + + if c.WebhookPublicKey == "" { + return errors.Wrap(models.ErrInvalidConfig, "missing webhook public key in config") + } + + p, _ := pem.Decode([]byte(c.WebhookPublicKey)) + if p == nil { + return errors.Wrap(models.ErrInvalidConfig, "invalid webhook public key in config") + } + + publicKey, err := x509.ParsePKIXPublicKey(p.Bytes) + if err != nil { + return errors.Wrap(models.ErrInvalidConfig, fmt.Sprintf("invalid webhook public key in config: %v", err)) + } + + switch pub := publicKey.(type) { + case *rsa.PublicKey: + c.webhookPublicKey = pub + default: + return errors.Wrap(models.ErrInvalidConfig, "invalid webhook public key in config") + } + + return nil +} + +func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) { + var config Config + if err := json.Unmarshal(payload, &config); err != nil { + return Config{}, errors.Wrap(models.ErrInvalidConfig, err.Error()) + } + + return config, config.validate() +} diff --git a/internal/connectors/plugins/public/wise/config.json b/internal/connectors/plugins/public/wise/config.json new file mode 100644 index 00000000..0c049a96 --- /dev/null +++ b/internal/connectors/plugins/public/wise/config.json @@ -0,0 +1,12 @@ +{ + "apiKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + }, + "webhookPublicKey": { + "dataType": "string", + "required": true, + "defaultValue": "" + } +} \ No newline at end of file diff --git a/internal/connectors/plugins/public/wise/currencies.go b/internal/connectors/plugins/public/wise/currencies.go new file mode 100644 index 00000000..681dd86a --- /dev/null +++ b/internal/connectors/plugins/public/wise/currencies.go @@ -0,0 +1,33 @@ +package wise + +import "github.com/formancehq/payments/internal/connectors/plugins/currency" + +var ( + // c.f. https://wise.com/help/articles/2897238/which-currencies-can-i-add-keep-and-receive-in-my-wise-account + supportedCurrenciesWithDecimal = map[string]int{ + "AUD": currency.ISO4217Currencies["AUD"], // Australian dollar + "BGN": currency.ISO4217Currencies["BGN"], // Bulgarian lev + "BRL": currency.ISO4217Currencies["BRL"], // Brazilian real + "CAD": currency.ISO4217Currencies["CAD"], // Canadian dollar + "CNY": currency.ISO4217Currencies["CNY"], // Chinese yuan + "CHF": currency.ISO4217Currencies["CHF"], // Swiss franc + "CZK": currency.ISO4217Currencies["CZK"], // Czech koruna + "DKK": currency.ISO4217Currencies["DKK"], // Danish krone + "EUR": currency.ISO4217Currencies["EUR"], // Euro + "GBP": currency.ISO4217Currencies["GBP"], // Pound sterling + "IDR": currency.ISO4217Currencies["IDR"], // Indonesian rupiah + "JPY": currency.ISO4217Currencies["JPY"], // Japanese yen + "MYR": currency.ISO4217Currencies["MYR"], // Malaysian ringgit + "NOK": currency.ISO4217Currencies["NOK"], // Norwegian krone + "NZD": currency.ISO4217Currencies["NZD"], // New Zealand dollar + "PLN": currency.ISO4217Currencies["PLN"], // Polish złoty + "RON": currency.ISO4217Currencies["RON"], // Romanian leu + "SEK": currency.ISO4217Currencies["SEK"], // Swedish krona/kronor + "SGD": currency.ISO4217Currencies["SGD"], // Singapore dollar + "TRY": currency.ISO4217Currencies["TRY"], // Turkish lira + "USD": currency.ISO4217Currencies["USD"], // United States dollar + + // Unsupported currencies + // "HUF": currency.ISO4217Currencies["HUF"], // Hungarian forint + } +) diff --git a/internal/connectors/plugins/public/wise/external_accounts.go b/internal/connectors/plugins/public/wise/external_accounts.go new file mode 100644 index 00000000..6ef725ec --- /dev/null +++ b/internal/connectors/plugins/public/wise/external_accounts.go @@ -0,0 +1,109 @@ +package wise + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" +) + +type externalAccountsState struct { + LastSeekPosition uint64 `json:"lastSeekPosition"` +} + +func (p *Plugin) fetchExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + var oldState externalAccountsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + } + + var from client.Profile + if req.FromPayload == nil { + return models.FetchNextExternalAccountsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + newState := externalAccountsState{ + LastSeekPosition: oldState.LastSeekPosition, + } + + var accounts []models.PSPAccount + needMore := false + hasMore := false + lastSeekPosition := oldState.LastSeekPosition + for { + pagedExternalAccounts, err := p.client.GetRecipientAccounts(ctx, from.ID, req.PageSize, lastSeekPosition) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + accounts, err = fillExternalAccounts(pagedExternalAccounts, accounts, oldState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + lastSeekPosition = pagedExternalAccounts.SeekPositionForNext + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedExternalAccounts.Content, req.PageSize) + if !needMore || !hasMore { + break + } + } + + if !needMore { + accounts = accounts[:req.PageSize] + } + + if len(accounts) > 0 { + // No need to check the error, it's already checked in the fillExternalAccounts function + id, _ := strconv.ParseUint(accounts[len(accounts)-1].Reference, 10, 64) + newState.LastSeekPosition = id + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err + } + + return models.FetchNextExternalAccountsResponse{ + ExternalAccounts: accounts, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillExternalAccounts( + pagedExternalAccounts *client.RecipientAccountsResponse, + accounts []models.PSPAccount, + oldState externalAccountsState, +) ([]models.PSPAccount, error) { + for _, externalAccount := range pagedExternalAccounts.Content { + if oldState.LastSeekPosition != 0 && externalAccount.ID <= oldState.LastSeekPosition { + continue + } + + raw, err := json.Marshal(externalAccount) + if err != nil { + return nil, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: strconv.FormatUint(externalAccount.ID, 10), + CreatedAt: time.Now().UTC(), + Name: &externalAccount.Name.FullName, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, externalAccount.Currency)), + Raw: raw, + }) + } + + return accounts, nil +} diff --git a/internal/connectors/plugins/public/wise/external_accounts_test.go b/internal/connectors/plugins/public/wise/external_accounts_test.go new file mode 100644 index 00000000..1d19ce1d --- /dev/null +++ b/internal/connectors/plugins/public/wise/external_accounts_test.go @@ -0,0 +1,173 @@ +package wise + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "go.uber.org/mock/gomock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wise Plugin External Accounts", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next external accounts", func() { + var ( + m *client.MockClient + sampleRecipientAccounts []*client.RecipientAccount + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + + sampleRecipientAccounts = make([]*client.RecipientAccount, 0) + for i := 0; i < 50; i++ { + sampleRecipientAccounts = append(sampleRecipientAccounts, &client.RecipientAccount{ + ID: uint64(i), + Profile: uint64(0), + Currency: "USD", + Name: client.Name{ + FullName: fmt.Sprintf("Account %d", i), + }, + }) + } + }) + + It("should return an error - get beneficiaries error", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetRecipientAccounts(gomock.Any(), uint64(0), 60, uint64(0)).Return( + &client.RecipientAccountsResponse{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextExternalAccountsResponse{})) + }) + + It("should fetch next external accounts - no state no results", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetRecipientAccounts(gomock.Any(), uint64(0), 60, uint64(0)).Return( + &client.RecipientAccountsResponse{ + Content: []*client.RecipientAccount{}, + SeekPositionForNext: 0, + }, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastSeekPosition).To(Equal(uint64(0))) + }) + + It("should fetch next external accounts - no state pageSize > total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetRecipientAccounts(gomock.Any(), uint64(0), 60, uint64(0)).Return( + &client.RecipientAccountsResponse{ + Content: sampleRecipientAccounts, + SeekPositionForNext: 0, + }, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastSeekPosition).To(Equal(uint64(49))) + }) + + It("should fetch next accounts - no state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + PageSize: 40, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetRecipientAccounts(gomock.Any(), uint64(0), 40, uint64(0)).Return( + &client.RecipientAccountsResponse{ + Content: sampleRecipientAccounts[:40], + SeekPositionForNext: 39, + }, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastSeekPosition).To(Equal(uint64(39))) + }) + + It("should fetch next external accounts - with state pageSize < total accounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: []byte(fmt.Sprintf(`{"lastSeekPosition": %d}`, 38)), + PageSize: 40, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetRecipientAccounts(gomock.Any(), uint64(0), 40, uint64(38)).Return( + &client.RecipientAccountsResponse{ + Content: sampleRecipientAccounts[39:], + SeekPositionForNext: 49, + }, + nil, + ) + + resp, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.ExternalAccounts).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state externalAccountsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.LastSeekPosition).To(Equal(uint64(49))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/metadata.go b/internal/connectors/plugins/public/wise/metadata.go new file mode 100644 index 00000000..5261fc4d --- /dev/null +++ b/internal/connectors/plugins/public/wise/metadata.go @@ -0,0 +1,5 @@ +package wise + +const ( + metadataProfileIDKey = "profile_id" +) diff --git a/internal/connectors/plugins/public/wise/payments.go b/internal/connectors/plugins/public/wise/payments.go new file mode 100644 index 00000000..b4ef8ecc --- /dev/null +++ b/internal/connectors/plugins/public/wise/payments.go @@ -0,0 +1,171 @@ +package wise + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" + "github.com/hashicorp/go-hclog" +) + +type paymentsState struct { + Offset int `json:"offset"` + LastTransferID uint64 `json:"lastTransferID"` +} + +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + var oldState paymentsState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + } + + var from client.Profile + if req.FromPayload == nil { + return models.FetchNextPaymentsResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + newState := paymentsState{ + Offset: oldState.Offset, + } + + var payments []models.PSPPayment + var paymentIDs []uint64 + needMore := false + hasMore := false + for { + pagedTransfers, err := p.client.GetTransfers(ctx, from.ID, newState.Offset, req.PageSize) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + payments, paymentIDs, err = fillPayments(pagedTransfers, payments, paymentIDs, oldState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedTransfers, req.PageSize) + if !needMore || !hasMore { + break + } + + newState.Offset += req.PageSize + } + + if !needMore { + payments = payments[:req.PageSize] + paymentIDs = paymentIDs[:req.PageSize] + + // Wise is very annoying with that point, the offset must be a multiple + // of the pageSize, otherwise, we will have an error inconsistent + // pagination. + newState.Offset += req.PageSize + } + + if len(paymentIDs) > 0 { + newState.LastTransferID = paymentIDs[len(paymentIDs)-1] + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextPaymentsResponse{}, err + } + + return models.FetchNextPaymentsResponse{ + Payments: payments, + NewState: payload, + HasMore: hasMore, + }, nil +} + +func fillPayments( + pagedTransfers []client.Transfer, + payments []models.PSPPayment, + paymentIDs []uint64, + oldState paymentsState, +) ([]models.PSPPayment, []uint64, error) { + for _, transfer := range pagedTransfers { + if oldState.LastTransferID != 0 && transfer.ID <= oldState.LastTransferID { + continue + } + + payment, err := fromTransferToPayment(transfer) + if err != nil { + if errors.Is(err, plugins.ErrCurrencyNotSupported) { + // Do not insert unknown currencies + hclog.Default().Info(fmt.Sprintf("skipping unsupported wise payment: %d", transfer.ID)) + continue + } + return nil, nil, err + } + + payments = append(payments, payment) + paymentIDs = append(paymentIDs, transfer.ID) + } + + return payments, paymentIDs, nil +} + +func fromTransferToPayment(from client.Transfer) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + precision, ok := supportedCurrenciesWithDecimal[from.TargetCurrency] + if !ok { + return models.PSPPayment{}, fmt.Errorf("unsupported currency: %s: %w", from.TargetCurrency, plugins.ErrCurrencyNotSupported) + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.TargetValue.String(), precision) + if err != nil { + return models.PSPPayment{}, err + } + + p := models.PSPPayment{ + Reference: fmt.Sprintf("%d", from.ID), + CreatedAt: from.CreatedAt, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.TargetCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchTransferStatus(from.Status), + Raw: raw, + } + + if from.SourceBalanceID != 0 { + p.SourceAccountReference = pointer.For(fmt.Sprintf("%d", from.SourceBalanceID)) + } + + if from.DestinationBalanceID != 0 { + p.DestinationAccountReference = pointer.For(fmt.Sprintf("%d", from.DestinationBalanceID)) + } + + return p, nil +} + +func matchTransferStatus(status string) models.PaymentStatus { + switch status { + case "incoming_payment_waiting", "incoming_payment_initiated", "processing", "funds_converted", "bounced_back": + return models.PAYMENT_STATUS_PENDING + case "outgoing_payment_sent": + return models.PAYMENT_STATUS_SUCCEEDED + case "funds_refunded", "charged_back": + return models.PAYMENT_STATUS_FAILED + case "cancelled": + return models.PAYMENT_STATUS_CANCELLED + } + + return models.PAYMENT_STATUS_OTHER +} diff --git a/internal/connectors/plugins/public/wise/payments_test.go b/internal/connectors/plugins/public/wise/payments_test.go new file mode 100644 index 00000000..077781e6 --- /dev/null +++ b/internal/connectors/plugins/public/wise/payments_test.go @@ -0,0 +1,192 @@ +package wise + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "go.uber.org/mock/gomock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wise Plugin Payments", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("fetching next accounts", func() { + var ( + m *client.MockClient + sampleTransfers []client.Transfer + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + sampleTransfers = make([]client.Transfer, 0) + for i := 0; i < 50; i++ { + sampleTransfers = append(sampleTransfers, client.Transfer{ + ID: uint64(i), + Reference: fmt.Sprintf("test%d", i), + Status: "outgoing_payment_sent", + SourceAccount: 1, + SourceCurrency: "USD", + SourceValue: "100", + TargetAccount: 2, + TargetCurrency: "USD", + TargetValue: "100", + User: 1, + SourceBalanceID: 123, + DestinationBalanceID: 321, + CreatedAt: now.Add(-time.Duration(50-i) * time.Minute).UTC(), + }) + } + }) + + It("should return an error - missing from payload", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + } + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("missing from payload in request")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should return an error - get transactions error", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetTransfers(gomock.Any(), uint64(0), 0, 60).Return( + []client.Transfer{}, + errors.New("test error"), + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.FetchNextPaymentsResponse{})) + }) + + It("should fetch next payments - no state no results", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetTransfers(gomock.Any(), uint64(0), 0, 60).Return( + []client.Transfer{}, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(0)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We fetched everything, state should be resetted + Expect(state.Offset).To(Equal(0)) + Expect(state.LastTransferID).To(Equal(uint64(0))) + }) + + It("should fetch next payments - no state pageSize > total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 60, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetTransfers(gomock.Any(), uint64(0), 0, 60).Return( + sampleTransfers, + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(50)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // Offset should not be increased as we did not attain the pageSize + Expect(state.Offset).To(Equal(0)) + Expect(state.LastTransferID).To(Equal(uint64(49))) + }) + + It("should fetch next payments - no state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + PageSize: 40, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetTransfers(gomock.Any(), uint64(0), 0, 40).Return( + sampleTransfers[:40], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(40)) + Expect(resp.HasMore).To(BeTrue()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + // We attain the pageSize, offset should be increased + Expect(state.Offset).To(Equal(40)) + Expect(state.LastTransferID).To(Equal(uint64(39))) + }) + + It("should fetch next payments - with state pageSize < total payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + State: []byte(`{"offset": 0, "lastTransferID": 38}`), + PageSize: 40, + FromPayload: []byte(`{"id": 0}`), + } + + m.EXPECT().GetTransfers(gomock.Any(), uint64(0), 0, 40).Return( + sampleTransfers[:40], + nil, + ) + + m.EXPECT().GetTransfers(gomock.Any(), uint64(0), 40, 40).Return( + sampleTransfers[40:], + nil, + ) + + resp, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(resp.Payments).To(HaveLen(11)) + Expect(resp.HasMore).To(BeFalse()) + Expect(resp.NewState).ToNot(BeNil()) + + var state paymentsState + err = json.Unmarshal(resp.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.Offset).To(Equal(40)) + Expect(state.LastTransferID).To(Equal(uint64(49))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/payouts.go b/internal/connectors/plugins/public/wise/payouts.go new file mode 100644 index 00000000..9c57e1ad --- /dev/null +++ b/internal/connectors/plugins/public/wise/payouts.go @@ -0,0 +1,87 @@ +package wise + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validateTransferPayoutRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + sourceProfileID := pi.SourceAccount.Metadata["profile_id"] + destinationProfileID, _ := strconv.ParseUint(pi.DestinationAccount.Metadata["profile_id"], 10, 64) + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %w: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to convert amount to string: %w: %w", err, models.ErrInvalidRequest) + } + + quote, err := p.client.CreateQuote(ctx, sourceProfileID, curr, json.Number(amount)) + if err != nil { + return models.PSPPayment{}, err + } + + resp, err := p.client.CreatePayout(ctx, quote, destinationProfileID, pi.Reference) + if err != nil { + return models.PSPPayment{}, err + } + + payment, err := fromPayoutToPayment(*resp) + if err != nil { + return models.PSPPayment{}, err + } + + return payment, nil +} + +func fromPayoutToPayment(from client.Payout) (models.PSPPayment, error) { + raw, err := json.Marshal(from) + if err != nil { + return models.PSPPayment{}, err + } + + precision, ok := supportedCurrenciesWithDecimal[from.TargetCurrency] + if !ok { + return models.PSPPayment{}, fmt.Errorf("unsupported currency: %s: %w", from.TargetCurrency, models.ErrInvalidRequest) + } + + amount, err := currency.GetAmountWithPrecisionFromString(from.TargetValue.String(), precision) + if err != nil { + return models.PSPPayment{}, err + } + + p := models.PSPPayment{ + Reference: fmt.Sprintf("%d", from.ID), + CreatedAt: from.CreatedAt, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.TargetCurrency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: matchTransferStatus(from.Status), + Raw: raw, + } + + if from.SourceBalanceID != 0 { + p.SourceAccountReference = pointer.For(fmt.Sprintf("%d", from.SourceBalanceID)) + } + + if from.TargetAccount != 0 { + p.DestinationAccountReference = pointer.For(fmt.Sprintf("%d", from.TargetAccount)) + } + + return p, nil +} diff --git a/internal/connectors/plugins/public/wise/payouts_test.go b/internal/connectors/plugins/public/wise/payouts_test.go new file mode 100644 index 00000000..447a5a6c --- /dev/null +++ b/internal/connectors/plugins/public/wise/payouts_test.go @@ -0,0 +1,232 @@ +package wise + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Wise Plugin Payouts Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create payout", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "profile_id": "1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "profile_id": "2", + }, + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - missing source account profile id in metadata", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount.Metadata = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account metadata with profile id is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - invalid source account profile id in metadata", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount.Metadata["profile_id"] = "invalid" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account metadata with profile id is required as an integer: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - missing destination account profile id in metadata", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount.Metadata = nil + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account metadata with profile id is required: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - invalid destination account profile id in metadata", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount.Metadata["profile_id"] = "invalid" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account metadata with profile id is required as an integer: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - create quote error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().CreateQuote(gomock.Any(), "1", "EUR", json.Number("1.00")).Return(client.Quote{}, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should return an error - create transfer error", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + quote := client.Quote{ + ID: uuid.New(), + } + m.EXPECT().CreateQuote(gomock.Any(), "1", "EUR", json.Number("1.00")).Return(quote, nil) + m.EXPECT().CreatePayout(gomock.Any(), quote, uint64(2), "test1").Return(nil, errors.New("test error")) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreatePayoutResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreatePayoutRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.Payout{ + ID: 123, + Status: "outgoing_payment_sent", + TargetAccount: 2, + TargetCurrency: "EUR", + TargetValue: "1.00", + SourceBalanceID: 1, + CreatedAt: now, + } + quote := client.Quote{ + ID: uuid.New(), + } + m.EXPECT().CreateQuote(gomock.Any(), "1", "EUR", json.Number("1.00")).Return(quote, nil) + m.EXPECT().CreatePayout(gomock.Any(), quote, uint64(2), "test1").Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreatePayout(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreatePayoutResponse{ + Payment: &models.PSPPayment{ + Reference: "123", + CreatedAt: now, + Type: models.PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("1"), + DestinationAccountReference: pointer.For("2"), + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/plugin.go b/internal/connectors/plugins/public/wise/plugin.go new file mode 100644 index 00000000..67a441c3 --- /dev/null +++ b/internal/connectors/plugins/public/wise/plugin.go @@ -0,0 +1,243 @@ +package wise + +import ( + "context" + "encoding/json" + "errors" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/connectors/plugins/registry" + "github.com/formancehq/payments/internal/models" +) + +func init() { + registry.RegisterPlugin("wise", func(name string, rm json.RawMessage) (models.Plugin, error) { + return New(name, rm) + }, capabilities) +} + +var ( + HeadersTestNotification = "X-Test-Notification" + HeadersDeliveryID = "X-Delivery-Id" + HeadersSignature = "X-Signature-Sha256" + + ErrStackPublicUrlMissing = errors.New("STACK_PUBLIC_URL is not set") + ErrWebhookHeaderXDeliveryIDMissing = errors.New("missing X-Delivery-Id header") + ErrWebhookHeaderXSignatureMissing = errors.New("missing X-Signature-Sha256 header") + ErrWebhookNameUnknown = errors.New("unknown webhook name") +) + +type Plugin struct { + name string + + config Config + client client.Client + webhookConfigs map[string]webhookConfig +} + +func New(name string, rawConfig json.RawMessage) (*Plugin, error) { + config, err := unmarshalAndValidateConfig(rawConfig) + if err != nil { + return nil, err + } + + client := client.New(config.APIKey) + + p := &Plugin{ + name: name, + client: client, + config: config, + } + + p.webhookConfigs = map[string]webhookConfig{ + "transfer_state_changed": { + triggerOn: "transfers#state-change", + urlPath: "/transferstatechanged", + fn: p.translateTransferStateChangedWebhook, + version: "2.0.0", + }, + "balance_update": { + triggerOn: "balances#update", + urlPath: "/balanceupdate", + fn: p.translateBalanceUpdateWebhook, + version: "2.2.0", + }, + } + + return p, nil +} + +func (p *Plugin) Name() string { + return p.name +} + +func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + configs := make([]models.PSPWebhookConfig, 0, len(p.webhookConfigs)) + for name, config := range p.webhookConfigs { + configs = append(configs, models.PSPWebhookConfig{ + Name: name, + URLPath: config.urlPath, + }) + } + + return models.InstallResponse{ + Workflow: workflow(), + WebhooksConfigs: configs, + }, nil +} + +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + if p.client == nil { + return models.UninstallResponse{}, plugins.ErrNotYetInstalled + } + return p.uninstall(ctx, req) +} + +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + if p.client == nil { + return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextAccounts(ctx, req) +} + +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + if p.client == nil { + return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextBalances(ctx, req) +} + +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + if p.client == nil { + return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchExternalAccounts(ctx, req) +} + +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + if p.client == nil { + return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled + } + return p.fetchNextPayments(ctx, req) +} + +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + if p.client == nil { + return models.FetchNextOthersResponse{}, plugins.ErrNotYetInstalled + } + + switch req.Name { + case fetchProfileName: + return p.fetchNextProfiles(ctx, req) + default: + return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented + } +} + +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + if p.client == nil { + return models.CreateTransferResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createTransfer(ctx, req.PaymentInitiation) + if err != nil { + return models.CreateTransferResponse{}, err + } + + return models.CreateTransferResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + return models.ReverseTransferResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + return models.PollTransferStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + if p.client == nil { + return models.CreatePayoutResponse{}, plugins.ErrNotYetInstalled + } + + payment, err := p.createPayout(ctx, req.PaymentInitiation) + if err != nil { + return models.CreatePayoutResponse{}, err + } + + return models.CreatePayoutResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + return models.ReversePayoutResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + return models.PollPayoutStatusResponse{}, plugins.ErrNotImplemented +} + +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + if p.client == nil { + return models.CreateWebhooksResponse{}, plugins.ErrNotYetInstalled + } + return p.createWebhooks(ctx, req) +} + +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + if p.client == nil { + return models.TranslateWebhookResponse{}, plugins.ErrNotYetInstalled + } + + testNotif, ok := req.Webhook.Headers[HeadersTestNotification] + if ok && len(testNotif) > 0 { + if testNotif[0] == "true" { + return models.TranslateWebhookResponse{}, nil + } + } + + v, ok := req.Webhook.Headers[HeadersDeliveryID] + if !ok || len(v) == 0 { + return models.TranslateWebhookResponse{}, ErrWebhookHeaderXDeliveryIDMissing + } + + signatures, ok := req.Webhook.Headers[HeadersSignature] + if !ok || len(signatures) == 0 { + return models.TranslateWebhookResponse{}, ErrWebhookHeaderXSignatureMissing + } + + err := p.verifySignature(req.Webhook.Body, signatures[0]) + if err != nil { + return models.TranslateWebhookResponse{}, err + } + + config, ok := p.webhookConfigs[req.Name] + if !ok { + return models.TranslateWebhookResponse{}, ErrWebhookNameUnknown + } + + res, err := config.fn(ctx, req) + if err != nil { + return models.TranslateWebhookResponse{}, err + } + + res.IdempotencyKey = v[0] + + return models.TranslateWebhookResponse{ + Responses: []models.WebhookResponse{res}, + }, nil +} + +func (p *Plugin) SetClient(cl client.Client) { + p.client = cl +} + +var _ models.Plugin = &Plugin{} diff --git a/internal/connectors/plugins/public/wise/plugin_test.go b/internal/connectors/plugins/public/wise/plugin_test.go new file mode 100644 index 00000000..3aa4f50e --- /dev/null +++ b/internal/connectors/plugins/public/wise/plugin_test.go @@ -0,0 +1,267 @@ +package wise + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "testing" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Wise Plugin Suite") +} + +var _ = Describe("Wise Plugin", func() { + var ( + plg *Plugin + block *pem.Block + pemKey *bytes.Buffer + privatekey *rsa.PrivateKey + ) + + BeforeEach(func() { + plg = &Plugin{} + + plg.webhookConfigs = map[string]webhookConfig{ + "transfer_state_changed": { + triggerOn: "transfers#state-change", + urlPath: "/transferstatechanged", + fn: plg.translateTransferStateChangedWebhook, + version: "2.0.0", + }, + "balance_update": { + triggerOn: "balances#update", + urlPath: "/balanceupdate", + fn: plg.translateBalanceUpdateWebhook, + version: "2.2.0", + }, + } + + var err error + privatekey, err = rsa.GenerateKey(rand.Reader, 2048) + Expect(err).To(BeNil()) + publickey := &privatekey.PublicKey + publicKeyBytes, err := x509.MarshalPKIXPublicKey(publickey) + Expect(err).To(BeNil()) + block = &pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + } + pemKey = bytes.NewBufferString("") + + err = pem.Encode(pemKey, block) + Expect(err).To(BeNil()) + }) + + Context("install", func() { + It("reports validation errors in the config", func(ctx SpecContext) { + _, err := New("wise", json.RawMessage(`{}`)) + Expect(err).To(MatchError(ContainSubstring("config"))) + }) + It("rejects malformed pem keys", func(ctx SpecContext) { + config := json.RawMessage(`{"apiKey":"dummy","webhookPublicKey":"badKey"}`) + _, err := New("wise", config) + Expect(err).To(MatchError(ContainSubstring("public key"))) + }) + It("returns valid install response", func(ctx SpecContext) { + config := &Config{ + APIKey: "key", + WebhookPublicKey: pemKey.String(), + } + configJson, err := json.Marshal(config) + Expect(err).To(BeNil()) + _, err = New("wise", configJson) + Expect(err).To(BeNil()) + req := models.InstallRequest{} + res, err := plg.Install(context.Background(), req) + Expect(err).To(BeNil()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow[0].Name).To(Equal("fetch_profiles")) + }) + }) + + Context("translate webhook", func() { + var ( + body []byte + signature []byte + m *client.MockClient + ) + + BeforeEach(func() { + config := &Config{ + APIKey: "key", + WebhookPublicKey: pemKey.String(), + } + configJson, err := json.Marshal(config) + Expect(err).To(BeNil()) + plg, err = New("wise", configJson) + Expect(err).To(BeNil()) + + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.SetClient(m) + + body = bytes.NewBufferString("body content").Bytes() + hash := sha256.New() + hash.Write(body) + digest := hash.Sum(nil) + + signature, err = rsa.SignPKCS1v15(rand.Reader, privatekey, crypto.SHA256, digest) + Expect(err).To(BeNil()) + }) + + It("it fails when X-Delivery-ID header missing", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{} + _, err := plg.TranslateWebhook(context.Background(), req) + Expect(err).To(MatchError(ErrWebhookHeaderXDeliveryIDMissing)) + }) + + It("it fails when X-Signature-Sha256 header missing", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Webhook: models.PSPWebhook{ + Headers: map[string][]string{ + HeadersDeliveryID: {"delivery-id"}, + }, + }, + } + _, err := plg.TranslateWebhook(context.Background(), req) + Expect(err).To(MatchError(ErrWebhookHeaderXSignatureMissing)) + }) + + It("it fails when unknown webhook name in request", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "unknown", + Webhook: models.PSPWebhook{ + Body: body, + Headers: map[string][]string{ + HeadersDeliveryID: {"delivery-id"}, + HeadersSignature: {base64.StdEncoding.EncodeToString(signature)}, + }, + }, + } + + _, err := plg.TranslateWebhook(context.Background(), req) + Expect(err).To(MatchError(ErrWebhookNameUnknown)) + }) + + It("it can create the transfer_state_changed webhook", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "transfer_state_changed", + Webhook: models.PSPWebhook{ + Body: body, + Headers: map[string][]string{ + HeadersDeliveryID: {"delivery-id"}, + HeadersSignature: {base64.StdEncoding.EncodeToString(signature)}, + }, + }, + } + transfer := client.Transfer{ID: 1, Reference: "ref1", TargetValue: json.Number("25"), TargetCurrency: "EUR"} + m.EXPECT().TranslateTransferStateChangedWebhook(gomock.Any(), body).Return(transfer, nil) + + res, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Responses).To(HaveLen(1)) + Expect(res.Responses[0].IdempotencyKey).To(Equal(req.Webhook.Headers[HeadersDeliveryID][0])) + Expect(res.Responses[0].Payment).NotTo(BeNil()) + Expect(res.Responses[0].Payment.Reference).To(Equal(fmt.Sprint(transfer.ID))) + }) + + It("it can create the balance_update webhook", func(ctx SpecContext) { + req := models.TranslateWebhookRequest{ + Name: "balance_update", + Webhook: models.PSPWebhook{ + Body: body, + Headers: map[string][]string{ + HeadersDeliveryID: {"delivery-id"}, + HeadersSignature: {base64.StdEncoding.EncodeToString(signature)}, + }, + }, + } + balance := client.BalanceUpdateWebhookPayload{ + Data: client.BalanceUpdateWebhookData{ + TransferReference: "trx", + OccurredAt: time.Now().Format(time.RFC3339), + Currency: "USD", + Amount: json.Number("43"), + }, + } + m.EXPECT().TranslateBalanceUpdateWebhook(gomock.Any(), body).Return(balance, nil) + + res, err := plg.TranslateWebhook(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Responses).To(HaveLen(1)) + Expect(res.Responses[0].IdempotencyKey).To(Equal(req.Webhook.Headers[HeadersDeliveryID][0])) + Expect(res.Responses[0].Payment).NotTo(BeNil()) + Expect(res.Responses[0].Payment.Reference).To(Equal(balance.Data.TransferReference)) + }) + }) + + Context("calling functions on uninstalled plugins", func() { + It("returns valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "dummyID"} + _, err := plg.Uninstall(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + + It("fails when fetch next accounts is called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextAccounts(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next balances is called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextBalances(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next others is called before install", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextOthers(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next external accounts is called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextExternalAccounts(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when create webhook is called before install", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{} + _, err := plg.CreateWebhooks(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when create transfer is called before install", func(ctx SpecContext) { + req := models.CreateTransferRequest{} + _, err := plg.CreateTransfer(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when create transfer is called before install", func(ctx SpecContext) { + req := models.CreatePayoutRequest{} + _, err := plg.CreatePayout(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/profiles.go b/internal/connectors/plugins/public/wise/profiles.go new file mode 100644 index 00000000..10febfad --- /dev/null +++ b/internal/connectors/plugins/public/wise/profiles.go @@ -0,0 +1,68 @@ +package wise + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/formancehq/payments/internal/models" +) + +type profilesState struct { + // Profiles are ordered by their ID + LastProfileID uint64 `json:"lastProfileID"` +} + +func (p *Plugin) fetchNextProfiles(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + var oldState profilesState + if req.State != nil { + if err := json.Unmarshal(req.State, &oldState); err != nil { + return models.FetchNextOthersResponse{}, err + } + } + + newState := profilesState{ + LastProfileID: oldState.LastProfileID, + } + + var others []models.PSPOther + hasMore := false + profiles, err := p.client.GetProfiles(ctx) + if err != nil { + return models.FetchNextOthersResponse{}, err + } + + for _, profile := range profiles { + if profile.ID <= oldState.LastProfileID { + continue + } + + raw, err := json.Marshal(profile) + if err != nil { + return models.FetchNextOthersResponse{}, err + } + + others = append(others, models.PSPOther{ + ID: strconv.FormatUint(profile.ID, 10), + Other: raw, + }) + + newState.LastProfileID = profile.ID + + if len(others) >= req.PageSize { + hasMore = true + break + } + } + + payload, err := json.Marshal(newState) + if err != nil { + return models.FetchNextOthersResponse{}, err + } + + return models.FetchNextOthersResponse{ + Others: others, + NewState: payload, + HasMore: hasMore, + }, nil +} diff --git a/internal/connectors/plugins/public/wise/profiles_test.go b/internal/connectors/plugins/public/wise/profiles_test.go new file mode 100644 index 00000000..ebf7a3a7 --- /dev/null +++ b/internal/connectors/plugins/public/wise/profiles_test.go @@ -0,0 +1,76 @@ +package wise + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "go.uber.org/mock/gomock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wise Plugin Profiles", func() { + var ( + plg *Plugin + m *client.MockClient + ) + + BeforeEach(func() { + plg = &Plugin{} + + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.SetClient(m) + }) + + Context("fetch next profiles", func() { + var ( + profiles []client.Profile + ) + + BeforeEach(func() { + profiles = []client.Profile{ + {ID: 14556, Type: "type1"}, + {ID: 3334, Type: "type2"}, + } + }) + + It("replies with unimplemented when unknown other type in request", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + State: json.RawMessage(`{}`), + PageSize: len(profiles), + } + _, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(MatchError(plugins.ErrNotImplemented)) + }) + + It("fetches profiles from wise", func(ctx SpecContext) { + req := models.FetchNextOthersRequest{ + State: json.RawMessage(`{}`), + Name: "fetch_profiles", + PageSize: len(profiles), + } + m.EXPECT().GetProfiles(gomock.Any()).Return( + profiles, + nil, + ) + + res, err := plg.FetchNextOthers(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(BeTrue()) + Expect(res.Others).To(HaveLen(req.PageSize)) + Expect(res.Others[0].ID).To(Equal(fmt.Sprint(profiles[0].ID))) + Expect(res.Others[1].ID).To(Equal(fmt.Sprint(profiles[1].ID))) + + var state profilesState + + err = json.Unmarshal(res.NewState, &state) + Expect(err).To(BeNil()) + Expect(fmt.Sprint(state.LastProfileID)).To(Equal(res.Others[len(res.Others)-1].ID)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/transfers.go b/internal/connectors/plugins/public/wise/transfers.go new file mode 100644 index 00000000..7a4a9b0c --- /dev/null +++ b/internal/connectors/plugins/public/wise/transfers.go @@ -0,0 +1,47 @@ +package wise + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) createTransfer(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) { + if err := p.validateTransferPayoutRequest(pi); err != nil { + return models.PSPPayment{}, err + } + + sourceProfileID := pi.SourceAccount.Metadata["profile_id"] + destinationProfileID, _ := strconv.ParseUint(pi.DestinationAccount.Metadata["profile_id"], 10, 64) + + curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %w: %w", err, models.ErrInvalidRequest) + } + + amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision) + if err != nil { + return models.PSPPayment{}, fmt.Errorf("failed to convert amount to string: %w: %w", err, models.ErrInvalidRequest) + } + + quote, err := p.client.CreateQuote(ctx, sourceProfileID, curr, json.Number(amount)) + if err != nil { + return models.PSPPayment{}, err + } + + resp, err := p.client.CreateTransfer(ctx, quote, destinationProfileID, pi.Reference) + if err != nil { + return models.PSPPayment{}, err + } + + payment, err := fromTransferToPayment(*resp) + if err != nil { + return models.PSPPayment{}, err + } + + return payment, nil +} diff --git a/internal/connectors/plugins/public/wise/transfers_test.go b/internal/connectors/plugins/public/wise/transfers_test.go new file mode 100644 index 00000000..22515f8d --- /dev/null +++ b/internal/connectors/plugins/public/wise/transfers_test.go @@ -0,0 +1,232 @@ +package wise + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Wise Plugin Transfers Creation", func() { + var ( + plg *Plugin + ) + + BeforeEach(func() { + plg = &Plugin{} + }) + + Context("create transfer", func() { + var ( + m *client.MockClient + samplePSPPaymentInitiation models.PSPPaymentInitiation + now time.Time + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.client = m + now = time.Now().UTC() + + samplePSPPaymentInitiation = models.PSPPaymentInitiation{ + Reference: "test1", + CreatedAt: now.UTC(), + Description: "test1", + SourceAccount: &models.PSPAccount{ + Reference: "acc1", + CreatedAt: now.Add(-time.Duration(50) * time.Minute).UTC(), + Name: pointer.For("acc1"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "profile_id": "1", + }, + }, + DestinationAccount: &models.PSPAccount{ + Reference: "acc2", + CreatedAt: now.Add(-time.Duration(49) * time.Minute).UTC(), + Name: pointer.For("acc2"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "profile_id": "2", + }, + }, + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo": "bar", + }, + } + }) + + It("should return an error - validation error - source account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - destination account", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - missing source account profile id in metadata", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount.Metadata = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account metadata with profile id is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - invalid source account profile id in metadata", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.SourceAccount.Metadata["profile_id"] = "invalid" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("source account metadata with profile id is required as an integer: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - missing destination account profile id in metadata", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount.Metadata = nil + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account metadata with profile id is required: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - invalid destination account profile id in metadata", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.DestinationAccount.Metadata["profile_id"] = "invalid" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("destination account metadata with profile id is required as an integer: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - validation error - asset not supported", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + req.PaymentInitiation.Asset = "HUF/2" + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("failed to get currency and precision from asset: missing currencies: invalid request")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - create quote error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + m.EXPECT().CreateQuote(gomock.Any(), "1", "EUR", json.Number("1.00")).Return(client.Quote{}, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should return an error - create transfer error", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + quote := client.Quote{ + ID: uuid.New(), + } + m.EXPECT().CreateQuote(gomock.Any(), "1", "EUR", json.Number("1.00")).Return(quote, nil) + m.EXPECT().CreateTransfer(gomock.Any(), quote, uint64(2), "test1").Return(nil, errors.New("test error")) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError("test error")) + Expect(resp).To(Equal(models.CreateTransferResponse{})) + }) + + It("should be ok", func(ctx SpecContext) { + req := models.CreateTransferRequest{ + PaymentInitiation: samplePSPPaymentInitiation, + } + + trResponse := client.Transfer{ + ID: 123, + Status: "outgoing_payment_sent", + TargetCurrency: "EUR", + TargetValue: "1.00", + SourceBalanceID: 1, + DestinationBalanceID: 2, + CreatedAt: now, + } + quote := client.Quote{ + ID: uuid.New(), + } + m.EXPECT().CreateQuote(gomock.Any(), "1", "EUR", json.Number("1.00")).Return(quote, nil) + m.EXPECT().CreateTransfer(gomock.Any(), quote, uint64(2), "test1").Return(&trResponse, nil) + + raw, err := json.Marshal(&trResponse) + Expect(err).To(BeNil()) + + resp, err := plg.CreateTransfer(ctx, req) + Expect(err).To(BeNil()) + Expect(resp).To(Equal(models.CreateTransferResponse{ + Payment: &models.PSPPayment{ + Reference: "123", + CreatedAt: now, + Type: models.PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(100), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountReference: pointer.For("1"), + DestinationAccountReference: pointer.For("2"), + Raw: raw, + }, + })) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/uninstall.go b/internal/connectors/plugins/public/wise/uninstall.go new file mode 100644 index 00000000..11e9c7dc --- /dev/null +++ b/internal/connectors/plugins/public/wise/uninstall.go @@ -0,0 +1,34 @@ +package wise + +import ( + "context" + "strings" + + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + profiles, err := p.client.GetProfiles(ctx) + if err != nil { + return models.UninstallResponse{}, err + } + + for _, profile := range profiles { + webhooks, err := p.client.ListWebhooksSubscription(ctx, profile.ID) + if err != nil { + return models.UninstallResponse{}, err + } + + for _, webhook := range webhooks { + if !strings.Contains(webhook.Delivery.URL, req.ConnectorID) { + continue + } + + if err := p.client.DeleteWebhooks(ctx, profile.ID, webhook.ID); err != nil { + return models.UninstallResponse{}, err + } + } + } + + return models.UninstallResponse{}, nil +} diff --git a/internal/connectors/plugins/public/wise/uninstall_test.go b/internal/connectors/plugins/public/wise/uninstall_test.go new file mode 100644 index 00000000..f008c776 --- /dev/null +++ b/internal/connectors/plugins/public/wise/uninstall_test.go @@ -0,0 +1,71 @@ +package wise + +import ( + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Wise Plugin Uninstall", func() { + var ( + plg *Plugin + m *client.MockClient + ) + + BeforeEach(func() { + plg = &Plugin{} + + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.SetClient(m) + }) + + Context("uninstall", func() { + var ( + profiles []client.Profile + expectedWebhookID = "webhook1" + expectedWebhookID2 = "webhook2" + ) + + BeforeEach(func() { + profiles = []client.Profile{ + {ID: 1, Type: "type1"}, + {ID: 2, Type: "type2"}, + } + }) + + It("deletes webhooks related to accounts", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "dummyID"} + m.EXPECT().GetProfiles(gomock.Any()).Return( + profiles, + nil, + ) + m.EXPECT().ListWebhooksSubscription(gomock.Any(), profiles[0].ID).Return( + []client.WebhookSubscriptionResponse{ + {ID: expectedWebhookID, Delivery: client.WebhookDelivery{ + URL: fmt.Sprintf("http://somesite.fr/%s", req.ConnectorID), + }}, + {ID: "skipped", Delivery: client.WebhookDelivery{URL: "http://somesite.fr"}}, + }, + nil, + ) + m.EXPECT().ListWebhooksSubscription(gomock.Any(), profiles[1].ID).Return( + []client.WebhookSubscriptionResponse{ + {ID: expectedWebhookID2, Delivery: client.WebhookDelivery{ + URL: fmt.Sprintf("http://%s.somesite.com", req.ConnectorID), + }}, + }, + nil, + ) + m.EXPECT().DeleteWebhooks(gomock.Any(), profiles[0].ID, expectedWebhookID).Return(nil) + m.EXPECT().DeleteWebhooks(gomock.Any(), profiles[1].ID, expectedWebhookID2).Return(nil) + + _, err := plg.Uninstall(ctx, req) + Expect(err).To(BeNil()) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/utils.go b/internal/connectors/plugins/public/wise/utils.go new file mode 100644 index 00000000..5f639981 --- /dev/null +++ b/internal/connectors/plugins/public/wise/utils.go @@ -0,0 +1,40 @@ +package wise + +import ( + "fmt" + "strconv" + + "github.com/formancehq/payments/internal/models" +) + +func (p *Plugin) validateTransferPayoutRequest(pi models.PSPPaymentInitiation) error { + if pi.SourceAccount == nil { + return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest) + } + + if pi.DestinationAccount == nil { + return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest) + } + + id, ok := pi.SourceAccount.Metadata["profile_id"] + if !ok { + return fmt.Errorf("source account metadata with profile id is required: %w", models.ErrInvalidRequest) + } + + _, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return fmt.Errorf("source account metadata with profile id is required as an integer: %w", models.ErrInvalidRequest) + } + + id, ok = pi.DestinationAccount.Metadata["profile_id"] + if !ok { + return fmt.Errorf("destination account metadata with profile id is required: %w", models.ErrInvalidRequest) + } + + _, err = strconv.ParseUint(id, 10, 64) + if err != nil { + return fmt.Errorf("destination account metadata with profile id is required as an integer: %w", models.ErrInvalidRequest) + } + + return nil +} diff --git a/internal/connectors/plugins/public/wise/webhooks.go b/internal/connectors/plugins/public/wise/webhooks.go new file mode 100644 index 00000000..a5ebabd3 --- /dev/null +++ b/internal/connectors/plugins/public/wise/webhooks.go @@ -0,0 +1,159 @@ +package wise + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" +) + +type webhookConfig struct { + triggerOn string + urlPath string + fn func(context.Context, models.TranslateWebhookRequest) (models.WebhookResponse, error) + version string +} + +func (p *Plugin) createWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + var from client.Profile + if req.FromPayload == nil { + return models.CreateWebhooksResponse{}, models.ErrMissingFromPayloadInRequest + } + if err := json.Unmarshal(req.FromPayload, &from); err != nil { + return models.CreateWebhooksResponse{}, err + } + + if req.WebhookBaseUrl == "" { + return models.CreateWebhooksResponse{}, ErrStackPublicUrlMissing + } + + others := make([]models.PSPOther, 0, len(p.webhookConfigs)) + for name, config := range p.webhookConfigs { + url, err := url.JoinPath(req.WebhookBaseUrl, config.urlPath) + if err != nil { + return models.CreateWebhooksResponse{}, err + } + + resp, err := p.client.CreateWebhook(ctx, from.ID, name, config.triggerOn, url, config.version) + if err != nil { + return models.CreateWebhooksResponse{}, err + } + + raw, err := json.Marshal(resp) + if err != nil { + return models.CreateWebhooksResponse{}, err + } + + others = append(others, models.PSPOther{ + ID: resp.ID, + Other: raw, + }) + } + + return models.CreateWebhooksResponse{ + Others: others, + }, nil +} + +func (p *Plugin) translateTransferStateChangedWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.WebhookResponse, error) { + transfer, err := p.client.TranslateTransferStateChangedWebhook(ctx, req.Webhook.Body) + if err != nil { + return models.WebhookResponse{}, err + } + + payment, err := fromTransferToPayment(transfer) + if err != nil { + return models.WebhookResponse{}, err + } + + return models.WebhookResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) translateBalanceUpdateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.WebhookResponse, error) { + update, err := p.client.TranslateBalanceUpdateWebhook(ctx, req.Webhook.Body) + if err != nil { + return models.WebhookResponse{}, err + } + + raw, err := json.Marshal(update) + if err != nil { + return models.WebhookResponse{}, err + } + + occuredAt, err := time.Parse(time.RFC3339, update.Data.OccurredAt) + if err != nil { + return models.WebhookResponse{}, fmt.Errorf("failed to parse created time: %w", err) + } + + var paymentType models.PaymentType + if update.Data.TransactionType == "credit" { + paymentType = models.PAYMENT_TYPE_PAYIN + } else { + paymentType = models.PAYMENT_TYPE_PAYOUT + } + + precision, ok := supportedCurrenciesWithDecimal[update.Data.Currency] + if !ok { + return models.WebhookResponse{}, nil + } + + amount, err := currency.GetAmountWithPrecisionFromString(update.Data.Amount.String(), precision) + if err != nil { + return models.WebhookResponse{}, err + } + + payment := models.PSPPayment{ + Reference: update.Data.TransferReference, + CreatedAt: occuredAt, + Type: paymentType, + Amount: amount, + Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, update.Data.Currency), + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Raw: raw, + } + + switch paymentType { + case models.PAYMENT_TYPE_PAYIN: + payment.SourceAccountReference = pointer.For(fmt.Sprintf("%d", update.Data.BalanceID)) + case models.PAYMENT_TYPE_PAYOUT: + payment.DestinationAccountReference = pointer.For(fmt.Sprintf("%d", update.Data.BalanceID)) + } + + return models.WebhookResponse{ + Payment: &payment, + }, nil +} + +func (p *Plugin) verifySignature(body []byte, signature string) error { + msgHash := sha256.New() + _, err := msgHash.Write(body) + if err != nil { + return err + } + msgHashSum := msgHash.Sum(nil) + + data, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature for wise webhook: %w", err) + } + + err = rsa.VerifyPKCS1v15(p.config.webhookPublicKey, crypto.SHA256, msgHashSum, data) + if err != nil { + return err + } + + return nil +} diff --git a/internal/connectors/plugins/public/wise/webhooks_test.go b/internal/connectors/plugins/public/wise/webhooks_test.go new file mode 100644 index 00000000..5d24ffc1 --- /dev/null +++ b/internal/connectors/plugins/public/wise/webhooks_test.go @@ -0,0 +1,87 @@ +package wise + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" + "github.com/formancehq/payments/internal/models" + "go.uber.org/mock/gomock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wise Plugin Webhooks", func() { + var ( + plg *Plugin + m *client.MockClient + ) + + BeforeEach(func() { + plg = &Plugin{} + + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg.SetClient(m) + }) + + Context("create webhooks", func() { + var ( + expectedProfileID uint64 + expectedWebhookPath string + expectedWebhookResponseID string + webhookBaseUrl string + err error + ) + + BeforeEach(func() { + expectedProfileID = 44 + plg.webhookConfigs = map[string]webhookConfig{ + "test": { + triggerOn: "transfers#state-change", + urlPath: "/transferstatechanged", + fn: plg.translateTransferStateChangedWebhook, + version: "1.0.0", + }, + } + expectedWebhookResponseID = "sampleResID" + webhookBaseUrl = "http://example.com" + expectedWebhookPath, err = url.JoinPath(webhookBaseUrl, plg.webhookConfigs["test"].urlPath) + Expect(err).To(BeNil()) + }) + + It("skips making calls when webhook url missing", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"id":%d}`, expectedProfileID)), + } + + _, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(MatchError(ErrStackPublicUrlMissing)) + }) + + It("creates webhooks with configured urls", func(ctx SpecContext) { + req := models.CreateWebhooksRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"id":%d}`, expectedProfileID)), + WebhookBaseUrl: webhookBaseUrl, + } + m.EXPECT().CreateWebhook( + gomock.Any(), + expectedProfileID, + "test", + plg.webhookConfigs["test"].triggerOn, + expectedWebhookPath, + plg.webhookConfigs["test"].version, + ).Return( + &client.WebhookSubscriptionResponse{ID: expectedWebhookResponseID}, + nil, + ) + + res, err := plg.CreateWebhooks(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Others).To(HaveLen(len(plg.webhookConfigs))) + Expect(res.Others[0].ID).To(Equal(expectedWebhookResponseID)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/wise/workflow.go b/internal/connectors/plugins/public/wise/workflow.go new file mode 100644 index 00000000..a096c5f2 --- /dev/null +++ b/internal/connectors/plugins/public/wise/workflow.go @@ -0,0 +1,50 @@ +package wise + +import "github.com/formancehq/payments/internal/models" + +const ( + fetchProfileName = "fetch_profiles" +) + +func workflow() models.ConnectorTasksTree { + return []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_OTHERS, + Name: fetchProfileName, + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_recipient_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_CREATE_WEBHOOKS, + Name: "create_webhooks", + Periodically: false, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: false, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + } +} diff --git a/internal/connectors/plugins/registry/errors.go b/internal/connectors/plugins/registry/errors.go new file mode 100644 index 00000000..dab3d804 --- /dev/null +++ b/internal/connectors/plugins/registry/errors.go @@ -0,0 +1,26 @@ +package registry + +import ( + "errors" + "fmt" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/models" +) + +func translateError(err error) error { + switch { + case errors.Is(err, plugins.ErrNotImplemented): + return err + case errors.Is(err, models.ErrMissingFromPayloadInRequest), + errors.Is(err, models.ErrMissingAccountInRequest), + errors.Is(err, models.ErrInvalidRequest), + errors.Is(err, plugins.ErrCurrencyNotSupported), + errors.Is(err, httpwrapper.ErrStatusCodeClientError), + errors.Is(err, models.ErrInvalidConfig): + return fmt.Errorf("%w: %w", err, plugins.ErrInvalidClientRequest) + default: + return err + } +} diff --git a/internal/connectors/plugins/registry/plugins.go b/internal/connectors/plugins/registry/plugins.go new file mode 100644 index 00000000..359d3ce9 --- /dev/null +++ b/internal/connectors/plugins/registry/plugins.go @@ -0,0 +1,53 @@ +package registry + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" +) + +type PluginCreateFunction func(string, json.RawMessage) (models.Plugin, error) + +type PluginInformation struct { + capabilities []models.Capability + createFunc PluginCreateFunction +} + +var ( + pluginsRegistry map[string]PluginInformation = make(map[string]PluginInformation) + + ErrPluginNotFound = errors.New("plugin not found") +) + +func RegisterPlugin(name string, createFunc PluginCreateFunction, capabilities []models.Capability) { + pluginsRegistry[name] = PluginInformation{ + capabilities: capabilities, + createFunc: createFunc, + } +} + +func GetPlugin(logger logging.Logger, provider string, connectorName string, rawConfig json.RawMessage) (models.Plugin, error) { + info, ok := pluginsRegistry[strings.ToLower(provider)] + if !ok { + return nil, ErrPluginNotFound + } + + p, err := info.createFunc(connectorName, rawConfig) + if err != nil { + return nil, translateError(err) + } + + return New(logger, p), nil +} + +func GetCapabilities(provider string) ([]models.Capability, error) { + info, ok := pluginsRegistry[strings.ToLower(provider)] + if !ok { + return nil, ErrPluginNotFound + } + + return info.capabilities, nil +} diff --git a/internal/connectors/plugins/registry/wrapper.go b/internal/connectors/plugins/registry/wrapper.go new file mode 100644 index 00000000..14814850 --- /dev/null +++ b/internal/connectors/plugins/registry/wrapper.go @@ -0,0 +1,316 @@ +package registry + +import ( + "context" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/otel" + "go.opentelemetry.io/otel/attribute" +) + +type impl struct { + logger logging.Logger + plugin models.Plugin +} + +func New(logger logging.Logger, plugin models.Plugin) *impl { + return &impl{ + logger: logger, + plugin: plugin, + } +} + +func (i *impl) Name() string { + return i.plugin.Name() +} + +func (i *impl) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.Install", attribute.String("psp", i.plugin.Name())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("installing...") + + resp, err := i.plugin.Install(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("install failed: %v", err) + otel.RecordError(span, err) + return models.InstallResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("installed!") + + return resp, nil +} + +func (i *impl) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.Uninstall", attribute.String("psp", i.plugin.Name()), attribute.String("connector_id", req.ConnectorID)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("uninstalling...") + + resp, err := i.plugin.Uninstall(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("uninstall failed: %v", err) + otel.RecordError(span, err) + return models.UninstallResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("uninstalled!") + + return resp, nil +} + +func (i *impl) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.FetchNextAccounts", attribute.String("psp", i.plugin.Name())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("fetching next accounts...") + + resp, err := i.plugin.FetchNextAccounts(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("fetching next accounts failed: %v", err) + otel.RecordError(span, err) + return models.FetchNextAccountsResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("fetched next accounts succeeded!") + + return resp, nil +} + +func (i *impl) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.FetchNextExternalAccounts", attribute.String("psp", i.plugin.Name())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("fetching next external accounts...") + + resp, err := i.plugin.FetchNextExternalAccounts(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("fetching next external accounts failed: %v", err) + otel.RecordError(span, err) + return models.FetchNextExternalAccountsResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("fetched next external accounts succeeded!") + + return resp, nil +} + +func (i *impl) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.FetchNextPayments", attribute.String("psp", i.plugin.Name())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("fetching next payments...") + + resp, err := i.plugin.FetchNextPayments(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("fetching next payments failed: %v", err) + otel.RecordError(span, err) + return models.FetchNextPaymentsResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("fetched next payments succeeded!") + + return resp, nil +} + +func (i *impl) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.FetchNextBalances", attribute.String("psp", i.plugin.Name())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("fetching next balances...") + + resp, err := i.plugin.FetchNextBalances(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("fetching next balances failed: %v", err) + otel.RecordError(span, err) + return models.FetchNextBalancesResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("fetched next balances succeeded!") + + return resp, nil +} + +func (i *impl) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.FetchNextOthers", attribute.String("psp", i.plugin.Name())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("fetching next others...") + + resp, err := i.plugin.FetchNextOthers(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("fetching next others failed: %v", err) + otel.RecordError(span, err) + return models.FetchNextOthersResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("fetched next others succeeded!") + + return resp, nil +} + +func (i *impl) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.CreateBankAccount", attribute.String("psp", i.plugin.Name()), attribute.String("bankAccount.id", req.BankAccount.ID.String())) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("creating bank account...") + + resp, err := i.plugin.CreateBankAccount(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("creating bank account failed: %v", err) + otel.RecordError(span, err) + return models.CreateBankAccountResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("created bank account succeeded!") + + return resp, nil +} + +func (i *impl) CreateTransfer(ctx context.Context, req models.CreateTransferRequest) (models.CreateTransferResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.CreateTransfer", attribute.String("psp", i.plugin.Name()), attribute.String("reference", req.PaymentInitiation.Reference)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("creating transfer...") + + resp, err := i.plugin.CreateTransfer(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("creating transfer failed: %v", err) + otel.RecordError(span, err) + return models.CreateTransferResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("created transfer succeeded!") + + return resp, nil +} + +func (i *impl) ReverseTransfer(ctx context.Context, req models.ReverseTransferRequest) (models.ReverseTransferResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.ReverseTransfer", attribute.String("psp", i.plugin.Name()), attribute.String("reference", req.PaymentInitiationReversal.Reference)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("reversing transfer...") + + resp, err := i.plugin.ReverseTransfer(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("reversing transfer failed: %v", err) + otel.RecordError(span, err) + return models.ReverseTransferResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("reversed transfer succeeded!") + + return resp, nil +} + +func (i *impl) PollTransferStatus(ctx context.Context, req models.PollTransferStatusRequest) (models.PollTransferStatusResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.PollTransferStatus", attribute.String("psp", i.plugin.Name()), attribute.String("transferID", req.TransferID)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("polling transfer status...") + + resp, err := i.plugin.PollTransferStatus(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("polling transfer status failed: %v", err) + otel.RecordError(span, err) + return models.PollTransferStatusResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("polled transfer status succeeded!") + + return resp, nil +} + +func (i *impl) CreatePayout(ctx context.Context, req models.CreatePayoutRequest) (models.CreatePayoutResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.CreatePayout", attribute.String("psp", i.plugin.Name()), attribute.String("reference", req.PaymentInitiation.Reference)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("creating payout...") + + resp, err := i.plugin.CreatePayout(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("creating payout failed: %v", err) + otel.RecordError(span, err) + return models.CreatePayoutResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("created payout succeeded!") + + return resp, nil +} + +func (i *impl) ReversePayout(ctx context.Context, req models.ReversePayoutRequest) (models.ReversePayoutResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.ReversePayout", attribute.String("psp", i.plugin.Name()), attribute.String("reference", req.PaymentInitiationReversal.Reference)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("reversing payout...") + + resp, err := i.plugin.ReversePayout(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("reversing payout failed: %v", err) + otel.RecordError(span, err) + return models.ReversePayoutResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("reversed payout succeeded!") + + return resp, nil +} + +func (i *impl) PollPayoutStatus(ctx context.Context, req models.PollPayoutStatusRequest) (models.PollPayoutStatusResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.PollPayoutStatus", attribute.String("psp", i.plugin.Name()), attribute.String("payoutID", req.PayoutID)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("polling payout status...") + + resp, err := i.plugin.PollPayoutStatus(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("polling payout status failed: %v", err) + otel.RecordError(span, err) + return models.PollPayoutStatusResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("polled payout status succeeded!") + + return resp, nil +} + +func (i *impl) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.CreateWebhooks", attribute.String("psp", i.plugin.Name()), attribute.String("connectorID", req.ConnectorID)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("creating webhooks...") + + resp, err := i.plugin.CreateWebhooks(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("creating webhooks failed: %v", err) + otel.RecordError(span, err) + return models.CreateWebhooksResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("created webhooks succeeded!") + + return resp, nil +} + +func (i *impl) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { + ctx, span := otel.StartSpan(ctx, "plugin.TranslateWebhook", attribute.String("psp", i.plugin.Name()), attribute.String("translateWebhookRequest.name", req.Name)) + defer span.End() + + i.logger.WithField("name", i.plugin.Name()).Info("translating webhook...") + + resp, err := i.plugin.TranslateWebhook(ctx, req) + if err != nil { + i.logger.WithField("name", i.plugin.Name()).Error("translating webhook failed: %v", err) + otel.RecordError(span, err) + return models.TranslateWebhookResponse{}, translateError(err) + } + + i.logger.WithField("name", i.plugin.Name()).Info("translated webhook succeeded!") + + return resp, nil +} + +var _ models.Plugin = &impl{} diff --git a/internal/events/account.go b/internal/events/account.go new file mode 100644 index 00000000..a74426c4 --- /dev/null +++ b/internal/events/account.go @@ -0,0 +1,53 @@ +package events + +import ( + "encoding/json" + "time" + + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/pkg/events" +) + +type AccountMessagePayload struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Reference string `json:"reference"` + Provider string `json:"provider"` + ConnectorID string `json:"connectorId"` + DefaultAsset string `json:"defaultAsset"` + AccountName string `json:"accountName"` + Type string `json:"type"` + Metadata map[string]string `json:"metadata"` + RawData json.RawMessage `json:"rawData"` +} + +func (e Events) NewEventSavedAccounts(account models.Account) publish.EventMessage { + payload := AccountMessagePayload{ + ID: account.ID.String(), + ConnectorID: account.ConnectorID.String(), + Provider: account.ConnectorID.Provider, + CreatedAt: account.CreatedAt, + Reference: account.Reference, + Type: string(account.Type), + Metadata: account.Metadata, + RawData: account.Raw, + } + + if account.DefaultAsset != nil { + payload.DefaultAsset = *account.DefaultAsset + } + + if account.Name != nil { + payload.AccountName = *account.Name + } + + return publish.EventMessage{ + IdempotencyKey: account.IdempotencyKey(), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeSavedAccounts, + Payload: payload, + } +} diff --git a/internal/events/balance.go b/internal/events/balance.go new file mode 100644 index 00000000..8fd1e136 --- /dev/null +++ b/internal/events/balance.go @@ -0,0 +1,41 @@ +package events + +import ( + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/pkg/events" +) + +type BalanceMessagePayload struct { + AccountID string `json:"accountID"` + ConnectorID string `json:"connectorId"` + Provider string `json:"provider"` + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Asset string `json:"asset"` + Balance *big.Int `json:"balance"` +} + +func (e Events) NewEventSavedBalances(balance models.Balance) publish.EventMessage { + payload := BalanceMessagePayload{ + AccountID: balance.AccountID.String(), + ConnectorID: balance.AccountID.ConnectorID.String(), + Provider: balance.AccountID.ConnectorID.Provider, + CreatedAt: balance.CreatedAt, + LastUpdatedAt: balance.LastUpdatedAt, + Asset: balance.Asset, + Balance: balance.Balance, + } + + return publish.EventMessage{ + IdempotencyKey: balance.IdempotencyKey(), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeSavedBalances, + Payload: payload, + } +} diff --git a/internal/events/bank_account.go b/internal/events/bank_account.go new file mode 100644 index 00000000..73411582 --- /dev/null +++ b/internal/events/bank_account.go @@ -0,0 +1,73 @@ +package events + +import ( + "time" + + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/pkg/events" +) + +type BankAccountMessagePayload struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Name string `json:"name"` + AccountNumber string `json:"accountNumber"` + IBAN string `json:"iban"` + SwiftBicCode string `json:"swiftBicCode"` + Country string `json:"country"` + RelatedAccounts []BankAccountRelatedAccountsPayload `json:"relatedAccounts"` +} + +type BankAccountRelatedAccountsPayload struct { + CreatedAt time.Time `json:"createdAt"` + AccountID string `json:"accountID"` + ConnectorID string `json:"connectorID"` + Provider string `json:"provider"` +} + +func (e Events) NewEventSavedBankAccounts(bankAccount models.BankAccount) publish.EventMessage { + bankAccount.Offuscate() + + payload := BankAccountMessagePayload{ + ID: bankAccount.ID.String(), + CreatedAt: bankAccount.CreatedAt, + Name: bankAccount.Name, + } + + if bankAccount.AccountNumber != nil { + payload.AccountNumber = *bankAccount.AccountNumber + } + + if bankAccount.IBAN != nil { + payload.IBAN = *bankAccount.IBAN + } + + if bankAccount.SwiftBicCode != nil { + payload.SwiftBicCode = *bankAccount.SwiftBicCode + } + + if bankAccount.Country != nil { + payload.Country = *bankAccount.Country + } + + for _, relatedAccount := range bankAccount.RelatedAccounts { + relatedAccount := BankAccountRelatedAccountsPayload{ + CreatedAt: relatedAccount.CreatedAt, + AccountID: relatedAccount.AccountID.String(), + Provider: relatedAccount.ConnectorID.Provider, + ConnectorID: relatedAccount.ConnectorID.String(), + } + + payload.RelatedAccounts = append(payload.RelatedAccounts, relatedAccount) + } + + return publish.EventMessage{ + IdempotencyKey: bankAccount.IdempotencyKey(), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeSavedBankAccount, + Payload: payload, + } +} diff --git a/internal/events/connector.go b/internal/events/connector.go new file mode 100644 index 00000000..306e3761 --- /dev/null +++ b/internal/events/connector.go @@ -0,0 +1,33 @@ +package events + +import ( + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/pkg/events" +) + +type connectorMessagePayload struct { + CreatedAt time.Time `json:"createdAt"` + ConnectorID string `json:"connectorId"` +} + +func (e Events) NewEventResetConnector(connectorID models.ConnectorID, at time.Time) publish.EventMessage { + return publish.EventMessage{ + IdempotencyKey: resetConnectorIdempotencyKey(connectorID, at), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeConnectorReset, + Payload: connectorMessagePayload{ + CreatedAt: at, + ConnectorID: connectorID.String(), + }, + } +} + +func resetConnectorIdempotencyKey(connectorID models.ConnectorID, at time.Time) string { + return fmt.Sprintf("%s-%s", connectorID.String(), at.Format(time.RFC3339Nano)) +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 00000000..b80ab01e --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,27 @@ +package events + +import ( + "context" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/formancehq/go-libs/v2/publish" + eventsdef "github.com/formancehq/payments/pkg/events" +) + +type Events struct { + publisher message.Publisher + + stackURL string +} + +func New(p message.Publisher, stackURL string) *Events { + return &Events{ + publisher: p, + stackURL: stackURL, + } +} + +func (e *Events) Publish(ctx context.Context, em publish.EventMessage) error { + return e.publisher.Publish(eventsdef.TopicPayments, + publish.NewMessage(ctx, em)) +} diff --git a/internal/events/payment.go b/internal/events/payment.go new file mode 100644 index 00000000..e16e7570 --- /dev/null +++ b/internal/events/payment.go @@ -0,0 +1,83 @@ +package events + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/api" + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/pkg/events" +) + +type paymentMessagePayload struct { + ID string `json:"id"` + ConnectorID string `json:"connectorId"` + Provider string `json:"provider"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Type string `json:"type"` + Status string `json:"status"` + Scheme string `json:"scheme"` + Asset string `json:"asset"` + SourceAccountID string `json:"sourceAccountId,omitempty"` + DestinationAccountID string `json:"destinationAccountId,omitempty"` + Links []api.Link `json:"links"` + RawData json.RawMessage `json:"rawData"` + + Amount *big.Int `json:"amount"` + Metadata map[string]string `json:"metadata"` +} + +func (e Events) NewEventSavedPayments(payment models.Payment, adjustment models.PaymentAdjustment) publish.EventMessage { + payload := paymentMessagePayload{ + ID: payment.ID.String(), + Reference: payment.Reference, + Type: payment.Type.String(), + Status: payment.Status.String(), + Amount: payment.Amount, + Scheme: payment.Scheme.String(), + Asset: payment.Asset, + CreatedAt: payment.CreatedAt, + ConnectorID: payment.ConnectorID.String(), + Provider: payment.ConnectorID.Provider, + SourceAccountID: func() string { + if payment.SourceAccountID == nil { + return "" + } + return payment.SourceAccountID.String() + }(), + DestinationAccountID: func() string { + if payment.DestinationAccountID == nil { + return "" + } + return payment.DestinationAccountID.String() + }(), + RawData: adjustment.Raw, + Metadata: payment.Metadata, + } + + if payment.SourceAccountID != nil { + payload.Links = append(payload.Links, api.Link{ + Name: "source_account", + URI: e.stackURL + "/api/payments/accounts/" + payment.SourceAccountID.String(), + }) + } + + if payment.DestinationAccountID != nil { + payload.Links = append(payload.Links, api.Link{ + Name: "destination_account", + URI: e.stackURL + "/api/payments/accounts/" + payment.DestinationAccountID.String(), + }) + } + + return publish.EventMessage{ + IdempotencyKey: adjustment.IdempotencyKey(), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeSavedPayments, + Payload: payload, + } +} diff --git a/internal/events/pool.go b/internal/events/pool.go new file mode 100644 index 00000000..3fcd2e1e --- /dev/null +++ b/internal/events/pool.go @@ -0,0 +1,58 @@ +package events + +import ( + "time" + + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/pkg/events" + "github.com/google/uuid" +) + +type poolMessagePayload struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + AccountIDs []string `json:"accountIDs"` +} + +func (e Events) NewEventSavedPool(pool models.Pool) publish.EventMessage { + payload := poolMessagePayload{ + ID: pool.ID.String(), + Name: pool.Name, + CreatedAt: pool.CreatedAt, + } + + payload.AccountIDs = make([]string, len(pool.PoolAccounts)) + for i, a := range pool.PoolAccounts { + payload.AccountIDs[i] = a.AccountID.String() + } + + return publish.EventMessage{ + IdempotencyKey: pool.IdempotencyKey(), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeSavedPool, + Payload: payload, + } +} + +type deletePoolMessagePayload struct { + CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` +} + +func (e Events) NewEventDeletePool(id uuid.UUID) publish.EventMessage { + return publish.EventMessage{ + IdempotencyKey: id.String(), + Date: time.Now().UTC(), + App: events.EventApp, + Version: events.EventVersion, + Type: events.EventTypeDeletePool, + Payload: deletePoolMessagePayload{ + CreatedAt: time.Now().UTC(), + ID: id.String(), + }, + } +} diff --git a/internal/messages/accounts.go b/internal/messages/accounts.go deleted file mode 100644 index 07636f9f..00000000 --- a/internal/messages/accounts.go +++ /dev/null @@ -1,49 +0,0 @@ -package messages - -import ( - "encoding/json" - "time" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type accountMessagePayload struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Reference string `json:"reference"` - ConnectorID string `json:"connectorId"` - Provider string `json:"provider"` - DefaultAsset string `json:"defaultAsset"` - AccountName string `json:"accountName"` - Type string `json:"type"` - RawData json.RawMessage `json:"rawData"` -} - -func (m *Messages) NewEventSavedAccounts(provider models.ConnectorProvider, account *models.Account) publish.EventMessage { - payload := accountMessagePayload{ - ID: account.ID.String(), - CreatedAt: account.CreatedAt, - Reference: account.Reference, - ConnectorID: account.ConnectorID.String(), - DefaultAsset: account.DefaultAsset.String(), - AccountName: account.AccountName, - Type: string(account.Type), - Provider: provider.String(), - RawData: account.RawData, - } - - if account.Type == models.AccountTypeExternalFormance { - payload.Type = models.AccountTypeExternal.String() - } - - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeSavedAccounts, - Payload: payload, - } -} diff --git a/internal/messages/balances.go b/internal/messages/balances.go deleted file mode 100644 index f65139c3..00000000 --- a/internal/messages/balances.go +++ /dev/null @@ -1,39 +0,0 @@ -package messages - -import ( - "math/big" - "time" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type balanceMessagePayload struct { - AccountID string `json:"accountID"` - ConnectorID string `json:"connectorId"` - Provider string `json:"provider"` - CreatedAt time.Time `json:"createdAt"` - Asset string `json:"asset"` - Balance *big.Int `json:"balance"` -} - -func (m *Messages) NewEventSavedBalances(balance *models.Balance) publish.EventMessage { - payload := balanceMessagePayload{ - CreatedAt: balance.CreatedAt, - ConnectorID: balance.ConnectorID.String(), - Provider: balance.ConnectorID.Provider.String(), - AccountID: balance.AccountID.String(), - Asset: balance.Asset.String(), - Balance: balance.Balance, - } - - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeSavedBalances, - Payload: payload, - } -} diff --git a/internal/messages/bank_account.go b/internal/messages/bank_account.go deleted file mode 100644 index a661f279..00000000 --- a/internal/messages/bank_account.go +++ /dev/null @@ -1,63 +0,0 @@ -package messages - -import ( - "time" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type bankAccountMessagePayload struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Name string `json:"name"` - AccountNumber string `json:"accountNumber"` - IBAN string `json:"iban"` - SwiftBicCode string `json:"swiftBicCode"` - Country string `json:"country"` - RelatedAccounts []bankAccountRelatedAccountsPayload `json:"adjustments"` -} - -type bankAccountRelatedAccountsPayload struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - AccountID string `json:"accountID"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` -} - -func (m *Messages) NewEventSavedBankAccounts(bankAccount *models.BankAccount) publish.EventMessage { - bankAccount.Offuscate() - - payload := bankAccountMessagePayload{ - ID: bankAccount.ID.String(), - CreatedAt: bankAccount.CreatedAt, - Name: bankAccount.Name, - AccountNumber: bankAccount.AccountNumber, - IBAN: bankAccount.IBAN, - SwiftBicCode: bankAccount.SwiftBicCode, - Country: bankAccount.Country, - } - - for _, relatedAccount := range bankAccount.RelatedAccounts { - relatedAccount := bankAccountRelatedAccountsPayload{ - ID: relatedAccount.ID.String(), - CreatedAt: relatedAccount.CreatedAt, - AccountID: relatedAccount.AccountID.String(), - Provider: relatedAccount.ConnectorID.Provider.String(), - ConnectorID: relatedAccount.ConnectorID.String(), - } - - payload.RelatedAccounts = append(payload.RelatedAccounts, relatedAccount) - } - - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeSavedBankAccount, - Payload: payload, - } -} diff --git a/internal/messages/connectors.go b/internal/messages/connectors.go deleted file mode 100644 index 0349b733..00000000 --- a/internal/messages/connectors.go +++ /dev/null @@ -1,28 +0,0 @@ -package messages - -import ( - "time" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type connectorMessagePayload struct { - CreatedAt time.Time `json:"createdAt"` - ConnectorID string `json:"connectorId"` -} - -func (m *Messages) NewEventResetConnector(connectorID models.ConnectorID) publish.EventMessage { - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeConnectorReset, - Payload: connectorMessagePayload{ - CreatedAt: time.Now().UTC(), - ConnectorID: connectorID.String(), - }, - } -} diff --git a/internal/messages/messages.go b/internal/messages/messages.go deleted file mode 100644 index 0da1b6c2..00000000 --- a/internal/messages/messages.go +++ /dev/null @@ -1,11 +0,0 @@ -package messages - -type Messages struct { - stackURL string -} - -func NewMessages(stackURL string) *Messages { - return &Messages{ - stackURL: stackURL, - } -} diff --git a/internal/messages/payments.go b/internal/messages/payments.go deleted file mode 100644 index 8d358542..00000000 --- a/internal/messages/payments.go +++ /dev/null @@ -1,93 +0,0 @@ -package messages - -import ( - "encoding/json" - "math/big" - "time" - - "github.com/formancehq/go-libs/api" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type paymentMessagePayload struct { - ID string `json:"id"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - ConnectorID string `json:"connectorId"` - Provider string `json:"provider"` - Type models.PaymentType `json:"type"` - Status models.PaymentStatus `json:"status"` - Scheme models.PaymentScheme `json:"scheme"` - Asset models.Asset `json:"asset"` - SourceAccountID string `json:"sourceAccountId,omitempty"` - DestinationAccountID string `json:"destinationAccountId,omitempty"` - Links []api.Link `json:"links"` - RawData json.RawMessage `json:"rawData"` - - // TODO: Remove 'initialAmount' once frontend has switched to 'amount - InitialAmount *big.Int `json:"initialAmount"` - Amount *big.Int `json:"amount"` - Metadata map[string]string `json:"metadata"` -} - -func (m *Messages) NewEventSavedPayments(provider models.ConnectorProvider, payment *models.Payment) publish.EventMessage { - payload := paymentMessagePayload{ - ID: payment.ID.String(), - Reference: payment.Reference, - Type: payment.Type, - Status: payment.Status, - InitialAmount: payment.InitialAmount, - Amount: payment.Amount, - Scheme: payment.Scheme, - Asset: payment.Asset, - CreatedAt: payment.CreatedAt, - ConnectorID: payment.ConnectorID.String(), - Provider: provider.String(), - SourceAccountID: func() string { - if payment.SourceAccountID == nil { - return "" - } - return payment.SourceAccountID.String() - }(), - DestinationAccountID: func() string { - if payment.DestinationAccountID == nil { - return "" - } - return payment.DestinationAccountID.String() - }(), - RawData: payment.RawData, - Metadata: func() map[string]string { - ret := make(map[string]string) - for _, m := range payment.Metadata { - ret[m.Key] = m.Value - } - return ret - }(), - } - - if payment.SourceAccountID != nil { - payload.Links = append(payload.Links, api.Link{ - Name: "source_account", - URI: m.stackURL + "/api/payments/accounts/" + payment.SourceAccountID.String(), - }) - } - - if payment.DestinationAccountID != nil { - payload.Links = append(payload.Links, api.Link{ - Name: "destination_account", - URI: m.stackURL + "/api/payments/accounts/" + payment.DestinationAccountID.String(), - }) - } - - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeSavedPayments, - Payload: payload, - } -} diff --git a/internal/messages/pools.go b/internal/messages/pools.go deleted file mode 100644 index 9269c3f1..00000000 --- a/internal/messages/pools.go +++ /dev/null @@ -1,57 +0,0 @@ -package messages - -import ( - "time" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" - "github.com/google/uuid" -) - -type poolMessagePayload struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt time.Time `json:"createdAt"` - AccountIDs []string `json:"accountIDs"` -} - -func (m *Messages) NewEventSavedPool(pool *models.Pool) publish.EventMessage { - payload := poolMessagePayload{ - ID: pool.ID.String(), - Name: pool.Name, - CreatedAt: pool.CreatedAt, - } - - payload.AccountIDs = make([]string, len(pool.PoolAccounts)) - for i, a := range pool.PoolAccounts { - payload.AccountIDs[i] = a.AccountID.String() - } - - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeSavedPool, - Payload: payload, - } -} - -type deletePoolMessagePayload struct { - CreatedAt time.Time `json:"createdAt"` - ID string `json:"id"` -} - -func (m *Messages) NewEventDeletePool(id uuid.UUID) publish.EventMessage { - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeDeletePool, - Payload: deletePoolMessagePayload{ - CreatedAt: time.Now().UTC(), - ID: id.String(), - }, - } -} diff --git a/internal/messages/transfer_initiations.go b/internal/messages/transfer_initiations.go deleted file mode 100644 index 20bcf82d..00000000 --- a/internal/messages/transfer_initiations.go +++ /dev/null @@ -1,97 +0,0 @@ -package messages - -import ( - "math/big" - "time" - - "github.com/formancehq/go-libs/publish" - - "github.com/formancehq/payments/internal/models" - "github.com/formancehq/payments/pkg/events" -) - -type transferInitiationsPaymentsMessagePayload struct { - TransferInitiationID string `json:"transferInitiationId"` - PaymentID string `json:"paymentId"` - CreatedAt time.Time `json:"createdAt"` - Status string `json:"status"` - Error string `json:"error"` -} - -type transferInitiationsMessagePayload struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - ScheduleAt time.Time `json:"scheduledAt"` - ConnectorID string `json:"connectorId"` - Provider string `json:"provider"` - Description string `json:"description"` - Type string `json:"type"` - SourceAccountID string `json:"sourceAccountId"` - DestinationAccountID string `json:"destinationAccountId"` - Amount *big.Int `json:"amount"` - Asset models.Asset `json:"asset"` - Attempts int `json:"attempts"` - Status string `json:"status"` - Error string `json:"error"` - RelatedPayments []*transferInitiationsPaymentsMessagePayload `json:"relatedPayments"` -} - -func (m *Messages) NewEventSavedTransferInitiations(tf *models.TransferInitiation) publish.EventMessage { - payload := transferInitiationsMessagePayload{ - ID: tf.ID.String(), - CreatedAt: tf.CreatedAt, - ScheduleAt: tf.ScheduledAt, - ConnectorID: tf.ConnectorID.String(), - Provider: tf.Provider.String(), - Description: tf.Description, - Type: tf.Type.String(), - SourceAccountID: tf.SourceAccountID.String(), - DestinationAccountID: tf.DestinationAccountID.String(), - Amount: tf.Amount, - Asset: tf.Asset, - Attempts: len(tf.RelatedAdjustments), - } - - if len(tf.RelatedAdjustments) > 0 { - // Take the status and error from the last adjustment - payload.Status = tf.RelatedAdjustments[0].Status.String() - payload.Error = tf.RelatedAdjustments[0].Error - } - - payload.RelatedPayments = make([]*transferInitiationsPaymentsMessagePayload, len(tf.RelatedPayments)) - for i, p := range tf.RelatedPayments { - payload.RelatedPayments[i] = &transferInitiationsPaymentsMessagePayload{ - TransferInitiationID: p.TransferInitiationID.String(), - PaymentID: p.PaymentID.String(), - CreatedAt: p.CreatedAt, - Status: p.Status.String(), - Error: p.Error, - } - } - - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeSavedTransferInitiation, - Payload: payload, - } -} - -type deleteTransferInitiationMessagePayload struct { - CreatedAt time.Time `json:"createdAt"` - ID string `json:"id"` -} - -func (m *Messages) NewEventDeleteTransferInitiation(id models.TransferInitiationID) publish.EventMessage { - return publish.EventMessage{ - Date: time.Now().UTC(), - App: events.EventApp, - Version: events.EventVersion, - Type: events.EventTypeDeleteTransferInitiation, - Payload: deleteTransferInitiationMessagePayload{ - CreatedAt: time.Now().UTC(), - ID: id.String(), - }, - } -} diff --git a/internal/models/account.go b/internal/models/account.go deleted file mode 100644 index e58ee405..00000000 --- a/internal/models/account.go +++ /dev/null @@ -1,128 +0,0 @@ -package models - -import ( - "database/sql/driver" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/gibson042/canonicaljson-go" - "github.com/uptrace/bun" -) - -type Account struct { - bun.BaseModel `bun:"accounts.account"` - - ID AccountID `bun:",pk,type:character varying,nullzero"` - ConnectorID ConnectorID `bun:",type:character varying"` - CreatedAt time.Time `bun:",nullzero"` - Reference string - DefaultAsset Asset `bun:"default_currency"` // Is optional and default to '' - AccountName string // Is optional and default to '' - Type AccountType - Metadata map[string]string - - RawData json.RawMessage - - PoolAccounts []*PoolAccounts `bun:"rel:has-many,join:id=account_id"` -} - -type AccountType string - -const ( - AccountTypeUnknown AccountType = "UNKNOWN" - // Refers to an account that is internal to the psp, an account that we - // can actually fetch the balance. - AccountTypeInternal AccountType = "INTERNAL" - // Refers to an external accounts such as user's bank accounts. - AccountTypeExternal AccountType = "EXTERNAL" - // Refers to an external accounts created inside formance database. - // This is used only internally and will be transformed to EXTERNAL when - // returned to the user. - AccountTypeExternalFormance AccountType = "EXTERNAL_FORMANCE" -) - -func (at AccountType) String() string { - return string(at) -} - -func AccountTypeFromString(t string) (AccountType, error) { - switch t { - case AccountTypeInternal.String(): - return AccountTypeInternal, nil - case AccountTypeExternal.String(): - return AccountTypeExternal, nil - case AccountTypeExternalFormance.String(): - return AccountTypeExternalFormance, nil - } - - return AccountTypeUnknown, fmt.Errorf("unknown account type: %s", t) -} - -type AccountID struct { - Reference string - ConnectorID ConnectorID -} - -func (aid *AccountID) String() string { - if aid == nil || aid.Reference == "" { - return "" - } - - data, err := canonicaljson.Marshal(aid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) -} - -func AccountIDFromString(value string) (*AccountID, error) { - data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) - if err != nil { - return nil, err - } - ret := AccountID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return nil, err - } - - return &ret, nil -} - -func MustAccountIDFromString(value string) AccountID { - id, err := AccountIDFromString(value) - if err != nil { - panic(err) - } - return *id -} - -func (aid AccountID) Value() (driver.Value, error) { - return aid.String(), nil -} - -func (aid *AccountID) Scan(value interface{}) error { - if value == nil { - return errors.New("account id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := AccountIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse account id %s: %v", v, err) - } - - *aid = *id - return nil - } - } - - return fmt.Errorf("failed to scan account id: %v", value) -} diff --git a/internal/models/account_id.go b/internal/models/account_id.go new file mode 100644 index 00000000..8b3d5d0a --- /dev/null +++ b/internal/models/account_id.go @@ -0,0 +1,78 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type AccountID struct { + Reference string + ConnectorID ConnectorID +} + +func (aid *AccountID) String() string { + if aid == nil || aid.Reference == "" { + return "" + } + + data, err := canonicaljson.Marshal(aid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func AccountIDFromString(value string) (AccountID, error) { + ret := AccountID{} + + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return ret, err + } + + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return ret, err + } + + return ret, nil +} + +func MustAccountIDFromString(value string) AccountID { + id, err := AccountIDFromString(value) + if err != nil { + panic(err) + } + return id +} + +func (aid AccountID) Value() (driver.Value, error) { + return aid.String(), nil +} + +func (aid *AccountID) Scan(value interface{}) error { + if value == nil { + return errors.New("account id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := AccountIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse account id %s: %v", v, err) + } + + *aid = id + return nil + } + } + + return fmt.Errorf("failed to scan account id: %v", value) +} diff --git a/internal/models/account_type.go b/internal/models/account_type.go new file mode 100644 index 00000000..0428e075 --- /dev/null +++ b/internal/models/account_type.go @@ -0,0 +1,12 @@ +package models + +type AccountType string + +const ( + ACCOUNT_TYPE_UNKNOWN AccountType = "UNKNOWN" + // Internal accounts refers to user's digital e-wallets. It serves as a + // secure storage for funds within the payments provider environment. + ACCOUNT_TYPE_INTERNAL AccountType = "INTERNAL" + // External accounts represents actual bank accounts of the user. + ACCOUNT_TYPE_EXTERNAL AccountType = "EXTERNAL" +) diff --git a/internal/models/accounts.go b/internal/models/accounts.go new file mode 100644 index 00000000..a5e4bb9b --- /dev/null +++ b/internal/models/accounts.go @@ -0,0 +1,159 @@ +package models + +import ( + "encoding/json" + "time" +) + +// Internal struct used by the plugins +type PSPAccount struct { + // PSP reference of the account. Should be unique. + Reference string + + // Account's creation date + CreatedAt time.Time + + // Optional, human readable name of the account (if existing) + Name *string + // Optional, if provided the default asset of the account + // in minor currencies unit. + DefaultAsset *string + + // Additional metadata + Metadata map[string]string + + // PSP response in raw + Raw json.RawMessage +} + +type Account struct { + // Unique Account ID generated from account information + ID AccountID `json:"id"` + // Related Connector ID + ConnectorID ConnectorID `json:"connectorID"` + + // PSP reference of the account. Should be unique. + Reference string `json:"reference"` + + // Account's creation date + CreatedAt time.Time `json:"createdAt"` + + // Type of account: INTERNAL, EXTERNAL... + Type AccountType `json:"type"` + + // Optional, human readable name of the account (if existing) + Name *string `json:"name"` + // Optional, if provided the default asset of the account + // in minor currencies unit. + DefaultAsset *string `json:"defaultAsset"` + + // Additional metadata + Metadata map[string]string `json:"metadata"` + + // PSP response in raw + Raw json.RawMessage `json:"raw"` +} + +func (a Account) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Type AccountType `json:"type"` + Name *string `json:"name"` + DefaultAsset *string `json:"defaultAsset"` + Metadata map[string]string `json:"metadata"` + Raw json.RawMessage `json:"raw"` + }{ + ID: a.ID.String(), + ConnectorID: a.ConnectorID.String(), + Reference: a.Reference, + CreatedAt: a.CreatedAt, + Type: a.Type, + Name: a.Name, + DefaultAsset: a.DefaultAsset, + Metadata: a.Metadata, + Raw: a.Raw, + }) +} + +func (a *Account) IdempotencyKey() string { + return a.ID.String() +} + +func (a *Account) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Type AccountType `json:"type"` + Name *string `json:"name"` + DefaultAsset *string `json:"defaultAsset"` + Metadata map[string]string `json:"metadata"` + Raw json.RawMessage `json:"raw"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := AccountIDFromString(aux.ID) + if err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + a.ID = id + a.ConnectorID = connectorID + a.Reference = aux.Reference + a.CreatedAt = aux.CreatedAt + a.Type = aux.Type + a.Name = aux.Name + a.DefaultAsset = aux.DefaultAsset + a.Metadata = aux.Metadata + a.Raw = aux.Raw + + return nil +} + +func FromPSPAccount(from PSPAccount, accountType AccountType, connectorID ConnectorID) Account { + return Account{ + ID: AccountID{ + Reference: from.Reference, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Reference: from.Reference, + CreatedAt: from.CreatedAt, + Type: accountType, + Name: from.Name, + DefaultAsset: from.DefaultAsset, + Metadata: from.Metadata, + Raw: from.Raw, + } +} + +func FromPSPAccounts(from []PSPAccount, accountType AccountType, connectorID ConnectorID) []Account { + accounts := make([]Account, 0, len(from)) + for _, a := range from { + accounts = append(accounts, FromPSPAccount(a, accountType, connectorID)) + } + return accounts +} + +func ToPSPAccount(from *Account) *PSPAccount { + return &PSPAccount{ + Reference: from.Reference, + CreatedAt: from.CreatedAt, + Name: from.Name, + DefaultAsset: from.DefaultAsset, + Metadata: from.Metadata, + Raw: from.Raw, + } +} diff --git a/internal/models/balance.go b/internal/models/balance.go deleted file mode 100644 index 4cc10169..00000000 --- a/internal/models/balance.go +++ /dev/null @@ -1,19 +0,0 @@ -package models - -import ( - "math/big" - "time" - - "github.com/uptrace/bun" -) - -type Balance struct { - bun.BaseModel `bun:"accounts.balances"` - - AccountID AccountID `bun:"type:character varying,nullzero"` - Asset Asset `bun:"currency"` - Balance *big.Int `bun:"type:numeric"` - CreatedAt time.Time - LastUpdatedAt time.Time - ConnectorID ConnectorID `bun:"-"` -} diff --git a/internal/models/balances.go b/internal/models/balances.go new file mode 100644 index 00000000..5d5e1ad9 --- /dev/null +++ b/internal/models/balances.go @@ -0,0 +1,128 @@ +package models + +import ( + "encoding/base64" + "encoding/json" + "math/big" + "time" + + "github.com/gibson042/canonicaljson-go" +) + +type PSPBalance struct { + // PSP account reference of the balance. + AccountReference string + + // Balance Creation date. + CreatedAt time.Time + + // Balance amount. + Amount *big.Int + + // Currency. Should be in minor currencies unit. + // For example: USD/2 + Asset string +} + +type Balance struct { + // Balance related formance account id + AccountID AccountID `json:"accountID"` + // Balance created at + CreatedAt time.Time `json:"createdAt"` + // Balance last updated at + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + + // Currency. Should be in minor currencies unit. + Asset string `json:"asset"` + // Balance amount. + Balance *big.Int `json:"balance"` +} + +func (b *Balance) IdempotencyKey() string { + var ik = struct { + AccountID string + CreatedAt int64 + LastUpdatedAt int64 + }{ + AccountID: b.AccountID.String(), + CreatedAt: b.CreatedAt.UnixNano(), + LastUpdatedAt: b.LastUpdatedAt.UnixNano(), + } + + data, err := canonicaljson.Marshal(ik) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func (b Balance) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + AccountID string `json:"accountID"` + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + + Asset string `json:"asset"` + Balance *big.Int `json:"balance"` + }{ + AccountID: b.AccountID.String(), + CreatedAt: b.CreatedAt, + LastUpdatedAt: b.LastUpdatedAt, + Asset: b.Asset, + Balance: b.Balance, + }) +} + +func (b *Balance) UnmarshalJSON(data []byte) error { + var aux struct { + AccountID string `json:"accountID"` + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Asset string `json:"asset"` + Balance *big.Int `json:"balance"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + accountID, err := AccountIDFromString(aux.AccountID) + if err != nil { + return err + } + + b.AccountID = accountID + b.CreatedAt = aux.CreatedAt + b.LastUpdatedAt = aux.LastUpdatedAt + b.Asset = aux.Asset + b.Balance = aux.Balance + + return nil +} + +type AggregatedBalance struct { + Asset string `json:"asset"` + Amount *big.Int `json:"amount"` +} + +func FromPSPBalance(from PSPBalance, connectorID ConnectorID) Balance { + return Balance{ + AccountID: AccountID{ + Reference: from.AccountReference, + ConnectorID: connectorID, + }, + CreatedAt: from.CreatedAt, + LastUpdatedAt: from.CreatedAt, + Asset: from.Asset, + Balance: from.Amount, + } +} + +func FromPSPBalances(from []PSPBalance, connectorID ConnectorID) []Balance { + balances := make([]Balance, 0, len(from)) + for _, b := range from { + balances = append(balances, FromPSPBalance(b, connectorID)) + } + return balances +} diff --git a/internal/models/bank_account.go b/internal/models/bank_account.go deleted file mode 100644 index 53ada264..00000000 --- a/internal/models/bank_account.go +++ /dev/null @@ -1,67 +0,0 @@ -package models - -import ( - "errors" - "strings" - "time" - - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -type BankAccount struct { - bun.BaseModel `bun:"accounts.bank_account"` - - ID uuid.UUID `bun:",pk,nullzero"` - CreatedAt time.Time `bun:",nullzero"` - Name string - AccountNumber string `bun:"decrypted_account_number,scanonly"` - IBAN string `bun:"decrypted_iban,scanonly"` - SwiftBicCode string `bun:"decrypted_swift_bic_code,scanonly"` - Country string `bun:"country"` - Metadata map[string]string - - RelatedAccounts []*BankAccountRelatedAccount `bun:"rel:has-many,join:id=bank_account_id"` -} - -func (a *BankAccount) Offuscate() error { - if a.IBAN != "" { - length := len(a.IBAN) - if length < 8 { - return errors.New("IBAN is not valid") - } - - a.IBAN = a.IBAN[:4] + strings.Repeat("*", length-8) + a.IBAN[length-4:] - } - - if a.AccountNumber != "" { - length := len(a.AccountNumber) - if length < 5 { - return errors.New("Account number is not valid") - } - - a.AccountNumber = a.AccountNumber[:2] + strings.Repeat("*", length-5) + a.AccountNumber[length-3:] - } - - return nil -} - -type BankAccountRelatedAccount struct { - bun.BaseModel `bun:"accounts.bank_account_related_accounts"` - - ID uuid.UUID `bun:",pk,nullzero"` - CreatedAt time.Time `bun:",nullzero"` - BankAccountID uuid.UUID `bun:",nullzero"` - ConnectorID ConnectorID `bun:",nullzero"` - AccountID AccountID `bun:",nullzero"` -} - -const ( - bankAccountOwnerNamespace = formanceMetadataSpecNamespace + "owner/" - - BankAccountOwnerAddressLine1MetadataKey = bankAccountOwnerNamespace + "addressLine1" - BankAccountOwnerAddressLine2MetadataKey = bankAccountOwnerNamespace + "addressLine2" - BankAccountOwnerCityMetadataKey = bankAccountOwnerNamespace + "city" - BankAccountOwnerRegionMetadataKey = bankAccountOwnerNamespace + "region" - BankAccountOwnerPostalCodeMetadataKey = bankAccountOwnerNamespace + "postalCode" -) diff --git a/internal/models/bank_accounts.go b/internal/models/bank_accounts.go new file mode 100644 index 00000000..78dec1d9 --- /dev/null +++ b/internal/models/bank_accounts.go @@ -0,0 +1,60 @@ +package models + +import ( + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + bankAccountOwnerNamespace = formanceMetadataSpecNamespace + "owner/" + + BankAccountOwnerAddressLine1MetadataKey = bankAccountOwnerNamespace + "addressLine1" + BankAccountOwnerAddressLine2MetadataKey = bankAccountOwnerNamespace + "addressLine2" + BankAccountOwnerCityMetadataKey = bankAccountOwnerNamespace + "city" + BankAccountOwnerRegionMetadataKey = bankAccountOwnerNamespace + "region" + BankAccountOwnerPostalCodeMetadataKey = bankAccountOwnerNamespace + "postalCode" +) + +type BankAccount struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Name string `json:"name"` + + AccountNumber *string `json:"accountNumber"` + IBAN *string `json:"iban"` + SwiftBicCode *string `json:"swiftBicCode"` + Country *string `json:"country"` + + Metadata map[string]string `json:"metadata"` + + RelatedAccounts []BankAccountRelatedAccount `json:"relatedAccounts"` +} + +func (b *BankAccount) IdempotencyKey() string { + return b.ID.String() +} + +func (a *BankAccount) Offuscate() error { + if a.IBAN != nil { + length := len(*a.IBAN) + if length < 8 { + return errors.New("IBAN is not valid") + } + + *a.IBAN = (*a.IBAN)[:4] + strings.Repeat("*", length-8) + (*a.IBAN)[length-4:] + } + + if a.AccountNumber != nil { + length := len(*a.AccountNumber) + if length < 5 { + return errors.New("Account number is not valid") + } + + *a.AccountNumber = (*a.AccountNumber)[:2] + strings.Repeat("*", length-5) + (*a.AccountNumber)[length-3:] + } + + return nil +} diff --git a/internal/models/bank_accounts_related_accounts.go b/internal/models/bank_accounts_related_accounts.go new file mode 100644 index 00000000..077bffdc --- /dev/null +++ b/internal/models/bank_accounts_related_accounts.go @@ -0,0 +1,59 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type BankAccountRelatedAccount struct { + BankAccountID uuid.UUID `json:"bankAccountID"` + AccountID AccountID `json:"accountID"` + ConnectorID ConnectorID `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` +} + +func (b BankAccountRelatedAccount) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + BankAccountID uuid.UUID `json:"bankAccountID"` + AccountID string `json:"accountID"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + }{ + BankAccountID: b.BankAccountID, + AccountID: b.AccountID.String(), + ConnectorID: b.ConnectorID.String(), + CreatedAt: b.CreatedAt, + }) +} + +func (b *BankAccountRelatedAccount) UnmarshalJSON(data []byte) error { + var aux struct { + BankAccountID uuid.UUID `json:"bankAccountID"` + AccountID string `json:"accountID"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + accountID, err := AccountIDFromString(aux.AccountID) + if err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + b.BankAccountID = aux.BankAccountID + b.AccountID = accountID + b.ConnectorID = connectorID + b.CreatedAt = aux.CreatedAt + + return nil +} diff --git a/internal/models/capabilities.go b/internal/models/capabilities.go new file mode 100644 index 00000000..2f1ce71d --- /dev/null +++ b/internal/models/capabilities.go @@ -0,0 +1,133 @@ +package models + +import ( + "database/sql/driver" + "errors" + "fmt" +) + +type Capability int + +const ( + CAPABILITY_FETCH_UNKNOWN Capability = iota + + // CAPABILITY_FETCH_X indicates that the connector can fetch the object X + CAPABILITY_FETCH_ACCOUNTS + CAPABILITY_FETCH_BALANCES + CAPABILITY_FETCH_EXTERNAL_ACCOUNTS + CAPABILITY_FETCH_PAYMENTS + CAPABILITY_FETCH_OTHERS + + // Webhooks capabilities indicates that the connector can create, manage and + // receive webhooks from the connector + CAPABILITY_CREATE_WEBHOOKS + CAPABILITY_TRANSLATE_WEBHOOKS + + // Creation capabilities indicates that the connector supports the creation + // of the object + CAPABILITY_CREATE_BANK_ACCOUNT + CAPABILITY_CREATE_TRANSFER + CAPABILITY_CREATE_PAYOUT + + // Thanks to the formance API, we can create formance object of an account + // and a payment without sending anything to the connector. + // It can be useful for testing, but also for the generic connector if the + // user don't want to use our platform to connect directly to their PSP, but + // still want us to record the accounts and payments. + CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION + CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION +) + +func (t Capability) String() string { + switch t { + case CAPABILITY_FETCH_ACCOUNTS: + return "FETCH_ACCOUNTS" + case CAPABILITY_FETCH_BALANCES: + return "FETCH_BALANCES" + case CAPABILITY_FETCH_EXTERNAL_ACCOUNTS: + return "FETCH_EXTERNAL_ACCOUNTS" + case CAPABILITY_FETCH_PAYMENTS: + return "FETCH_PAYMENTS" + case CAPABILITY_FETCH_OTHERS: + return "FETCH_OTHERS" + + case CAPABILITY_CREATE_WEBHOOKS: + return "CREATE_WEBHOOKS" + case CAPABILITY_TRANSLATE_WEBHOOKS: + return "TRANSLATE_WEBHOOKS" + + case CAPABILITY_CREATE_BANK_ACCOUNT: + return "CREATE_BANK_ACCOUNT" + case CAPABILITY_CREATE_TRANSFER: + return "CREATE_TRANSFER" + case CAPABILITY_CREATE_PAYOUT: + return "CREATE_PAYOUT" + + case CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION: + return "ALLOW_FORMANCE_ACCOUNT_CREATION" + case CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION: + return "ALLOW_FORMANCE_PAYMENT_CREATION" + + default: + return "UNKNOWN" + } +} + +func (t Capability) Value() (driver.Value, error) { + res := t.String() + if res == "UNKNOWN" { + return nil, fmt.Errorf("unknown capability") + } + return res, nil +} + +func (t *Capability) Scan(value interface{}) error { + if value == nil { + return errors.New("capability is nil") + } + + s, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert capability") + } + + v, ok := s.(string) + if !ok { + return fmt.Errorf("failed to cast capability") + } + + switch v { + case "FETCH_ACCOUNTS": + *t = CAPABILITY_FETCH_ACCOUNTS + case "FETCH_BALANCES": + *t = CAPABILITY_FETCH_BALANCES + case "FETCH_EXTERNAL_ACCOUNTS": + *t = CAPABILITY_FETCH_EXTERNAL_ACCOUNTS + case "FETCH_PAYMENTS": + *t = CAPABILITY_FETCH_PAYMENTS + case "FETCH_OTHERS": + *t = CAPABILITY_FETCH_OTHERS + + case "CREATE_WEBHOOKS": + *t = CAPABILITY_CREATE_WEBHOOKS + case "TRANSLATE_WEBHOOKS": + *t = CAPABILITY_TRANSLATE_WEBHOOKS + + case "CREATE_BANK_ACCOUNT": + *t = CAPABILITY_CREATE_BANK_ACCOUNT + case "CREATE_TRANSFER": + *t = CAPABILITY_CREATE_TRANSFER + case "CREATE_PAYOUT": + *t = CAPABILITY_CREATE_PAYOUT + + case "ALLOW_FORMANCE_ACCOUNT_CREATION": + *t = CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION + case "ALLOW_FORMANCE_PAYMENT_CREATION": + *t = CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION + + default: + return fmt.Errorf("unknown capability") + } + + return nil +} diff --git a/internal/models/config.go b/internal/models/config.go new file mode 100644 index 00000000..0ca34733 --- /dev/null +++ b/internal/models/config.go @@ -0,0 +1,82 @@ +package models + +import ( + "encoding/json" + "errors" + "time" +) + +const ( + defaultPollingPeriod = 2 * time.Minute + defaultPageSize = 25 +) + +type Config struct { + Name string `json:"name"` + PollingPeriod time.Duration `json:"pollingPeriod"` + PageSize int `json:"pageSize"` +} + +func (c Config) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Name string `json:"name"` + PollingPeriod string `json:"pollingPeriod"` + PageSize int `json:"pageSize"` + }{ + Name: c.Name, + PollingPeriod: c.PollingPeriod.String(), + PageSize: c.PageSize, + }) +} + +func (c *Config) UnmarshalJSON(data []byte) error { + var raw struct { + Name string `json:"name"` + PollingPeriod string `json:"pollingPeriod"` + PageSize int `json:"pageSize"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + pollingPeriod := defaultPollingPeriod + if raw.PollingPeriod != "" { + p, err := time.ParseDuration(raw.PollingPeriod) + if err != nil { + return err + } + pollingPeriod = p + } + + c.Name = raw.Name + + if pollingPeriod > 0 { + c.PollingPeriod = pollingPeriod + } + + if raw.PageSize > 0 { + c.PageSize = raw.PageSize + } + + return nil +} + +func (c Config) Validate() error { + if c.Name == "" { + return errors.New("name is required") + } + + if c.PollingPeriod.Seconds() < 30 { + return errors.New("polling period must be at least 30 seconds") + } + + return nil +} + +func DefaultConfig() Config { + return Config{ + PollingPeriod: defaultPollingPeriod, + PageSize: defaultPageSize, + } +} diff --git a/internal/models/config_test.go b/internal/models/config_test.go new file mode 100644 index 00000000..9578c197 --- /dev/null +++ b/internal/models/config_test.go @@ -0,0 +1,39 @@ +package models + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + t.Run("missing name", func(t *testing.T) { + config := Config{} + err := config.Validate() + require.Error(t, err) + require.Equal(t, errors.New("name is required"), err) + }) + + t.Run("invalid polling period", func(t *testing.T) { + config := Config{ + Name: "test", + PollingPeriod: 2 * time.Second, + } + err := config.Validate() + require.Error(t, err) + require.Equal(t, errors.New("polling period must be at least 30 seconds"), err) + }) + + t.Run("valid config", func(t *testing.T) { + config := Config{ + Name: "test", + PollingPeriod: 30 * time.Second, + } + err := config.Validate() + require.NoError(t, err) + }) +} diff --git a/internal/models/connector.go b/internal/models/connector.go deleted file mode 100644 index ad5ea0a6..00000000 --- a/internal/models/connector.go +++ /dev/null @@ -1,203 +0,0 @@ -package models - -import ( - "database/sql/driver" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/gibson042/canonicaljson-go" - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -type Connector struct { - bun.BaseModel `bun:"connectors.connector"` - - ID ConnectorID `bun:",pk,nullzero"` - Name string - CreatedAt time.Time `bun:",nullzero"` - Provider ConnectorProvider - - // EncryptedConfig is a PGP-encrypted JSON string. - EncryptedConfig string `bun:"config"` - - // Config is a decrypted config. It is not stored in the database. - Config json.RawMessage `bun:"decrypted_config,scanonly"` - - Tasks []*Task `bun:"rel:has-many,join:id=connector_id"` -} - -type ConnectorID struct { - Reference uuid.UUID - Provider ConnectorProvider -} - -func (cid *ConnectorID) String() string { - if cid == nil || cid.Reference == uuid.Nil { - return "" - } - - data, err := canonicaljson.Marshal(cid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) -} - -func ConnectorIDFromString(value string) (ConnectorID, error) { - data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) - if err != nil { - return ConnectorID{}, err - } - ret := ConnectorID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return ConnectorID{}, err - } - - return ret, nil -} - -func MustConnectorIDFromString(value string) ConnectorID { - id, err := ConnectorIDFromString(value) - if err != nil { - panic(err) - } - return id -} - -func (cid ConnectorID) Value() (driver.Value, error) { - return cid.String(), nil -} - -func (cid *ConnectorID) Scan(value interface{}) error { - if value == nil { - return errors.New("connector id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := ConnectorIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse connector id %s: %v", v, err) - } - - *cid = id - return nil - } - } - - return fmt.Errorf("failed to scan connector id: %v", value) -} - -func (c Connector) String() string { - c.EncryptedConfig = "****" - c.Config = nil - - var t any = c - - return fmt.Sprintf("%+v", t) -} - -type ConnectorProvider string - -const ( - ConnectorProviderBankingCircle ConnectorProvider = "BANKING-CIRCLE" - ConnectorProviderCurrencyCloud ConnectorProvider = "CURRENCY-CLOUD" - ConnectorProviderDummyPay ConnectorProvider = "DUMMY-PAY" - ConnectorProviderModulr ConnectorProvider = "MODULR" - ConnectorProviderStripe ConnectorProvider = "STRIPE" - ConnectorProviderWise ConnectorProvider = "WISE" - ConnectorProviderMangopay ConnectorProvider = "MANGOPAY" - ConnectorProviderMoneycorp ConnectorProvider = "MONEYCORP" - ConnectorProviderAtlar ConnectorProvider = "ATLAR" - ConnectorProviderAdyen ConnectorProvider = "ADYEN" - ConnectorProviderGeneric ConnectorProvider = "GENERIC" -) - -func (p ConnectorProvider) String() string { - return string(p) -} - -func (p ConnectorProvider) StringLower() string { - return strings.ToLower(string(p)) -} - -func ConnectorProviderFromString(s string) (ConnectorProvider, error) { - switch s { - case "BANKING-CIRCLE": - return ConnectorProviderBankingCircle, nil - case "CURRENCY-CLOUD": - return ConnectorProviderCurrencyCloud, nil - case "DUMMY-PAY": - return ConnectorProviderDummyPay, nil - case "MODULR": - return ConnectorProviderModulr, nil - case "STRIPE": - return ConnectorProviderStripe, nil - case "WISE": - return ConnectorProviderWise, nil - case "MANGOPAY": - return ConnectorProviderMangopay, nil - case "MONEYCORP": - return ConnectorProviderMoneycorp, nil - case "ATLAR": - return ConnectorProviderAtlar, nil - case "ADYEN": - return ConnectorProviderAdyen, nil - case "GENERIC": - return ConnectorProviderGeneric, nil - default: - return "", errors.New("unknown connector provider") - } -} - -func MustConnectorProviderFromString(s string) ConnectorProvider { - p, err := ConnectorProviderFromString(s) - if err != nil { - panic(err) - } - return p -} - -func (c Connector) ParseConfig(to interface{}) error { - if c.Config == nil { - return nil - } - - err := json.Unmarshal(c.Config, to) - if err != nil { - return fmt.Errorf("failed to parse config (%s): %w", string(c.Config), err) - } - - return nil -} - -type ConnectorConfigObject interface { - ConnectorName() string - Validate() error - Marshal() ([]byte, error) -} - -type EmptyConnectorConfig struct { - Name string -} - -func (cfg EmptyConnectorConfig) ConnectorName() string { - return cfg.Name -} - -func (cfg EmptyConnectorConfig) Validate() error { - return nil -} - -func (cfg EmptyConnectorConfig) Marshal() ([]byte, error) { - return nil, nil -} diff --git a/internal/models/connector_id.go b/internal/models/connector_id.go new file mode 100644 index 00000000..b9a60c62 --- /dev/null +++ b/internal/models/connector_id.go @@ -0,0 +1,78 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" + "github.com/google/uuid" +) + +// TODO(polo): change reference to uuid for temporal purpose +type ConnectorID struct { + Reference uuid.UUID + Provider string +} + +func (cid *ConnectorID) String() string { + if cid == nil || cid.Reference == uuid.Nil { + return "" + } + + data, err := canonicaljson.Marshal(cid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func ConnectorIDFromString(value string) (ConnectorID, error) { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return ConnectorID{}, err + } + ret := ConnectorID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return ConnectorID{}, err + } + + return ret, nil +} + +func MustConnectorIDFromString(value string) ConnectorID { + id, err := ConnectorIDFromString(value) + if err != nil { + panic(err) + } + return id +} + +func (cid ConnectorID) Value() (driver.Value, error) { + return cid.String(), nil +} + +func (cid *ConnectorID) Scan(value interface{}) error { + if value == nil { + return errors.New("connector id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := ConnectorIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse connector id %s: %v", v, err) + } + + *cid = id + return nil + } + } + + return fmt.Errorf("failed to scan connector id: %v", value) +} diff --git a/internal/models/connector_tasks_tree.go b/internal/models/connector_tasks_tree.go new file mode 100644 index 00000000..c925dcc5 --- /dev/null +++ b/internal/models/connector_tasks_tree.go @@ -0,0 +1,35 @@ +package models + +type TaskType int + +const ( + TASK_FETCH_OTHERS TaskType = iota + TASK_FETCH_ACCOUNTS + TASK_FETCH_BALANCES + TASK_FETCH_EXTERNAL_ACCOUNTS + TASK_FETCH_PAYMENTS + TASK_CREATE_WEBHOOKS +) + +type TaskTreeFetchOther struct{} +type TaskTreeFetchAccounts struct{} +type TaskTreeFetchBalances struct{} +type TaskTreeFetchExternalAccounts struct{} +type TaskTreeFetchPayments struct{} +type TaskTreeCreateWebhooks struct{} + +type ConnectorTaskTree struct { + TaskType TaskType + Name string + Periodically bool + NextTasks []ConnectorTaskTree + + TaskTreeFetchOther *TaskTreeFetchOther + TaskTreeFetchAccounts *TaskTreeFetchAccounts + TaskTreeFetchBalances *TaskTreeFetchBalances + TaskTreeFetchExternalAccounts *TaskTreeFetchExternalAccounts + TaskTreeFetchPayments *TaskTreeFetchPayments + TaskTreeCreateWebhooks *TaskTreeCreateWebhooks +} + +type ConnectorTasksTree []ConnectorTaskTree diff --git a/internal/models/connectors.go b/internal/models/connectors.go new file mode 100644 index 00000000..4badeddd --- /dev/null +++ b/internal/models/connectors.go @@ -0,0 +1,73 @@ +package models + +import ( + "encoding/json" + "time" +) + +type Connector struct { + // Unique ID of the connector + ID ConnectorID `json:"id"` + // Name given by the user to the connector + Name string `json:"name"` + // Creation date + CreatedAt time.Time `json:"createdAt"` + // Provider type + Provider string `json:"provider"` + // ScheduledForDeletion indicates if the connector is scheduled for deletion + ScheduledForDeletion bool `json:"scheduledForDeletion"` + + // Config given by the user. It will be encrypted when stored + Config json.RawMessage `json:"config"` +} + +func (c *Connector) IdempotencyKey() string { + return c.ID.String() +} + +func (c Connector) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Provider string `json:"provider"` + Config json.RawMessage `json:"config"` + ScheduledForDeletion bool `json:"scheduledForDeletion"` + }{ + ID: c.ID.String(), + Name: c.Name, + CreatedAt: c.CreatedAt, + Provider: c.Provider, + Config: c.Config, + ScheduledForDeletion: c.ScheduledForDeletion, + }) +} + +func (c *Connector) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Provider string `json:"provider"` + Config json.RawMessage `json:"config"` + ScheduledForDeletion bool `json:"scheduledForDeletion"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := ConnectorIDFromString(aux.ID) + if err != nil { + return err + } + + c.ID = id + c.Name = aux.Name + c.CreatedAt = aux.CreatedAt + c.Provider = aux.Provider + c.Config = aux.Config + c.ScheduledForDeletion = aux.ScheduledForDeletion + + return nil +} diff --git a/internal/models/errors.go b/internal/models/errors.go new file mode 100644 index 00000000..48af1e6c --- /dev/null +++ b/internal/models/errors.go @@ -0,0 +1,11 @@ +package models + +import "errors" + +var ( + ErrInvalidConfig = errors.New("invalid config") + ErrFailedAccountCreation = errors.New("failed to create account") + ErrMissingFromPayloadInRequest = errors.New("missing from payload in request") + ErrMissingAccountInRequest = errors.New("missing account number in request") + ErrInvalidRequest = errors.New("invalid request") +) diff --git a/internal/models/event_id.go b/internal/models/event_id.go new file mode 100644 index 00000000..3015e8b7 --- /dev/null +++ b/internal/models/event_id.go @@ -0,0 +1,70 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type EventID struct { + EventIdempotencyKey string + ConnectorID *ConnectorID +} + +func (aid *EventID) String() string { + if aid == nil || aid.EventIdempotencyKey == "" { + return "" + } + + data, err := canonicaljson.Marshal(aid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func EventIDFromString(value string) (EventID, error) { + ret := EventID{} + + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return ret, err + } + + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return ret, err + } + + return ret, nil +} + +func (aid EventID) Value() (driver.Value, error) { + return aid.String(), nil +} + +func (aid *EventID) Scan(value interface{}) error { + if value == nil { + return errors.New("event id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := EventIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse event id %s: %v", v, err) + } + + *aid = id + return nil + } + } + + return fmt.Errorf("failed to scan event id: %v", value) +} diff --git a/internal/models/events.go b/internal/models/events.go new file mode 100644 index 00000000..29babfbd --- /dev/null +++ b/internal/models/events.go @@ -0,0 +1,12 @@ +package models + +import "time" + +type EventSent struct { + // Unique Event ID generated from event information + ID EventID + // Related Connector ID + ConnectorID *ConnectorID + // Time when the event was sent + SentAt time.Time +} diff --git a/internal/models/others.go b/internal/models/others.go new file mode 100644 index 00000000..f682c041 --- /dev/null +++ b/internal/models/others.go @@ -0,0 +1,8 @@ +package models + +import "encoding/json" + +type PSPOther struct { + ID string `json:"id"` + Other json.RawMessage `json:"other"` +} diff --git a/internal/models/payment.go b/internal/models/payment.go deleted file mode 100644 index 3358adf4..00000000 --- a/internal/models/payment.go +++ /dev/null @@ -1,321 +0,0 @@ -package models - -import ( - "database/sql/driver" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "math/big" - "strconv" - "strings" - "time" - - "github.com/gibson042/canonicaljson-go" - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -type PaymentReference struct { - Reference string - Type PaymentType -} - -type PaymentID struct { - PaymentReference - ConnectorID ConnectorID -} - -func (pid PaymentID) String() string { - data, err := canonicaljson.Marshal(pid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) -} - -func PaymentIDFromString(value string) (*PaymentID, error) { - data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) - if err != nil { - return nil, err - } - ret := PaymentID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return nil, err - } - - return &ret, nil -} - -func MustPaymentIDFromString(value string) *PaymentID { - data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) - if err != nil { - panic(err) - } - ret := PaymentID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - panic(err) - } - - return &ret -} - -func (pid PaymentID) Value() (driver.Value, error) { - return pid.String(), nil -} - -func (pid *PaymentID) Scan(value interface{}) error { - if value == nil { - return errors.New("payment id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := PaymentIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse paymentid %s: %v", v, err) - } - - *pid = *id - return nil - } - } - - return fmt.Errorf("failed to scan paymentid: %v", value) -} - -type Payment struct { - bun.BaseModel `bun:"payments.payment"` - - ID PaymentID `bun:",pk,type:character varying,nullzero"` - ConnectorID ConnectorID `bun:",nullzero"` - CreatedAt time.Time `bun:",nullzero"` - Reference string - Amount *big.Int `bun:"type:numeric"` - InitialAmount *big.Int `bun:"type:numeric"` - Type PaymentType `bun:",type:payment_type"` - Status PaymentStatus `bun:",type:payment_status"` - Scheme PaymentScheme - Asset Asset - - RawData json.RawMessage - - SourceAccountID *AccountID `bun:",type:character varying,nullzero"` - DestinationAccountID *AccountID `bun:",type:character varying,nullzero"` - - // Read only fields - Metadata []*PaymentMetadata `bun:"rel:has-many,join:id=payment_id"` - Adjustments []*PaymentAdjustment `bun:"rel:has-many,join:id=payment_id"` - Connector *Connector `bun:"rel:has-one,join:connector_id=id"` -} - -type ( - PaymentType string - PaymentStatus string - PaymentScheme string - Asset string -) - -const ( - PaymentTypePayIn PaymentType = "PAY-IN" - PaymentTypePayOut PaymentType = "PAYOUT" - PaymentTypeTransfer PaymentType = "TRANSFER" - PaymentTypeOther PaymentType = "OTHER" -) - -const ( - PaymentStatusPending PaymentStatus = "PENDING" - PaymentStatusSucceeded PaymentStatus = "SUCCEEDED" - PaymentStatusCancelled PaymentStatus = "CANCELLED" - PaymentStatusFailed PaymentStatus = "FAILED" - PaymentStatusExpired PaymentStatus = "EXPIRED" - PaymentStatusRefunded PaymentStatus = "REFUNDED" - PaymentStatusRefundedFailure PaymentStatus = "REFUNDED_FAILURE" - PaymentStatusDispute PaymentStatus = "DISPUTE" - PaymentStatusDisputeWon PaymentStatus = "DISPUTE_WON" - PaymentStatusDisputeLost PaymentStatus = "DISPUTE_LOST" - PaymentStatusOther PaymentStatus = "OTHER" -) - -const ( - PaymentSchemeUnknown PaymentScheme = "unknown" - PaymentSchemeOther PaymentScheme = "other" - - PaymentSchemeCardVisa PaymentScheme = "visa" - PaymentSchemeCardMasterCard PaymentScheme = "mastercard" - PaymentSchemeCardAmex PaymentScheme = "amex" - PaymentSchemeCardDiners PaymentScheme = "diners" - PaymentSchemeCardDiscover PaymentScheme = "discover" - PaymentSchemeCardJCB PaymentScheme = "jcb" - PaymentSchemeCardUnionPay PaymentScheme = "unionpay" - PaymentSchemeCardAlipay PaymentScheme = "alipay" - PaymentSchemeCardCUP PaymentScheme = "cup" - - PaymentSchemeSepaDebit PaymentScheme = "sepa debit" - PaymentSchemeSepaCredit PaymentScheme = "sepa credit" - PaymentSchemeSepa PaymentScheme = "sepa" - - PaymentSchemeApplePay PaymentScheme = "apple pay" - PaymentSchemeGooglePay PaymentScheme = "google pay" - - PaymentSchemeDOKU PaymentScheme = "doku" - PaymentSchemeDragonPay PaymentScheme = "dragonpay" - PaymentSchemeMaestro PaymentScheme = "maestro" - PaymentSchemeMolPay PaymentScheme = "molpay" - - PaymentSchemeA2A PaymentScheme = "a2a" - PaymentSchemeACHDebit PaymentScheme = "ach debit" - PaymentSchemeACH PaymentScheme = "ach" - PaymentSchemeRTP PaymentScheme = "rtp" -) - -func (t PaymentType) String() string { - return string(t) -} - -func PaymentTypeFromString(value string) (PaymentType, error) { - switch value { - case "PAY-IN": - return PaymentTypePayIn, nil - case "PAYOUT": - return PaymentTypePayOut, nil - case "TRANSFER": - return PaymentTypeTransfer, nil - case "OTHER": - return PaymentTypeOther, nil - default: - return "", errors.New("invalid payment type") - } -} - -func (t PaymentStatus) String() string { - return string(t) -} - -func PaymentStatusFromString(value string) (PaymentStatus, error) { - switch value { - case "PENDING": - return PaymentStatusPending, nil - case "SUCCEEDED": - return PaymentStatusSucceeded, nil - case "CANCELLED": - return PaymentStatusCancelled, nil - case "FAILED": - return PaymentStatusFailed, nil - case "EXPIRED": - return PaymentStatusExpired, nil - case "REFUNDED": - return PaymentStatusRefunded, nil - case "REFUNDED_FAILURE": - return PaymentStatusRefundedFailure, nil - case "DISPUTE": - return PaymentStatusDispute, nil - case "DISPUTE_WON": - return PaymentStatusDisputeWon, nil - case "DISPUTE_LOST": - return PaymentStatusDisputeLost, nil - case "OTHER": - return PaymentStatusOther, nil - default: - return "", errors.New("invalid payment status") - } -} - -func (t PaymentScheme) String() string { - return string(t) -} - -func PaymentSchemeFromString(value string) (PaymentScheme, error) { - switch strings.ToLower(value) { - case "unknown": - return PaymentSchemeUnknown, nil - case "other": - return PaymentSchemeOther, nil - case "visa": - return PaymentSchemeCardVisa, nil - case "mastercard": - return PaymentSchemeCardMasterCard, nil - case "amex": - return PaymentSchemeCardAmex, nil - case "diners": - return PaymentSchemeCardDiners, nil - case "discover": - return PaymentSchemeCardDiscover, nil - case "jcb": - return PaymentSchemeCardJCB, nil - case "unionpay": - return PaymentSchemeCardUnionPay, nil - case "sepa debit": - return PaymentSchemeSepaDebit, nil - case "sepa credit": - return PaymentSchemeSepaCredit, nil - case "sepa": - return PaymentSchemeSepa, nil - case "apple pay": - return PaymentSchemeApplePay, nil - case "google pay": - return PaymentSchemeGooglePay, nil - case "a2a": - return PaymentSchemeA2A, nil - case "ach debit": - return PaymentSchemeACHDebit, nil - case "ach": - return PaymentSchemeACH, nil - case "rtp": - return PaymentSchemeRTP, nil - default: - return "", errors.New("invalid payment scheme") - } -} - -func (t Asset) String() string { - return string(t) -} - -func GetCurrencyAndPrecisionFromAsset(asset Asset) (string, int64, error) { - parts := strings.Split(asset.String(), "/") - if len(parts) != 2 { - return "", 0, errors.New("invalid asset") - } - - precision, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - return "", 0, errors.New("invalid asset precision") - } - - return parts[0], precision, nil -} - -type PaymentAdjustment struct { - bun.BaseModel `bun:"payments.adjustment"` - - ID uuid.UUID `bun:",pk,nullzero"` - PaymentID PaymentID `bun:",pk,nullzero"` - CreatedAt time.Time `bun:",nullzero"` - Reference string - Amount *big.Int - Status PaymentStatus - - RawData json.RawMessage -} - -type PaymentMetadata struct { - bun.BaseModel `bun:"payments.metadata"` - - PaymentID PaymentID `bun:",pk,nullzero"` - CreatedAt time.Time `bun:",nullzero"` - Key string `bun:",pk,nullzero"` - Value string - - Changelog []MetadataChangelog `bun:",nullzero"` -} - -type MetadataChangelog struct { - CreatedAt time.Time `json:"createdAt"` - Value string `json:"value"` -} diff --git a/internal/models/payment_adjustment_id.go b/internal/models/payment_adjustment_id.go new file mode 100644 index 00000000..f7c1b63e --- /dev/null +++ b/internal/models/payment_adjustment_id.go @@ -0,0 +1,81 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/gibson042/canonicaljson-go" +) + +type PaymentAdjustmentID struct { + PaymentID PaymentID + Reference string + CreatedAt time.Time + Status PaymentStatus +} + +func (pid PaymentAdjustmentID) String() string { + data, err := canonicaljson.Marshal(pid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func PaymentAdjustmentIDFromString(value string) (*PaymentAdjustmentID, error) { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return nil, err + } + ret := PaymentAdjustmentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return nil, err + } + + return &ret, nil +} + +func MustPaymentAdjustmentIDFromString(value string) *PaymentAdjustmentID { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + panic(err) + } + ret := PaymentAdjustmentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + panic(err) + } + + return &ret +} + +func (pid PaymentAdjustmentID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PaymentAdjustmentID) Scan(value interface{}) error { + if value == nil { + return errors.New("payment adjustment id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := PaymentAdjustmentIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse payment adjustment id %s: %v", v, err) + } + + *pid = *id + return nil + } + } + + return fmt.Errorf("failed to scan payment adjustement id: %v", value) +} diff --git a/internal/models/payment_adjustments.go b/internal/models/payment_adjustments.go new file mode 100644 index 00000000..ee336285 --- /dev/null +++ b/internal/models/payment_adjustments.go @@ -0,0 +1,100 @@ +package models + +import ( + "encoding/json" + "math/big" + "time" +) + +type PaymentAdjustment struct { + // Unique ID of the payment adjustment + ID PaymentAdjustmentID `json:"id"` + // Related Payment ID + PaymentID PaymentID `json:"paymentID"` + + // Reference of the adjustment. If we do not have a new reference for the + // adjustment, it will be the same as the payment reference. + Reference string `json:"reference"` + + // Creation date of the adjustment + CreatedAt time.Time `json:"createdAt"` + + // Status of the payment adjustement + Status PaymentStatus `json:"status"` + + // Optional + // Amount moved + Amount *big.Int `json:"amount"` + // Optional + // Asset related to amount + Asset *string `json:"asset"` + + // Additional metadata + Metadata map[string]string `json:"metadata"` + // PSP response in raw + Raw json.RawMessage `json:"raw"` +} + +func (p *PaymentAdjustment) IdempotencyKey() string { + return p.ID.String() +} + +func (c PaymentAdjustment) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + PaymentID string `json:"paymentID"` + CreatedAt time.Time `json:"createdAt"` + Status PaymentStatus `json:"status"` + Amount *big.Int `json:"amount"` + Asset *string `json:"asset"` + Metadata map[string]string `json:"metadata"` + Raw json.RawMessage `json:"raw"` + }{ + ID: c.ID.String(), + PaymentID: c.PaymentID.String(), + CreatedAt: c.CreatedAt, + Status: c.Status, + Amount: c.Amount, + Asset: c.Asset, + Metadata: c.Metadata, + Raw: c.Raw, + }) +} + +func (c *PaymentAdjustment) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + PaymentID string `json:"paymentID"` + CreatedAt time.Time `json:"createdAt"` + Status PaymentStatus `json:"status"` + Amount *big.Int `json:"amount"` + Asset *string `json:"asset"` + Metadata map[string]string `json:"metadata"` + Raw json.RawMessage `json:"raw"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + paymentID, err := PaymentIDFromString(aux.PaymentID) + if err != nil { + return err + } + + adjustmentID, err := PaymentAdjustmentIDFromString(aux.ID) + if err != nil { + return err + } + + c.ID = *adjustmentID + c.PaymentID = paymentID + c.CreatedAt = aux.CreatedAt + c.Status = aux.Status + c.Amount = aux.Amount + c.Asset = aux.Asset + c.Metadata = aux.Metadata + c.Raw = aux.Raw + + return nil +} diff --git a/internal/models/payment_id.go b/internal/models/payment_id.go new file mode 100644 index 00000000..7f7dd045 --- /dev/null +++ b/internal/models/payment_id.go @@ -0,0 +1,83 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type PaymentReference struct { + Reference string + Type PaymentType +} + +type PaymentID struct { + PaymentReference + ConnectorID ConnectorID +} + +func (pid PaymentID) String() string { + data, err := canonicaljson.Marshal(pid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func PaymentIDFromString(value string) (PaymentID, error) { + ret := PaymentID{} + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return ret, err + } + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return ret, err + } + + return ret, nil +} + +func MustPaymentIDFromString(value string) *PaymentID { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + panic(err) + } + ret := PaymentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + panic(err) + } + + return &ret +} + +func (pid PaymentID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PaymentID) Scan(value interface{}) error { + if value == nil { + return errors.New("payment id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := PaymentIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse paymentid %s: %v", v, err) + } + + *pid = id + return nil + } + } + + return fmt.Errorf("failed to scan paymentid: %v", value) +} diff --git a/internal/models/payment_initation_related_payments.go b/internal/models/payment_initation_related_payments.go new file mode 100644 index 00000000..a7e1cea1 --- /dev/null +++ b/internal/models/payment_initation_related_payments.go @@ -0,0 +1,9 @@ +package models + +type PaymentInitiationRelatedPayments struct { + // Payment Initiation ID + PaymentInitiationID PaymentInitiationID `json:"paymentInitiationID"` + + // Related Payment ID + PaymentID PaymentID `json:"paymentID"` +} diff --git a/internal/models/payment_initiation_adjusments_status.go b/internal/models/payment_initiation_adjusments_status.go new file mode 100644 index 00000000..4860888f --- /dev/null +++ b/internal/models/payment_initiation_adjusments_status.go @@ -0,0 +1,166 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +type PaymentInitiationAdjustmentStatus int + +const ( + PAYMENT_INITIATION_ADJUSTMENT_STATUS_UNKNOWN PaymentInitiationAdjustmentStatus = iota + PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION + PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING + PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED + PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED + PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED + PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_PROCESSING + PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED + PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED +) + +func (s PaymentInitiationAdjustmentStatus) String() string { + switch s { + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION: + return "WAITING_FOR_VALIDATION" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING: + return "PROCESSING" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED: + return "PROCESSED" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED: + return "FAILED" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED: + return "REJECTED" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_PROCESSING: + return "REVERSE_PROCESSING" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED: + return "REVERSE_FAILED" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED: + return "REVERSED" + case PAYMENT_INITIATION_ADJUSTMENT_STATUS_UNKNOWN: + return "UNKNOWN" + } + return "UNKNOWN" +} + +func PaymentInitiationAdjustmentStatusFromString(s string) (PaymentInitiationAdjustmentStatus, error) { + switch s { + case "WAITING_FOR_VALIDATION": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION, nil + case "PROCESSING": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, nil + case "PROCESSED": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED, nil + case "FAILED": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED, nil + case "REJECTED": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED, nil + case "REVERSE_PROCESSING": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_PROCESSING, nil + case "REVERSE_FAILED": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED, nil + case "REVERSED": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED, nil + case "UNKNOWN": + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_UNKNOWN, nil + } + + return PAYMENT_INITIATION_ADJUSTMENT_STATUS_UNKNOWN, fmt.Errorf("unknown PaymentInitiationAdjustmentStatus: %s", s) +} + +func (t PaymentInitiationAdjustmentStatus) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, t.String())), nil +} + +func (t *PaymentInitiationAdjustmentStatus) UnmarshalJSON(data []byte) error { + var v string + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + value, err := PaymentInitiationAdjustmentStatusFromString(v) + if err != nil { + return err + } + + *t = value + + return nil +} + +func (t PaymentInitiationAdjustmentStatus) Value() (driver.Value, error) { + return t.String(), nil +} + +func (t *PaymentInitiationAdjustmentStatus) Scan(value interface{}) error { + if value == nil { + return errors.New("payment initiation adjusmtent status status is nil") + } + + s, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert payment initiation adjusmtent status status") + } + + v, ok := s.(string) + if !ok { + return fmt.Errorf("failed to cast payment initiation adjusmtent status status") + } + + res, err := PaymentInitiationAdjustmentStatusFromString(v) + if err != nil { + return err + } + + *t = res + + return nil +} + +func FromPaymentToPaymentInitiationAdjustment(from *Payment, piID PaymentInitiationID) *PaymentInitiationAdjustment { + var status PaymentInitiationAdjustmentStatus + var err error + + switch from.Status { + case PAYMENT_STATUS_AMOUNT_ADJUSTEMENT, PAYMENT_STATUS_UNKNOWN: + // No need to add an adjustment for this payment initiation + return nil + case PAYMENT_STATUS_PENDING, PAYMENT_STATUS_AUTHORISATION: + status = PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING + case PAYMENT_STATUS_SUCCEEDED, + PAYMENT_STATUS_CAPTURE, + PAYMENT_STATUS_REFUND_REVERSED, + PAYMENT_STATUS_DISPUTE_WON: + status = PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED + case PAYMENT_STATUS_CANCELLED, + PAYMENT_STATUS_CAPTURE_FAILED, + PAYMENT_STATUS_EXPIRED, + PAYMENT_STATUS_FAILED, + PAYMENT_STATUS_DISPUTE_LOST: + status = PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED + err = errors.New("payment failed") + case PAYMENT_STATUS_DISPUTE: + status = PAYMENT_INITIATION_ADJUSTMENT_STATUS_UNKNOWN + case PAYMENT_STATUS_REFUNDED: + status = PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED + case PAYMENT_STATUS_REFUNDED_FAILURE: + status = PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSE_FAILED + err = errors.New("payment refund failed") + default: + return nil + } + + return &PaymentInitiationAdjustment{ + ID: PaymentInitiationAdjustmentID{ + PaymentInitiationID: piID, + CreatedAt: from.CreatedAt, + Status: status, + }, + PaymentInitiationID: piID, + CreatedAt: from.CreatedAt, + Status: status, + Error: err, + } +} diff --git a/internal/models/payment_initiation_adjustment_id.go b/internal/models/payment_initiation_adjustment_id.go new file mode 100644 index 00000000..4f699fd1 --- /dev/null +++ b/internal/models/payment_initiation_adjustment_id.go @@ -0,0 +1,80 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/gibson042/canonicaljson-go" +) + +type PaymentInitiationAdjustmentID struct { + PaymentInitiationID PaymentInitiationID + CreatedAt time.Time + Status PaymentInitiationAdjustmentStatus +} + +func (pid PaymentInitiationAdjustmentID) String() string { + data, err := canonicaljson.Marshal(pid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func PaymentInitiationAdjustmentIDFromString(value string) (PaymentInitiationAdjustmentID, error) { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return PaymentInitiationAdjustmentID{}, err + } + ret := PaymentInitiationAdjustmentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return PaymentInitiationAdjustmentID{}, err + } + + return ret, nil +} + +func MustPaymentInitiationAdjustmentIDFromString(value string) *PaymentInitiationAdjustmentID { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + panic(err) + } + ret := PaymentInitiationAdjustmentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + panic(err) + } + + return &ret +} + +func (pid PaymentInitiationAdjustmentID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PaymentInitiationAdjustmentID) Scan(value interface{}) error { + if value == nil { + return errors.New("payment adjustment id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := PaymentInitiationAdjustmentIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse payment adjustment id %s: %v", v, err) + } + + *pid = id + return nil + } + } + + return fmt.Errorf("failed to scan payment adjustement id: %v", value) +} diff --git a/internal/models/payment_initiation_adjustments.go b/internal/models/payment_initiation_adjustments.go new file mode 100644 index 00000000..2df5af56 --- /dev/null +++ b/internal/models/payment_initiation_adjustments.go @@ -0,0 +1,98 @@ +package models + +import ( + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" +) + +type PaymentInitiationAdjustment struct { + // Unique ID + ID PaymentInitiationAdjustmentID `json:"id"` + + // Related Payment Initiation ID + PaymentInitiationID PaymentInitiationID `json:"paymentInitiationID"` + // Creation date of the adjustment + CreatedAt time.Time `json:"createdAt"` + // Last status of the adjustment + Status PaymentInitiationAdjustmentStatus `json:"status"` + // Amount of the adjustment in case we have a refund, reverse etc... + Amount *big.Int `json:"amount"` + // Currency of the adjustment in case we have a refund, reverse etc... + Asset *string `json:"asset"` + // Error description if we had one + Error error `json:"error"` + // Additional metadata + Metadata map[string]string `json:"metadata"` +} + +func (pia PaymentInitiationAdjustment) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + PaymentInitiationID string `json:"paymentInitiationID"` + CreatedAt time.Time `json:"createdAt"` + Status PaymentInitiationAdjustmentStatus `json:"status"` + Amount *big.Int `json:"amount,omitempty"` + Asset *string `json:"asset,omitempty"` + Error *string `json:"error,omitempty"` + Metadata map[string]string `json:"metadata"` + }{ + ID: pia.ID.String(), + PaymentInitiationID: pia.PaymentInitiationID.String(), + CreatedAt: pia.CreatedAt, + Status: pia.Status, + Amount: pia.Amount, + Asset: pia.Asset, + Error: func() *string { + if pia.Error == nil { + return nil + } + + return pointer.For(pia.Error.Error()) + }(), + Metadata: pia.Metadata, + }) +} + +func (pia *PaymentInitiationAdjustment) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + PaymentInitiationID string `json:"paymentInitiationID"` + CreatedAt time.Time `json:"createdAt"` + Status PaymentInitiationAdjustmentStatus `json:"status"` + Amount *big.Int `json:"amount"` + Asset *string `json:"asset"` + Error *string `json:"error"` + Metadata map[string]string `json:"metadata"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := PaymentInitiationAdjustmentIDFromString(aux.ID) + if err != nil { + return err + } + + piID, err := PaymentInitiationIDFromString(aux.PaymentInitiationID) + if err != nil { + return err + } + + pia.ID = id + pia.PaymentInitiationID = piID + pia.CreatedAt = aux.CreatedAt + pia.Status = aux.Status + pia.Amount = aux.Amount + pia.Asset = aux.Asset + if aux.Error != nil { + pia.Error = errors.New(*aux.Error) + } + pia.Metadata = aux.Metadata + + return nil +} diff --git a/internal/models/payment_initiation_id.go b/internal/models/payment_initiation_id.go new file mode 100644 index 00000000..1afcfc26 --- /dev/null +++ b/internal/models/payment_initiation_id.go @@ -0,0 +1,78 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type PaymentInitiationID struct { + Reference string + ConnectorID ConnectorID +} + +func (pid PaymentInitiationID) String() string { + data, err := canonicaljson.Marshal(pid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func PaymentInitiationIDFromString(value string) (PaymentInitiationID, error) { + ret := PaymentInitiationID{} + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return ret, err + } + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return ret, err + } + + return ret, nil +} + +func MustPaymentInitiationIDFromString(value string) *PaymentInitiationID { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + panic(err) + } + ret := PaymentInitiationID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + panic(err) + } + + return &ret +} + +func (pid PaymentInitiationID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PaymentInitiationID) Scan(value interface{}) error { + if value == nil { + return errors.New("payment initiation id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := PaymentInitiationIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse payment initiation id %s: %v", v, err) + } + + *pid = id + return nil + } + } + + return fmt.Errorf("failed to scan payment initiation id: %v", value) +} diff --git a/internal/models/payment_initiation_reversal_adjustment_id.go b/internal/models/payment_initiation_reversal_adjustment_id.go new file mode 100644 index 00000000..2407a844 --- /dev/null +++ b/internal/models/payment_initiation_reversal_adjustment_id.go @@ -0,0 +1,80 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/gibson042/canonicaljson-go" +) + +type PaymentInitiationReversalAdjustmentID struct { + PaymentInitiationReversalID PaymentInitiationReversalID + CreatedAt time.Time + Status PaymentInitiationReversalAdjustmentStatus +} + +func (pid PaymentInitiationReversalAdjustmentID) String() string { + data, err := canonicaljson.Marshal(pid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func PaymentInitiationReversalAdjustmentIDFromString(value string) (PaymentInitiationReversalAdjustmentID, error) { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return PaymentInitiationReversalAdjustmentID{}, err + } + ret := PaymentInitiationReversalAdjustmentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return PaymentInitiationReversalAdjustmentID{}, err + } + + return ret, nil +} + +func MustPaymentInitiationReversalAdjustmentIDFromString(value string) *PaymentInitiationReversalAdjustmentID { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + panic(err) + } + ret := PaymentInitiationReversalAdjustmentID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + panic(err) + } + + return &ret +} + +func (pid PaymentInitiationReversalAdjustmentID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PaymentInitiationReversalAdjustmentID) Scan(value interface{}) error { + if value == nil { + return errors.New("payment reversal adjustment id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := PaymentInitiationReversalAdjustmentIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse payment reversal adjustment id %s: %v", v, err) + } + + *pid = id + return nil + } + } + + return fmt.Errorf("failed to scan payment reversal adjustment id: %v", value) +} diff --git a/internal/models/payment_initiation_reversal_adjustment_status.go b/internal/models/payment_initiation_reversal_adjustment_status.go new file mode 100644 index 00000000..4d199d68 --- /dev/null +++ b/internal/models/payment_initiation_reversal_adjustment_status.go @@ -0,0 +1,92 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +type PaymentInitiationReversalAdjustmentStatus int + +const ( + PAYMENT_INITIATION_REVERSAL_STATUS_UNKNOWN PaymentInitiationReversalAdjustmentStatus = iota + PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING + PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED + PAYMENT_INITIATION_REVERSAL_STATUS_FAILED +) + +func (s PaymentInitiationReversalAdjustmentStatus) String() string { + switch s { + case PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING: + return "PROCESSING" + case PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED: + return "PROCESSED" + case PAYMENT_INITIATION_REVERSAL_STATUS_FAILED: + return "FAILED" + case PAYMENT_INITIATION_REVERSAL_STATUS_UNKNOWN: + return "UNKNOWN" + } + return "UNKNOWN" +} + +func PaymentInitiationReversalStatusFromString(s string) (PaymentInitiationReversalAdjustmentStatus, error) { + switch s { + case "PROCESSING": + return PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, nil + case "PROCESSED": + return PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, nil + case "FAILED": + return PAYMENT_INITIATION_REVERSAL_STATUS_FAILED, nil + } + return PAYMENT_INITIATION_REVERSAL_STATUS_UNKNOWN, nil +} + +func (t PaymentInitiationReversalAdjustmentStatus) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, t.String())), nil +} + +func (t *PaymentInitiationReversalAdjustmentStatus) UnmarshalJSON(data []byte) error { + var v string + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + value, err := PaymentInitiationReversalStatusFromString(v) + if err != nil { + return err + } + + *t = value + + return nil +} + +func (t PaymentInitiationReversalAdjustmentStatus) Value() (driver.Value, error) { + return t.String(), nil +} + +func (t *PaymentInitiationReversalAdjustmentStatus) Scan(value interface{}) error { + if value == nil { + return errors.New("payment initiation reversal status status is nil") + } + + s, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert payment initiation reversal status status") + } + + v, ok := s.(string) + if !ok { + return fmt.Errorf("failed to cast payment initiation reversal status status") + } + + res, err := PaymentInitiationReversalStatusFromString(v) + if err != nil { + return err + } + + *t = res + + return nil +} diff --git a/internal/models/payment_initiation_reversal_adjustments.go b/internal/models/payment_initiation_reversal_adjustments.go new file mode 100644 index 00000000..28ddad09 --- /dev/null +++ b/internal/models/payment_initiation_reversal_adjustments.go @@ -0,0 +1,86 @@ +package models + +import ( + "encoding/json" + "errors" + "time" + + "github.com/formancehq/go-libs/v2/pointer" +) + +type PaymentInitiationReversalAdjustment struct { + // Unique ID + ID PaymentInitiationReversalAdjustmentID `json:"id"` + + // Related Payment Initiation Reversal ID + PaymentInitiationReversalID PaymentInitiationReversalID `json:"paymentInitiationReversalID"` + // Creation date of the adjustment + CreatedAt time.Time `json:"createdAt"` + // Last status of the adjustment + Status PaymentInitiationReversalAdjustmentStatus `json:"status"` + // Error description if we had one + Error error `json:"error"` + // Additional metadata + Metadata map[string]string `json:"metadata"` +} + +func (piara PaymentInitiationReversalAdjustment) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + PaymentInitiationReversalID string `json:"paymentInitiationReversalID"` + CreatedAt time.Time `json:"createdAt"` + Status PaymentInitiationReversalAdjustmentStatus `json:"status"` + Error *string `json:"error,omitempty"` + Metadata map[string]string `json:"metadata"` + }{ + ID: piara.ID.String(), + PaymentInitiationReversalID: piara.PaymentInitiationReversalID.String(), + CreatedAt: piara.CreatedAt, + Status: piara.Status, + Error: func() *string { + if piara.Error == nil { + return nil + } + + return pointer.For(piara.Error.Error()) + }(), + Metadata: piara.Metadata, + }) +} + +func (piara *PaymentInitiationReversalAdjustment) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + PaymentInitiationReversalID string `json:"paymentInitiationReversalID"` + CreatedAt time.Time `json:"createdAt"` + Status PaymentInitiationReversalAdjustmentStatus `json:"status"` + Error *string `json:"error"` + Metadata map[string]string `json:"metadata"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := PaymentInitiationReversalAdjustmentIDFromString(aux.ID) + if err != nil { + return err + } + + reversalID, err := PaymentInitiationReversalIDFromString(aux.PaymentInitiationReversalID) + if err != nil { + return err + } + + piara.ID = id + piara.PaymentInitiationReversalID = reversalID + piara.CreatedAt = aux.CreatedAt + piara.Status = aux.Status + piara.Metadata = aux.Metadata + + if aux.Error != nil { + piara.Error = errors.New(*aux.Error) + } + + return nil +} diff --git a/internal/models/payment_initiation_reversal_id.go b/internal/models/payment_initiation_reversal_id.go new file mode 100644 index 00000000..7b422e5a --- /dev/null +++ b/internal/models/payment_initiation_reversal_id.go @@ -0,0 +1,78 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type PaymentInitiationReversalID struct { + Reference string + ConnectorID ConnectorID +} + +func (pid PaymentInitiationReversalID) String() string { + data, err := canonicaljson.Marshal(pid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func PaymentInitiationReversalIDFromString(value string) (PaymentInitiationReversalID, error) { + ret := PaymentInitiationReversalID{} + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return ret, err + } + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return ret, err + } + + return ret, nil +} + +func MustPaymentInitiationReversalIDFromString(value string) *PaymentInitiationReversalID { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + panic(err) + } + ret := PaymentInitiationReversalID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + panic(err) + } + + return &ret +} + +func (pid PaymentInitiationReversalID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PaymentInitiationReversalID) Scan(value interface{}) error { + if value == nil { + return errors.New("payment initiation reversal id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := PaymentInitiationReversalIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse payment initiation reversal id %s: %v", v, err) + } + + *pid = id + return nil + } + } + + return fmt.Errorf("failed to scan payment initiation reversal id: %v", value) +} diff --git a/internal/models/payment_initiation_reversals.go b/internal/models/payment_initiation_reversals.go new file mode 100644 index 00000000..dda53fd5 --- /dev/null +++ b/internal/models/payment_initiation_reversals.go @@ -0,0 +1,172 @@ +package models + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" +) + +type PSPPaymentInitiationReversal struct { + // Reference of the unique payment initiation reversal + Reference string + + // Payment Initiation Reversal creation date + CreatedAt time.Time + + // Description of the payment initiation reversal + Description string + + // Related Payment Initiation object + RelatedPaymentInitiation PSPPaymentInitiation + + // Amount that we want to reverse + Amount *big.Int + // Asset of the reversal + Asset string + + // Additional metadata + Metadata map[string]string +} + +type PaymentInitiationReversal struct { + // Unique Payment initiation reversal ID generated from reversal information + ID PaymentInitiationReversalID `json:"id"` + // Related Connector ID + ConnectorID ConnectorID `json:"connectorID"` + // Related Payment Initiation ID that is being reversed + PaymentInitiationID PaymentInitiationID `json:"paymentInitiationID"` + // Unique reference of the reversal + Reference string `json:"reference"` + + // Payment Initiation Reversal creation date + CreatedAt time.Time `json:"createdAt"` + // Description of the reversal + Description string `json:"description"` + + // Amount reversed + Amount *big.Int `json:"amount"` + // Asset of the reversal + Asset string `json:"asset"` + + // Additional metadata + Metadata map[string]string `json:"metadata"` +} + +func (pir PaymentInitiationReversal) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + PaymentInitiationID string `json:"paymentInitiationID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Description string `json:"description"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` + }{ + ID: pir.ID.String(), + ConnectorID: pir.ConnectorID.String(), + PaymentInitiationID: pir.PaymentInitiationID.String(), + Reference: pir.Reference, + CreatedAt: pir.CreatedAt, + Description: pir.Description, + Amount: pir.Amount, + Asset: pir.Asset, + Metadata: pir.Metadata, + }) +} + +func (pir *PaymentInitiationReversal) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + PaymentInitiationID string `json:"paymentInitiationID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Description string `json:"description"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := PaymentInitiationReversalIDFromString(aux.ID) + if err != nil { + return err + } + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + paymentInitiationID, err := PaymentInitiationIDFromString(aux.PaymentInitiationID) + if err != nil { + return err + } + + pir.ID = id + pir.ConnectorID = connectorID + pir.PaymentInitiationID = paymentInitiationID + pir.Reference = aux.Reference + pir.CreatedAt = aux.CreatedAt + pir.Description = aux.Description + pir.Amount = aux.Amount + pir.Asset = aux.Asset + pir.Metadata = aux.Metadata + + return nil +} + +func FromPaymentInitiationReversalToPSPPaymentInitiationReversal(from *PaymentInitiationReversal, relatedPI PSPPaymentInitiation) PSPPaymentInitiationReversal { + return PSPPaymentInitiationReversal{ + Reference: from.Reference, + CreatedAt: from.CreatedAt, + Description: from.Description, + RelatedPaymentInitiation: relatedPI, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + } +} + +type PaymentInitiationReversalExpanded struct { + PaymentInitiationReversal PaymentInitiationReversal + Status PaymentInitiationReversalAdjustmentStatus + Error error +} + +func (pi PaymentInitiationReversalExpanded) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + PaymentInitiationID string `json:"paymentInitiationID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Description string `json:"description"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` + Status string `json:"status"` + Error *string `json:"error,omitempty"` + }{ + ID: pi.PaymentInitiationReversal.ID.String(), + ConnectorID: pi.PaymentInitiationReversal.ConnectorID.String(), + PaymentInitiationID: pi.PaymentInitiationReversal.PaymentInitiationID.String(), + Reference: pi.PaymentInitiationReversal.Reference, + CreatedAt: pi.PaymentInitiationReversal.CreatedAt, + Description: pi.PaymentInitiationReversal.Description, + Amount: pi.PaymentInitiationReversal.Amount, + Asset: pi.PaymentInitiationReversal.Asset, + Metadata: pi.PaymentInitiationReversal.Metadata, + Status: pi.Status.String(), + Error: func() *string { + if pi.Error == nil { + return nil + } + return pointer.For(pi.Error.Error()) + }(), + }) +} diff --git a/internal/models/payment_initiation_type.go b/internal/models/payment_initiation_type.go new file mode 100644 index 00000000..e7d33086 --- /dev/null +++ b/internal/models/payment_initiation_type.go @@ -0,0 +1,99 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +type PaymentInitiationType int + +const ( + PAYMENT_INITIATION_TYPE_UNKNOWN PaymentInitiationType = iota + PAYMENT_INITIATION_TYPE_TRANSFER + PAYMENT_INITIATION_TYPE_PAYOUT +) + +func (t PaymentInitiationType) String() string { + switch t { + case PAYMENT_INITIATION_TYPE_UNKNOWN: + return "UNKNOWN" + case PAYMENT_INITIATION_TYPE_TRANSFER: + return "TRANSFER" + case PAYMENT_INITIATION_TYPE_PAYOUT: + return "PAYOUT" + default: + return "UNKNOWN" + } +} + +func PaymentInitiationTypeFromString(value string) (PaymentInitiationType, error) { + switch value { + case "TRANSFER": + return PAYMENT_INITIATION_TYPE_TRANSFER, nil + case "PAYOUT": + return PAYMENT_INITIATION_TYPE_PAYOUT, nil + case "UNKNOWN": + return PAYMENT_INITIATION_TYPE_UNKNOWN, nil + default: + return PAYMENT_INITIATION_TYPE_UNKNOWN, errors.New("invalid payment initiation type value") + } +} + +func MustPaymentInitiationTypeFromString(value string) PaymentInitiationType { + ret, err := PaymentInitiationTypeFromString(value) + if err != nil { + panic(err) + } + return ret +} + +func (t PaymentInitiationType) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, t.String())), nil +} + +func (t *PaymentInitiationType) UnmarshalJSON(data []byte) error { + var v string + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + value, err := PaymentInitiationTypeFromString(v) + if err != nil { + return err + } + + *t = value + + return nil +} + +func (t PaymentInitiationType) Value() (driver.Value, error) { + return t.String(), nil +} + +func (t *PaymentInitiationType) Scan(value interface{}) error { + if value == nil { + return errors.New("payment status is nil") + } + + s, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert payment status") + } + + v, ok := s.(string) + if !ok { + return fmt.Errorf("failed to cast payment status") + } + + res, err := PaymentInitiationTypeFromString(v) + if err != nil { + return err + } + + *t = res + + return nil +} diff --git a/internal/models/payment_initiations.go b/internal/models/payment_initiations.go new file mode 100644 index 00000000..9fb964b9 --- /dev/null +++ b/internal/models/payment_initiations.go @@ -0,0 +1,238 @@ +package models + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" +) + +type PSPPaymentInitiation struct { + // Reference of the unique payment initiation + Reference string + + // Payment Initiation creation date + CreatedAt time.Time + + // Description of the payment + Description string + + // PSP reference of the source account + SourceAccount *PSPAccount + // PSP reference of the destination account + DestinationAccount *PSPAccount + + // Amount of the payment + Amount *big.Int + // Asset of the payment + Asset string + + // Additional metadata + Metadata map[string]string +} + +type PaymentInitiation struct { + // Unique Payment initiation ID generated from payments information + ID PaymentInitiationID `json:"id"` + // Related Connector ID + ConnectorID ConnectorID `json:"connectorID"` + // Unique reference of the payment + Reference string `json:"reference"` + + // Payment Initiation creation date + CreatedAt time.Time `json:"createdAt"` + + // Time to schedule the payment + ScheduledAt time.Time `json:"scheduledAt"` + + // Description of the payment + Description string `json:"description"` + + Type PaymentInitiationType `json:"paymentInitiationType"` + + // Source account of the payment + SourceAccountID *AccountID `json:"sourceAccountID"` + // Destination account of the payment + DestinationAccountID *AccountID `json:"destinationAccountID"` + + // Payment current amount (can be changed in case of reversed, refunded, etc...) + Amount *big.Int `json:"amount"` + // Asset of the payment + Asset string `json:"asset"` + + // Additional metadata + Metadata map[string]string `json:"metadata"` +} + +func (pi PaymentInitiation) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + ScheduledAt time.Time `json:"scheduledAt"` + Description string `json:"description"` + Type PaymentInitiationType `json:"paymentInitiationType"` + SourceAccountID *string `json:"sourceAccountID,omitempty"` + DestinationAccountID *string `json:"destinationAccountID,omitempty"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` + }{ + ID: pi.ID.String(), + ConnectorID: pi.ConnectorID.String(), + Reference: pi.Reference, + CreatedAt: pi.CreatedAt, + ScheduledAt: pi.ScheduledAt, + Description: pi.Description, + Type: pi.Type, + SourceAccountID: func() *string { + if pi.SourceAccountID == nil { + return nil + } + return pointer.For(pi.SourceAccountID.String()) + }(), + DestinationAccountID: func() *string { + if pi.DestinationAccountID == nil { + return nil + } + return pointer.For(pi.DestinationAccountID.String()) + }(), + Amount: pi.Amount, + Asset: pi.Asset, + Metadata: pi.Metadata, + }) +} + +func (pi *PaymentInitiation) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + ScheduledAt time.Time `json:"scheduledAt"` + Description string `json:"description"` + Type PaymentInitiationType `json:"paymentInitiationType"` + SourceAccountID *string `json:"sourceAccountID,omitempty"` + DestinationAccountID *string `json:"destinationAccountID,omitempty"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := PaymentInitiationIDFromString(aux.ID) + if err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + var sourceAccountID *AccountID + if aux.SourceAccountID != nil { + id, err := AccountIDFromString(*aux.SourceAccountID) + if err != nil { + return err + } + sourceAccountID = &id + } + + var destinationAccountID *AccountID + if aux.DestinationAccountID != nil { + id, err := AccountIDFromString(*aux.DestinationAccountID) + if err != nil { + return err + } + destinationAccountID = &id + } + + pi.ID = id + pi.ConnectorID = connectorID + pi.Reference = aux.Reference + pi.CreatedAt = aux.CreatedAt + pi.ScheduledAt = aux.ScheduledAt + pi.Description = aux.Description + pi.Type = aux.Type + pi.SourceAccountID = sourceAccountID + pi.DestinationAccountID = destinationAccountID + pi.Amount = aux.Amount + pi.Asset = aux.Asset + pi.Metadata = aux.Metadata + + return nil +} + +func FromPaymentInitiationToPSPPaymentInitiation(from *PaymentInitiation, sourceAccount, destinationAccount *PSPAccount) PSPPaymentInitiation { + return PSPPaymentInitiation{ + Reference: from.Reference, + CreatedAt: from.ScheduledAt, // Scheduled at should be the creation time of the payment on the PSP + Description: from.Description, + SourceAccount: sourceAccount, + DestinationAccount: destinationAccount, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + } +} + +type PaymentInitiationExpanded struct { + PaymentInitiation PaymentInitiation + Status PaymentInitiationAdjustmentStatus + Error error +} + +func (pi PaymentInitiationExpanded) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + ScheduledAt time.Time `json:"scheduledAt"` + Description string `json:"description"` + Type PaymentInitiationType `json:"paymentInitiationType"` + SourceAccountID *string `json:"sourceAccountID,omitempty"` + DestinationAccountID *string `json:"destinationAccountID,omitempty"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Metadata map[string]string `json:"metadata"` + Status string `json:"status"` + Error *string `json:"error,omitempty"` + }{ + ID: pi.PaymentInitiation.ID.String(), + ConnectorID: pi.PaymentInitiation.ConnectorID.String(), + Reference: pi.PaymentInitiation.Reference, + CreatedAt: pi.PaymentInitiation.CreatedAt, + ScheduledAt: pi.PaymentInitiation.ScheduledAt, + Description: pi.PaymentInitiation.Description, + Type: pi.PaymentInitiation.Type, + SourceAccountID: func() *string { + if pi.PaymentInitiation.SourceAccountID == nil { + return nil + } + return pointer.For(pi.PaymentInitiation.SourceAccountID.String()) + }(), + DestinationAccountID: func() *string { + if pi.PaymentInitiation.DestinationAccountID == nil { + return nil + } + return pointer.For(pi.PaymentInitiation.DestinationAccountID.String()) + }(), + Amount: pi.PaymentInitiation.Amount, + Asset: pi.PaymentInitiation.Asset, + Metadata: pi.PaymentInitiation.Metadata, + Status: pi.Status.String(), + Error: func() *string { + if pi.Error == nil { + return nil + } + return pointer.For(pi.Error.Error()) + }(), + }) +} diff --git a/internal/models/payment_scheme.go b/internal/models/payment_scheme.go new file mode 100644 index 00000000..d62ce964 --- /dev/null +++ b/internal/models/payment_scheme.go @@ -0,0 +1,212 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +// TODO(polo): use stringer generator +type PaymentScheme int + +const ( + PAYMENT_SCHEME_UNKNOWN PaymentScheme = iota + + PAYMENT_SCHEME_CARD_VISA + PAYMENT_SCHEME_CARD_MASTERCARD + PAYMENT_SCHEME_CARD_AMEX + PAYMENT_SCHEME_CARD_DINERS + PAYMENT_SCHEME_CARD_DISCOVER + PAYMENT_SCHEME_CARD_JCB + PAYMENT_SCHEME_CARD_UNION_PAY + PAYMENT_SCHEME_CARD_ALIPAY + PAYMENT_SCHEME_CARD_CUP + + PAYMENT_SCHEME_SEPA_DEBIT + PAYMENT_SCHEME_SEPA_CREDIT + PAYMENT_SCHEME_SEPA + + PAYMENT_SCHEME_GOOGLE_PAY + PAYMENT_SCHEME_APPLE_PAY + + PAYMENT_SCHEME_DOKU + PAYMENT_SCHEME_DRAGON_PAY + PAYMENT_SCHEME_MAESTRO + PAYMENT_SCHEME_MOL_PAY + + PAYMENT_SCHEME_A2A + PAYMENT_SCHEME_ACH_DEBIT + PAYMENT_SCHEME_ACH + PAYMENT_SCHEME_RTP + + PAYMENT_SCHEME_OTHER = 100 +) + +func (s PaymentScheme) String() string { + switch s { + case PAYMENT_SCHEME_UNKNOWN: + return "UNKNOWN" + case PAYMENT_SCHEME_CARD_VISA: + return "CARD_VISA" + case PAYMENT_SCHEME_CARD_MASTERCARD: + return "CARD_MASTERCARD" + case PAYMENT_SCHEME_CARD_AMEX: + return "CARD_AMEX" + case PAYMENT_SCHEME_CARD_DINERS: + return "CARD_DINERS" + case PAYMENT_SCHEME_CARD_DISCOVER: + return "CARD_DISCOVER" + case PAYMENT_SCHEME_CARD_JCB: + return "CARD_JCB" + case PAYMENT_SCHEME_CARD_UNION_PAY: + return "CARD_UNION_PAY" + case PAYMENT_SCHEME_CARD_ALIPAY: + return "CARD_ALIPAY" + case PAYMENT_SCHEME_CARD_CUP: + return "CARD_CUP" + case PAYMENT_SCHEME_SEPA_DEBIT: + return "SEPA_DEBIT" + case PAYMENT_SCHEME_SEPA_CREDIT: + return "SEPA_CREDIT" + case PAYMENT_SCHEME_SEPA: + return "SEPA" + case PAYMENT_SCHEME_GOOGLE_PAY: + return "GOOGLE_PAY" + case PAYMENT_SCHEME_APPLE_PAY: + return "APPLE_PAY" + case PAYMENT_SCHEME_DOKU: + return "DOKU" + case PAYMENT_SCHEME_DRAGON_PAY: + return "DRAGON_PAY" + case PAYMENT_SCHEME_MAESTRO: + return "MAESTRO" + case PAYMENT_SCHEME_MOL_PAY: + return "MOL_PAY" + case PAYMENT_SCHEME_A2A: + return "A2A" + case PAYMENT_SCHEME_ACH_DEBIT: + return "ACH_DEBIT" + case PAYMENT_SCHEME_ACH: + return "ACH" + case PAYMENT_SCHEME_RTP: + return "RTP" + case PAYMENT_SCHEME_OTHER: + return "OTHER" + default: + return "UNKNOWN" + } +} + +func PaymentSchemeFromString(value string) (PaymentScheme, error) { + switch value { + case "CARD_VISA": + return PAYMENT_SCHEME_CARD_VISA, nil + case "CARD_MASTERCARD": + return PAYMENT_SCHEME_CARD_MASTERCARD, nil + case "CARD_AMEX": + return PAYMENT_SCHEME_CARD_AMEX, nil + case "CARD_DINERS": + return PAYMENT_SCHEME_CARD_DINERS, nil + case "CARD_DISCOVER": + return PAYMENT_SCHEME_CARD_DISCOVER, nil + case "CARD_JCB": + return PAYMENT_SCHEME_CARD_JCB, nil + case "CARD_UNION_PAY": + return PAYMENT_SCHEME_CARD_UNION_PAY, nil + case "CARD_ALIPAY": + return PAYMENT_SCHEME_CARD_ALIPAY, nil + case "CARD_CUP": + return PAYMENT_SCHEME_CARD_CUP, nil + case "SEPA_DEBIT": + return PAYMENT_SCHEME_SEPA_DEBIT, nil + case "SEPA_CREDIT": + return PAYMENT_SCHEME_SEPA_CREDIT, nil + case "SEPA": + return PAYMENT_SCHEME_SEPA, nil + case "GOOGLE_PAY": + return PAYMENT_SCHEME_GOOGLE_PAY, nil + case "APPLE_PAY": + return PAYMENT_SCHEME_APPLE_PAY, nil + case "DOKU": + return PAYMENT_SCHEME_DOKU, nil + case "DRAGON_PAY": + return PAYMENT_SCHEME_DRAGON_PAY, nil + case "MAESTRO": + return PAYMENT_SCHEME_MAESTRO, nil + case "MOL_PAY": + return PAYMENT_SCHEME_MOL_PAY, nil + case "A2A": + return PAYMENT_SCHEME_A2A, nil + case "ACH_DEBIT": + return PAYMENT_SCHEME_ACH_DEBIT, nil + case "ACH": + return PAYMENT_SCHEME_ACH, nil + case "RTP": + return PAYMENT_SCHEME_RTP, nil + case "OTHER": + return PAYMENT_SCHEME_OTHER, nil + case "UNKNOWN": + return PAYMENT_SCHEME_UNKNOWN, nil + default: + return PAYMENT_SCHEME_UNKNOWN, fmt.Errorf("unknown payment scheme") + } +} + +func MustPaymentSchemeFromString(value string) PaymentScheme { + t, err := PaymentSchemeFromString(value) + if err != nil { + panic(err) + } + + return t +} + +func (s PaymentScheme) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, s.String())), nil +} + +func (s *PaymentScheme) UnmarshalJSON(data []byte) error { + var v string + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + value, err := PaymentSchemeFromString(v) + if err != nil { + return err + } + + *s = value + + return nil +} + +func (s PaymentScheme) Value() (driver.Value, error) { + return s.String(), nil +} + +func (s *PaymentScheme) Scan(value interface{}) error { + if value == nil { + return errors.New("payment type is nil") + } + + st, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert payment type") + } + + v, ok := st.(string) + if !ok { + return fmt.Errorf("failed to cast payment type") + } + + res, err := PaymentSchemeFromString(v) + if err != nil { + return err + } + + *s = res + + return nil +} diff --git a/internal/models/payment_status.go b/internal/models/payment_status.go new file mode 100644 index 00000000..4dee2087 --- /dev/null +++ b/internal/models/payment_status.go @@ -0,0 +1,169 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +type PaymentStatus int + +const ( + PAYMENT_STATUS_UNKNOWN PaymentStatus = iota + PAYMENT_STATUS_PENDING + PAYMENT_STATUS_SUCCEEDED + PAYMENT_STATUS_CANCELLED + PAYMENT_STATUS_FAILED + PAYMENT_STATUS_EXPIRED + PAYMENT_STATUS_REFUNDED + PAYMENT_STATUS_REFUNDED_FAILURE + PAYMENT_STATUS_REFUND_REVERSED + PAYMENT_STATUS_DISPUTE + PAYMENT_STATUS_DISPUTE_WON + PAYMENT_STATUS_DISPUTE_LOST + PAYMENT_STATUS_AMOUNT_ADJUSTEMENT + PAYMENT_STATUS_AUTHORISATION + PAYMENT_STATUS_CAPTURE + PAYMENT_STATUS_CAPTURE_FAILED + PAYMENT_STATUS_OTHER = 100 +) + +func (t PaymentStatus) String() string { + switch t { + case PAYMENT_STATUS_UNKNOWN: + return "UNKNOWN" + case PAYMENT_STATUS_PENDING: + return "PENDING" + case PAYMENT_STATUS_SUCCEEDED: + return "SUCCEEDED" + case PAYMENT_STATUS_CANCELLED: + return "CANCELLED" + case PAYMENT_STATUS_FAILED: + return "FAILED" + case PAYMENT_STATUS_EXPIRED: + return "EXPIRED" + case PAYMENT_STATUS_REFUNDED: + return "REFUNDED" + case PAYMENT_STATUS_REFUNDED_FAILURE: + return "REFUNDED_FAILURE" + case PAYMENT_STATUS_REFUND_REVERSED: + return "REFUND_REVERSED" + case PAYMENT_STATUS_DISPUTE: + return "DISPUTE" + case PAYMENT_STATUS_DISPUTE_WON: + return "DISPUTE_WON" + case PAYMENT_STATUS_DISPUTE_LOST: + return "DISPUTE_LOST" + case PAYMENT_STATUS_AMOUNT_ADJUSTEMENT: + return "AMOUNT_ADJUSTEMENT" + case PAYMENT_STATUS_AUTHORISATION: + return "AUTHORISATION" + case PAYMENT_STATUS_CAPTURE: + return "CAPTURE" + case PAYMENT_STATUS_CAPTURE_FAILED: + return "CAPTURE_FAILED" + case PAYMENT_STATUS_OTHER: + return "OTHER" + default: + return "UNKNOWN" + } +} + +func PaymentStatusFromString(value string) (PaymentStatus, error) { + switch value { + case "PENDING": + return PAYMENT_STATUS_PENDING, nil + case "SUCCEEDED": + return PAYMENT_STATUS_SUCCEEDED, nil + case "CANCELLED": + return PAYMENT_STATUS_CANCELLED, nil + case "FAILED": + return PAYMENT_STATUS_FAILED, nil + case "EXPIRED": + return PAYMENT_STATUS_EXPIRED, nil + case "REFUNDED": + return PAYMENT_STATUS_REFUNDED, nil + case "REFUNDED_FAILURE": + return PAYMENT_STATUS_REFUNDED_FAILURE, nil + case "REFUND_REVERSED": + return PAYMENT_STATUS_REFUND_REVERSED, nil + case "DISPUTE": + return PAYMENT_STATUS_DISPUTE, nil + case "DISPUTE_WON": + return PAYMENT_STATUS_DISPUTE_WON, nil + case "DISPUTE_LOST": + return PAYMENT_STATUS_DISPUTE_LOST, nil + case "AMOUNT_ADJUSTEMENT": + return PAYMENT_STATUS_AMOUNT_ADJUSTEMENT, nil + case "AUTHORISATION": + return PAYMENT_STATUS_AUTHORISATION, nil + case "CAPTURE": + return PAYMENT_STATUS_CAPTURE, nil + case "CAPTURE_FAILED": + return PAYMENT_STATUS_CAPTURE_FAILED, nil + case "OTHER": + return PAYMENT_STATUS_OTHER, nil + case "UNKNOWN": + return PAYMENT_STATUS_UNKNOWN, nil + default: + return PAYMENT_STATUS_UNKNOWN, fmt.Errorf("unknown payment status") + } +} + +func MustPaymentStatusFromString(value string) PaymentStatus { + v, err := PaymentStatusFromString(value) + if err != nil { + panic(err) + } + return v +} + +func (t PaymentStatus) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, t.String())), nil +} + +func (t *PaymentStatus) UnmarshalJSON(data []byte) error { + var v string + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + value, err := PaymentStatusFromString(v) + if err != nil { + return err + } + + *t = value + + return nil +} + +func (t PaymentStatus) Value() (driver.Value, error) { + return t.String(), nil +} + +func (t *PaymentStatus) Scan(value interface{}) error { + if value == nil { + return errors.New("payment status is nil") + } + + s, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert payment status") + } + + v, ok := s.(string) + if !ok { + return fmt.Errorf("failed to cast payment status") + } + + res, err := PaymentStatusFromString(v) + if err != nil { + return err + } + + *t = res + + return nil +} diff --git a/internal/models/payment_test.go b/internal/models/payment_test.go new file mode 100644 index 00000000..d2a4a0d7 --- /dev/null +++ b/internal/models/payment_test.go @@ -0,0 +1,221 @@ +package models + +import ( + "math/big" + "testing" + "time" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestFromPSPPaymentToPayment(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + connectorID := ConnectorID{ + Reference: uuid.New(), + Provider: "test", + } + + t.Run("parent reference is empty", func(t *testing.T) { + t.Parallel() + + pspPayment := PSPPayment{ + ParentReference: "", + Reference: "test1", + CreatedAt: now.UTC(), + Type: PAYMENT_TYPE_PAYOUT, + Amount: big.NewInt(100), + Asset: "USD/2", + Scheme: PAYMENT_SCHEME_OTHER, + Status: PAYMENT_STATUS_CANCELLED, + SourceAccountReference: pointer.For("acc"), + Metadata: map[string]string{ + "foo": "bar", + }, + Raw: []byte(`{}`), + } + + actual := FromPSPPaymentToPayment(pspPayment, connectorID) + + pid := PaymentID{ + PaymentReference: PaymentReference{ + Reference: "test1", + Type: PAYMENT_TYPE_PAYOUT, + }, + ConnectorID: connectorID, + } + expected := Payment{ + ID: pid, + ConnectorID: connectorID, + Reference: "test1", + CreatedAt: now.UTC(), + Type: PAYMENT_TYPE_PAYOUT, + InitialAmount: big.NewInt(100), + Amount: big.NewInt(100), + Asset: "USD/2", + Scheme: PAYMENT_SCHEME_OTHER, + Status: PAYMENT_STATUS_CANCELLED, + SourceAccountID: &AccountID{ + Reference: "acc", + ConnectorID: connectorID, + }, + Metadata: map[string]string{ + "foo": "bar", + }, + Adjustments: []PaymentAdjustment{ + { + ID: PaymentAdjustmentID{ + PaymentID: pid, + Reference: "test1", + CreatedAt: now.UTC(), + Status: PAYMENT_STATUS_CANCELLED, + }, + PaymentID: pid, + Reference: "test1", + CreatedAt: now.UTC(), + Status: PAYMENT_STATUS_CANCELLED, + Amount: big.NewInt(100), + Asset: pointer.For("USD/2"), + Metadata: map[string]string{ + "foo": "bar", + }, + Raw: []byte(`{}`), + }, + }, + } + + comparePayment(t, expected, actual) + }) + + t.Run("parent reference is not empty", func(t *testing.T) { + t.Parallel() + + pspPayment := PSPPayment{ + ParentReference: "parent_reference", + Reference: "test1", + CreatedAt: now.UTC(), + Type: PAYMENT_TYPE_TRANSFER, + Amount: big.NewInt(150), + Asset: "EUR/2", + Scheme: PAYMENT_SCHEME_OTHER, + Status: PAYMENT_STATUS_SUCCEEDED, + DestinationAccountReference: pointer.For("acc"), + Metadata: map[string]string{ + "foo": "bar", + }, + Raw: []byte(`{}`), + } + + actual := FromPSPPaymentToPayment(pspPayment, connectorID) + + pid := PaymentID{ + PaymentReference: PaymentReference{ + Reference: "parent_reference", + Type: PAYMENT_TYPE_TRANSFER, + }, + ConnectorID: connectorID, + } + expected := Payment{ + ID: pid, + ConnectorID: connectorID, + Reference: "parent_reference", + CreatedAt: now.UTC(), + Type: PAYMENT_TYPE_TRANSFER, + InitialAmount: big.NewInt(150), + Amount: big.NewInt(150), + Asset: "EUR/2", + Scheme: PAYMENT_SCHEME_OTHER, + Status: PAYMENT_STATUS_SUCCEEDED, + DestinationAccountID: &AccountID{ + Reference: "acc", + ConnectorID: connectorID, + }, + Metadata: map[string]string{ + "foo": "bar", + }, + Adjustments: []PaymentAdjustment{ + { + ID: PaymentAdjustmentID{ + PaymentID: pid, + Reference: "test1", + CreatedAt: now.UTC(), + Status: PAYMENT_STATUS_SUCCEEDED, + }, + PaymentID: pid, + Reference: "test1", + CreatedAt: now.UTC(), + Status: PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(150), + Asset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "foo": "bar", + }, + Raw: []byte(`{}`), + }, + }, + } + + comparePayment(t, expected, actual) + }) +} + +func comparePayment(t *testing.T, expected, actual Payment) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.ConnectorID, actual.ConnectorID) + require.Equal(t, expected.Reference, actual.Reference) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Type, actual.Type) + require.Equal(t, expected.InitialAmount, actual.InitialAmount) + require.Equal(t, expected.Amount, actual.Amount) + require.Equal(t, expected.Asset, actual.Asset) + require.Equal(t, expected.Scheme, actual.Scheme) + require.Equal(t, expected.Status, actual.Status) + + switch { + case expected.SourceAccountID == nil && actual.SourceAccountID == nil: + case expected.SourceAccountID != nil && actual.SourceAccountID != nil: + require.Equal(t, *expected.SourceAccountID, *actual.SourceAccountID) + default: + require.Fail(t, "source account id mismatch") + } + + switch { + case expected.DestinationAccountID == nil && actual.DestinationAccountID == nil: + case expected.DestinationAccountID != nil && actual.DestinationAccountID != nil: + require.Equal(t, *expected.DestinationAccountID, *actual.DestinationAccountID) + default: + require.Fail(t, "destination account id mismatch") + } + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } + + compareAdjustments(t, expected.Adjustments, actual.Adjustments) +} + +func compareAdjustments(t *testing.T, expected, actual []PaymentAdjustment) { + require.Equal(t, len(expected), len(actual)) + for i := range expected { + require.Equal(t, expected[i].ID, actual[i].ID) + require.Equal(t, expected[i].PaymentID, actual[i].PaymentID) + require.Equal(t, expected[i].Reference, actual[i].Reference) + require.Equal(t, expected[i].CreatedAt, actual[i].CreatedAt) + require.Equal(t, expected[i].Status, actual[i].Status) + require.Equal(t, expected[i].Amount, actual[i].Amount) + require.Equal(t, expected[i].Asset, actual[i].Asset) + + require.Equal(t, len(expected[i].Metadata), len(actual[i].Metadata)) + for k, v := range expected[i].Metadata { + _, ok := actual[i].Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual[i].Metadata[k]) + } + } +} diff --git a/internal/models/payment_type.go b/internal/models/payment_type.go new file mode 100644 index 00000000..17a608b3 --- /dev/null +++ b/internal/models/payment_type.go @@ -0,0 +1,108 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +type PaymentType int + +const ( + PAYMENT_TYPE_UNKNOWN PaymentType = iota + PAYMENT_TYPE_PAYIN + PAYMENT_TYPE_PAYOUT + PAYMENT_TYPE_TRANSFER + PAYMENT_TYPE_OTHER = 100 +) + +func (t PaymentType) String() string { + switch t { + case PAYMENT_TYPE_PAYIN: + return "PAY-IN" + case PAYMENT_TYPE_PAYOUT: + return "PAYOUT" + case PAYMENT_TYPE_TRANSFER: + return "TRANSFER" + case PAYMENT_TYPE_OTHER: + return "OTHER" + default: + return "UNKNOWN" + } +} + +func PaymentTypeFromString(value string) (PaymentType, error) { + switch value { + case "PAY-IN": + return PAYMENT_TYPE_PAYIN, nil + case "PAYOUT": + return PAYMENT_TYPE_PAYOUT, nil + case "TRANSFER": + return PAYMENT_TYPE_TRANSFER, nil + case "OTHER": + return PAYMENT_TYPE_OTHER, nil + case "UNKNOWN": + return PAYMENT_TYPE_UNKNOWN, nil + default: + return PAYMENT_TYPE_UNKNOWN, fmt.Errorf("unknown payment type") + } +} + +func MustPaymentTypeFromString(value string) PaymentType { + t, err := PaymentTypeFromString(value) + if err != nil { + panic(err) + } + + return t +} + +func (t PaymentType) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, t.String())), nil +} + +func (t *PaymentType) UnmarshalJSON(data []byte) error { + var v string + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + value, err := PaymentTypeFromString(v) + if err != nil { + return err + } + + *t = value + + return nil +} + +func (t PaymentType) Value() (driver.Value, error) { + return t.String(), nil +} + +func (t *PaymentType) Scan(value interface{}) error { + if value == nil { + return errors.New("payment type is nil") + } + + s, err := driver.String.ConvertValue(value) + if err != nil { + return fmt.Errorf("failed to convert payment type") + } + + v, ok := s.(string) + if !ok { + return fmt.Errorf("failed to cast payment type") + } + + res, err := PaymentTypeFromString(v) + if err != nil { + return err + } + + *t = res + + return nil +} diff --git a/internal/models/payments.go b/internal/models/payments.go new file mode 100644 index 00000000..8ae6cead --- /dev/null +++ b/internal/models/payments.go @@ -0,0 +1,304 @@ +package models + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/pointer" +) + +// Internal struct used by the plugins +type PSPPayment struct { + // Original PSP payment/transaction reference. + // In case of refunds, dispute etc... this reference should be the original + // payment reference. In case it's the first payment, ths reference should + // be empty + ParentReference string + + // PSP payment/transaction reference. Should be unique. + Reference string + + // Payment Creation date. + CreatedAt time.Time + + // Type of payment: payin, payout, transfer etc... + Type PaymentType + + // Payment amount. + Amount *big.Int + + // Currency. Should be in minor currencies unit. + // For example: USD/2 + Asset string + + // Payment scheme if existing: visa, mastercard etc... + Scheme PaymentScheme + + // Payment status: pending, failed, succeeded etc... + Status PaymentStatus + + // Optional, can be filled for payouts and transfers for example. + SourceAccountReference *string + // Optional, can be filled for payins and transfers for example. + DestinationAccountReference *string + + // Additional metadata + Metadata map[string]string + + // PSP response in raw + Raw json.RawMessage +} + +func (p *PSPPayment) HasParent() bool { + return p.ParentReference != "" +} + +type Payment struct { + // Unique Payment ID generated from payments information + ID PaymentID `json:"id"` + // Related Connector ID + ConnectorID ConnectorID `json:"connectorID"` + + // PSP payment/transaction reference. Should be unique. + Reference string `json:"reference"` + + // Payment Creation date. + CreatedAt time.Time `json:"createdAt"` + + // Type of payment: payin, payout, transfer etc... + Type PaymentType `json:"type"` + + // Payment Initial amount + InitialAmount *big.Int `json:"initialAmount"` + // Payment amount. + Amount *big.Int `json:"amount"` + + // Currency. Should be in minor currencies unit. + // For example: USD/2 + Asset string `json:"asset"` + + // Payment scheme if existing: visa, mastercard etc... + Scheme PaymentScheme `json:"scheme"` + + // Payment status: pending, failed, succeeded etc... + Status PaymentStatus `json:"status"` + + // Optional, can be filled for payouts and transfers for example. + SourceAccountID *AccountID `json:"sourceAccountID"` + // Optional, can be filled for payins and transfers for example. + DestinationAccountID *AccountID `json:"destinationAccountID"` + + // Additional metadata + Metadata map[string]string `json:"metadata"` + + // Related adjustment + Adjustments []PaymentAdjustment `json:"adjustments"` +} + +func (p Payment) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Type PaymentType `json:"type"` + InitialAmount *big.Int `json:"initialAmount"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Scheme PaymentScheme `json:"scheme"` + Status PaymentStatus `json:"status"` + SourceAccountID *string `json:"sourceAccountID"` + DestinationAccountID *string `json:"destinationAccountID"` + Metadata map[string]string `json:"metadata"` + Adjustments []PaymentAdjustment `json:"adjustments"` + }{ + ID: p.ID.String(), + ConnectorID: p.ConnectorID.String(), + Reference: p.Reference, + CreatedAt: p.CreatedAt, + Type: p.Type, + InitialAmount: p.InitialAmount, + Amount: p.Amount, + Asset: p.Asset, + Scheme: p.Scheme, + Status: p.Status, + SourceAccountID: func() *string { + if p.SourceAccountID == nil { + return nil + } + return pointer.For(p.SourceAccountID.String()) + }(), + DestinationAccountID: func() *string { + if p.DestinationAccountID == nil { + return nil + } + return pointer.For(p.DestinationAccountID.String()) + }(), + Metadata: p.Metadata, + Adjustments: p.Adjustments, + }) +} + +func (c *Payment) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Type PaymentType `json:"type"` + InitialAmount *big.Int `json:"initialAmount"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Scheme PaymentScheme `json:"scheme"` + Status PaymentStatus `json:"status"` + SourceAccountID *string `json:"sourceAccountID"` + DestinationAccountID *string `json:"destinationAccountID"` + Metadata map[string]string `json:"metadata"` + Adjustments []PaymentAdjustment `json:"adjustments"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := PaymentIDFromString(aux.ID) + if err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + var sourceAccountID *AccountID + if aux.SourceAccountID != nil { + id, err := AccountIDFromString(*aux.SourceAccountID) + if err != nil { + return err + } + sourceAccountID = &id + } + + var destinationAccountID *AccountID + if aux.DestinationAccountID != nil { + id, err := AccountIDFromString(*aux.DestinationAccountID) + if err != nil { + return err + } + destinationAccountID = &id + } + + c.ID = id + c.ConnectorID = connectorID + c.Reference = aux.Reference + c.CreatedAt = aux.CreatedAt + c.Type = aux.Type + c.InitialAmount = aux.InitialAmount + c.Amount = aux.Amount + c.Asset = aux.Asset + c.Scheme = aux.Scheme + c.Status = aux.Status + c.SourceAccountID = sourceAccountID + c.DestinationAccountID = destinationAccountID + c.Metadata = aux.Metadata + c.Adjustments = aux.Adjustments + + return nil +} + +func FromPSPPaymentToPayment(from PSPPayment, connectorID ConnectorID) Payment { + paymentReference := from.Reference + if from.HasParent() { + paymentReference = from.ParentReference + } + + p := Payment{ + ID: PaymentID{ + PaymentReference: PaymentReference{ + Reference: paymentReference, + Type: from.Type, + }, + ConnectorID: connectorID, + }, + ConnectorID: connectorID, + Reference: paymentReference, + CreatedAt: from.CreatedAt, + Type: from.Type, + InitialAmount: from.Amount, + Amount: from.Amount, + Asset: from.Asset, + Scheme: from.Scheme, + Status: from.Status, + SourceAccountID: func() *AccountID { + if from.SourceAccountReference == nil { + return nil + } + return &AccountID{ + Reference: *from.SourceAccountReference, + ConnectorID: connectorID, + } + }(), + DestinationAccountID: func() *AccountID { + if from.DestinationAccountReference == nil { + return nil + } + return &AccountID{ + Reference: *from.DestinationAccountReference, + ConnectorID: connectorID, + } + }(), + Metadata: from.Metadata, + } + + if p.Status == PAYMENT_STATUS_AUTHORISATION { + // Will be capture later + p.Amount = big.NewInt(0) + } + + p.Adjustments = append(p.Adjustments, FromPSPPaymentToPaymentAdjustement(from, connectorID)) + + return p +} + +func FromPSPPayments(from []PSPPayment, connectorID ConnectorID) []Payment { + payments := make([]Payment, 0, len(from)) + for _, p := range from { + payment := FromPSPPaymentToPayment(p, connectorID) + payments = append(payments, payment) + } + return payments +} + +func FromPSPPaymentToPaymentAdjustement(from PSPPayment, connectorID ConnectorID) PaymentAdjustment { + parentReference := from.Reference + if from.HasParent() { + parentReference = from.ParentReference + } + + paymentID := PaymentID{ + PaymentReference: PaymentReference{ + Reference: parentReference, + Type: from.Type, + }, + ConnectorID: connectorID, + } + + return PaymentAdjustment{ + ID: PaymentAdjustmentID{ + PaymentID: paymentID, + Reference: from.Reference, + CreatedAt: from.CreatedAt, + Status: from.Status, + }, + PaymentID: paymentID, + Reference: from.Reference, + CreatedAt: from.CreatedAt, + Status: from.Status, + Amount: from.Amount, + Asset: &from.Asset, + Metadata: from.Metadata, + Raw: from.Raw, + } +} diff --git a/internal/models/plugin.go b/internal/models/plugin.go new file mode 100644 index 00000000..1671159b --- /dev/null +++ b/internal/models/plugin.go @@ -0,0 +1,211 @@ +package models + +import ( + "context" + "encoding/json" +) + +type PluginConstructorFn func() Plugin + +//go:generate mockgen -source plugin.go -destination plugin_generated.go -package models . Plugin +type Plugin interface { + Name() string + + Install(context.Context, InstallRequest) (InstallResponse, error) + Uninstall(context.Context, UninstallRequest) (UninstallResponse, error) + + FetchNextAccounts(context.Context, FetchNextAccountsRequest) (FetchNextAccountsResponse, error) + FetchNextPayments(context.Context, FetchNextPaymentsRequest) (FetchNextPaymentsResponse, error) + FetchNextBalances(context.Context, FetchNextBalancesRequest) (FetchNextBalancesResponse, error) + FetchNextExternalAccounts(context.Context, FetchNextExternalAccountsRequest) (FetchNextExternalAccountsResponse, error) + FetchNextOthers(context.Context, FetchNextOthersRequest) (FetchNextOthersResponse, error) + + CreateBankAccount(context.Context, CreateBankAccountRequest) (CreateBankAccountResponse, error) + CreateTransfer(context.Context, CreateTransferRequest) (CreateTransferResponse, error) + ReverseTransfer(context.Context, ReverseTransferRequest) (ReverseTransferResponse, error) + PollTransferStatus(context.Context, PollTransferStatusRequest) (PollTransferStatusResponse, error) + CreatePayout(context.Context, CreatePayoutRequest) (CreatePayoutResponse, error) + ReversePayout(context.Context, ReversePayoutRequest) (ReversePayoutResponse, error) + PollPayoutStatus(context.Context, PollPayoutStatusRequest) (PollPayoutStatusResponse, error) + + CreateWebhooks(context.Context, CreateWebhooksRequest) (CreateWebhooksResponse, error) + TranslateWebhook(context.Context, TranslateWebhookRequest) (TranslateWebhookResponse, error) +} + +type InstallRequest struct{} + +type InstallResponse struct { + Workflow ConnectorTasksTree + WebhooksConfigs []PSPWebhookConfig +} + +type UninstallRequest struct { + ConnectorID string +} + +type UninstallResponse struct{} + +type FetchNextAccountsRequest struct { + FromPayload json.RawMessage + State json.RawMessage + PageSize int +} + +type FetchNextAccountsResponse struct { + Accounts []PSPAccount + NewState json.RawMessage + HasMore bool +} + +type FetchNextExternalAccountsRequest struct { + FromPayload json.RawMessage + State json.RawMessage + PageSize int +} + +type FetchNextExternalAccountsResponse struct { + ExternalAccounts []PSPAccount + NewState json.RawMessage + HasMore bool +} + +type FetchNextPaymentsRequest struct { + FromPayload json.RawMessage + State json.RawMessage + PageSize int +} + +type FetchNextPaymentsResponse struct { + Payments []PSPPayment + NewState json.RawMessage + HasMore bool +} + +type FetchNextOthersRequest struct { + Name string + FromPayload json.RawMessage + State json.RawMessage + PageSize int +} + +type FetchNextOthersResponse struct { + Others []PSPOther + NewState json.RawMessage + HasMore bool +} + +type FetchNextBalancesRequest struct { + FromPayload json.RawMessage + State json.RawMessage + PageSize int +} + +type FetchNextBalancesResponse struct { + Balances []PSPBalance + NewState json.RawMessage + HasMore bool +} + +type CreateBankAccountRequest struct { + BankAccount BankAccount +} + +type CreateBankAccountResponse struct { + RelatedAccount PSPAccount +} + +type CreateWebhooksRequest struct { + FromPayload json.RawMessage + ConnectorID string + WebhookBaseUrl string +} + +type CreateWebhooksResponse struct { + Others []PSPOther +} + +type TranslateWebhookRequest struct { + Name string + Webhook PSPWebhook +} + +type WebhookResponse struct { + IdempotencyKey string + Account *PSPAccount + ExternalAccount *PSPAccount + Payment *PSPPayment +} + +type TranslateWebhookResponse struct { + Responses []WebhookResponse +} + +type CreateTransferRequest struct { + PaymentInitiation PSPPaymentInitiation +} + +type CreateTransferResponse struct { + // If payment is immediately available, it will be return here and + // the workflow will be terminated + Payment *PSPPayment + // Otherwise, the payment will be nil and the transfer ID will be returned + // to be polled regularly until the payment is available + PollingTransferID *string +} + +type ReverseTransferRequest struct { + PaymentInitiationReversal PSPPaymentInitiationReversal +} +type ReverseTransferResponse struct { + Payment PSPPayment +} + +type PollTransferStatusRequest struct { + TransferID string +} + +type PollTransferStatusResponse struct { + // If nil, the payment is not yet available and the function will be called + // again later + // If not, the payment is available and the workflow will be terminated + Payment *PSPPayment + + // If not nil, it means that the transfer failed, the payment initiation + // will be marked as fail and the workflow will be terminated + Error error +} + +type CreatePayoutRequest struct { + PaymentInitiation PSPPaymentInitiation +} + +type CreatePayoutResponse struct { + // If payment is immediately available, it will be return here and + // the workflow will be terminated + Payment *PSPPayment + // Otherwise, the payment will be nil and the payout ID will be returned + // to be polled regularly until the payment is available + PollingPayoutID *string +} + +type ReversePayoutRequest struct { + PaymentInitiationReversal PSPPaymentInitiationReversal +} +type ReversePayoutResponse struct { + Payment PSPPayment +} + +type PollPayoutStatusRequest struct { + PayoutID string +} + +type PollPayoutStatusResponse struct { + // If nil, the payment is not yet available and the function will be called + // again later + // If not, the payment is available and the workflow will be terminated + Payment *PSPPayment + + // If not nil, it means that the transfer failed, the payment initiation + // will be marked as fail and the workflow will be terminated + Error error +} diff --git a/internal/models/plugin_generated.go b/internal/models/plugin_generated.go new file mode 100644 index 00000000..18151b29 --- /dev/null +++ b/internal/models/plugin_generated.go @@ -0,0 +1,294 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: plugin.go +// +// Generated by this command: +// +// mockgen -source plugin.go -destination plugin_generated.go -package models . Plugin +// + +// Package models is a generated GoMock package. +package models + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockPlugin is a mock of Plugin interface. +type MockPlugin struct { + ctrl *gomock.Controller + recorder *MockPluginMockRecorder +} + +// MockPluginMockRecorder is the mock recorder for MockPlugin. +type MockPluginMockRecorder struct { + mock *MockPlugin +} + +// NewMockPlugin creates a new mock instance. +func NewMockPlugin(ctrl *gomock.Controller) *MockPlugin { + mock := &MockPlugin{ctrl: ctrl} + mock.recorder = &MockPluginMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPlugin) EXPECT() *MockPluginMockRecorder { + return m.recorder +} + +// CreateBankAccount mocks base method. +func (m *MockPlugin) CreateBankAccount(arg0 context.Context, arg1 CreateBankAccountRequest) (CreateBankAccountResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateBankAccount", arg0, arg1) + ret0, _ := ret[0].(CreateBankAccountResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateBankAccount indicates an expected call of CreateBankAccount. +func (mr *MockPluginMockRecorder) CreateBankAccount(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBankAccount", reflect.TypeOf((*MockPlugin)(nil).CreateBankAccount), arg0, arg1) +} + +// CreatePayout mocks base method. +func (m *MockPlugin) CreatePayout(arg0 context.Context, arg1 CreatePayoutRequest) (CreatePayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePayout", arg0, arg1) + ret0, _ := ret[0].(CreatePayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePayout indicates an expected call of CreatePayout. +func (mr *MockPluginMockRecorder) CreatePayout(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePayout", reflect.TypeOf((*MockPlugin)(nil).CreatePayout), arg0, arg1) +} + +// CreateTransfer mocks base method. +func (m *MockPlugin) CreateTransfer(arg0 context.Context, arg1 CreateTransferRequest) (CreateTransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTransfer", arg0, arg1) + ret0, _ := ret[0].(CreateTransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTransfer indicates an expected call of CreateTransfer. +func (mr *MockPluginMockRecorder) CreateTransfer(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransfer", reflect.TypeOf((*MockPlugin)(nil).CreateTransfer), arg0, arg1) +} + +// CreateWebhooks mocks base method. +func (m *MockPlugin) CreateWebhooks(arg0 context.Context, arg1 CreateWebhooksRequest) (CreateWebhooksResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateWebhooks", arg0, arg1) + ret0, _ := ret[0].(CreateWebhooksResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateWebhooks indicates an expected call of CreateWebhooks. +func (mr *MockPluginMockRecorder) CreateWebhooks(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWebhooks", reflect.TypeOf((*MockPlugin)(nil).CreateWebhooks), arg0, arg1) +} + +// FetchNextAccounts mocks base method. +func (m *MockPlugin) FetchNextAccounts(arg0 context.Context, arg1 FetchNextAccountsRequest) (FetchNextAccountsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchNextAccounts", arg0, arg1) + ret0, _ := ret[0].(FetchNextAccountsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchNextAccounts indicates an expected call of FetchNextAccounts. +func (mr *MockPluginMockRecorder) FetchNextAccounts(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchNextAccounts", reflect.TypeOf((*MockPlugin)(nil).FetchNextAccounts), arg0, arg1) +} + +// FetchNextBalances mocks base method. +func (m *MockPlugin) FetchNextBalances(arg0 context.Context, arg1 FetchNextBalancesRequest) (FetchNextBalancesResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchNextBalances", arg0, arg1) + ret0, _ := ret[0].(FetchNextBalancesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchNextBalances indicates an expected call of FetchNextBalances. +func (mr *MockPluginMockRecorder) FetchNextBalances(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchNextBalances", reflect.TypeOf((*MockPlugin)(nil).FetchNextBalances), arg0, arg1) +} + +// FetchNextExternalAccounts mocks base method. +func (m *MockPlugin) FetchNextExternalAccounts(arg0 context.Context, arg1 FetchNextExternalAccountsRequest) (FetchNextExternalAccountsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchNextExternalAccounts", arg0, arg1) + ret0, _ := ret[0].(FetchNextExternalAccountsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchNextExternalAccounts indicates an expected call of FetchNextExternalAccounts. +func (mr *MockPluginMockRecorder) FetchNextExternalAccounts(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchNextExternalAccounts", reflect.TypeOf((*MockPlugin)(nil).FetchNextExternalAccounts), arg0, arg1) +} + +// FetchNextOthers mocks base method. +func (m *MockPlugin) FetchNextOthers(arg0 context.Context, arg1 FetchNextOthersRequest) (FetchNextOthersResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchNextOthers", arg0, arg1) + ret0, _ := ret[0].(FetchNextOthersResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchNextOthers indicates an expected call of FetchNextOthers. +func (mr *MockPluginMockRecorder) FetchNextOthers(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchNextOthers", reflect.TypeOf((*MockPlugin)(nil).FetchNextOthers), arg0, arg1) +} + +// FetchNextPayments mocks base method. +func (m *MockPlugin) FetchNextPayments(arg0 context.Context, arg1 FetchNextPaymentsRequest) (FetchNextPaymentsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchNextPayments", arg0, arg1) + ret0, _ := ret[0].(FetchNextPaymentsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchNextPayments indicates an expected call of FetchNextPayments. +func (mr *MockPluginMockRecorder) FetchNextPayments(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchNextPayments", reflect.TypeOf((*MockPlugin)(nil).FetchNextPayments), arg0, arg1) +} + +// Install mocks base method. +func (m *MockPlugin) Install(arg0 context.Context, arg1 InstallRequest) (InstallResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Install", arg0, arg1) + ret0, _ := ret[0].(InstallResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Install indicates an expected call of Install. +func (mr *MockPluginMockRecorder) Install(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Install", reflect.TypeOf((*MockPlugin)(nil).Install), arg0, arg1) +} + +// Name mocks base method. +func (m *MockPlugin) Name() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Name") + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockPluginMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockPlugin)(nil).Name)) +} + +// PollPayoutStatus mocks base method. +func (m *MockPlugin) PollPayoutStatus(arg0 context.Context, arg1 PollPayoutStatusRequest) (PollPayoutStatusResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PollPayoutStatus", arg0, arg1) + ret0, _ := ret[0].(PollPayoutStatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PollPayoutStatus indicates an expected call of PollPayoutStatus. +func (mr *MockPluginMockRecorder) PollPayoutStatus(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PollPayoutStatus", reflect.TypeOf((*MockPlugin)(nil).PollPayoutStatus), arg0, arg1) +} + +// PollTransferStatus mocks base method. +func (m *MockPlugin) PollTransferStatus(arg0 context.Context, arg1 PollTransferStatusRequest) (PollTransferStatusResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PollTransferStatus", arg0, arg1) + ret0, _ := ret[0].(PollTransferStatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PollTransferStatus indicates an expected call of PollTransferStatus. +func (mr *MockPluginMockRecorder) PollTransferStatus(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PollTransferStatus", reflect.TypeOf((*MockPlugin)(nil).PollTransferStatus), arg0, arg1) +} + +// ReversePayout mocks base method. +func (m *MockPlugin) ReversePayout(arg0 context.Context, arg1 ReversePayoutRequest) (ReversePayoutResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReversePayout", arg0, arg1) + ret0, _ := ret[0].(ReversePayoutResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReversePayout indicates an expected call of ReversePayout. +func (mr *MockPluginMockRecorder) ReversePayout(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReversePayout", reflect.TypeOf((*MockPlugin)(nil).ReversePayout), arg0, arg1) +} + +// ReverseTransfer mocks base method. +func (m *MockPlugin) ReverseTransfer(arg0 context.Context, arg1 ReverseTransferRequest) (ReverseTransferResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReverseTransfer", arg0, arg1) + ret0, _ := ret[0].(ReverseTransferResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReverseTransfer indicates an expected call of ReverseTransfer. +func (mr *MockPluginMockRecorder) ReverseTransfer(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReverseTransfer", reflect.TypeOf((*MockPlugin)(nil).ReverseTransfer), arg0, arg1) +} + +// TranslateWebhook mocks base method. +func (m *MockPlugin) TranslateWebhook(arg0 context.Context, arg1 TranslateWebhookRequest) (TranslateWebhookResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TranslateWebhook", arg0, arg1) + ret0, _ := ret[0].(TranslateWebhookResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TranslateWebhook indicates an expected call of TranslateWebhook. +func (mr *MockPluginMockRecorder) TranslateWebhook(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TranslateWebhook", reflect.TypeOf((*MockPlugin)(nil).TranslateWebhook), arg0, arg1) +} + +// Uninstall mocks base method. +func (m *MockPlugin) Uninstall(arg0 context.Context, arg1 UninstallRequest) (UninstallResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Uninstall", arg0, arg1) + ret0, _ := ret[0].(UninstallResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Uninstall indicates an expected call of Uninstall. +func (mr *MockPluginMockRecorder) Uninstall(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Uninstall", reflect.TypeOf((*MockPlugin)(nil).Uninstall), arg0, arg1) +} diff --git a/internal/models/pool_accounts.go b/internal/models/pool_accounts.go new file mode 100644 index 00000000..f9cbd12e --- /dev/null +++ b/internal/models/pool_accounts.go @@ -0,0 +1,43 @@ +package models + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type PoolAccounts struct { + PoolID uuid.UUID `json:"poolID"` + AccountID AccountID `json:"accountID"` +} + +func (p PoolAccounts) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + PoolID uuid.UUID `json:"poolID"` + AccountID string `json:"accountID"` + }{ + PoolID: p.PoolID, + AccountID: p.AccountID.String(), + }) +} + +func (p *PoolAccounts) UnmarshalJSON(data []byte) error { + var aux struct { + PoolID uuid.UUID `json:"poolID"` + AccountID string `json:"accountID"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + accountID, err := AccountIDFromString(aux.AccountID) + if err != nil { + return err + } + + p.PoolID = aux.PoolID + p.AccountID = accountID + + return nil +} diff --git a/internal/models/pools.go b/internal/models/pools.go index ab865b13..42103042 100644 --- a/internal/models/pools.go +++ b/internal/models/pools.go @@ -1,25 +1,38 @@ package models import ( + "encoding/base64" "time" + "github.com/gibson042/canonicaljson-go" "github.com/google/uuid" - "github.com/uptrace/bun" ) -type PoolAccounts struct { - bun.BaseModel `bun:"accounts.pool_accounts"` +type Pool struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` - PoolID uuid.UUID `bun:",pk,notnull"` - AccountID AccountID `bun:",pk,notnull"` + PoolAccounts []PoolAccounts `json:"poolAccounts"` } -type Pool struct { - bun.BaseModel `bun:"accounts.pools"` +func (p *Pool) IdempotencyKey() string { + relatedAccounts := make([]string, len(p.PoolAccounts)) + for i := range p.PoolAccounts { + relatedAccounts[i] = p.PoolAccounts[i].AccountID.String() + } + var ik = struct { + ID string + RelatedAccounts []string + }{ + ID: p.ID.String(), + RelatedAccounts: relatedAccounts, + } - ID uuid.UUID `bun:",pk,nullzero"` - Name string - CreatedAt time.Time + data, err := canonicaljson.Marshal(ik) + if err != nil { + panic(err) + } - PoolAccounts []*PoolAccounts `bun:"rel:has-many,join:id=pool_id"` + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) } diff --git a/internal/models/schedules.go b/internal/models/schedules.go new file mode 100644 index 00000000..a012e2d2 --- /dev/null +++ b/internal/models/schedules.go @@ -0,0 +1,47 @@ +package models + +import ( + "encoding/json" + "time" +) + +type Schedule struct { + ID string + ConnectorID ConnectorID + CreatedAt time.Time +} + +func (s Schedule) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + }{ + ID: s.ID, + ConnectorID: s.ConnectorID.String(), + CreatedAt: s.CreatedAt, + }) +} + +func (s *Schedule) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + CreatedAt time.Time `json:"createdAt"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + s.ID = aux.ID + s.ConnectorID = connectorID + s.CreatedAt = aux.CreatedAt + + return nil +} diff --git a/internal/models/state_id.go b/internal/models/state_id.go new file mode 100644 index 00000000..1e1f3945 --- /dev/null +++ b/internal/models/state_id.go @@ -0,0 +1,76 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type StateID struct { + Reference string + ConnectorID ConnectorID +} + +func (aid *StateID) String() string { + if aid == nil || aid.Reference == "" { + return "" + } + + data, err := canonicaljson.Marshal(aid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func StateIDFromString(value string) (*StateID, error) { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return nil, err + } + ret := StateID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return nil, err + } + + return &ret, nil +} + +func MustStateIDFromString(value string) StateID { + id, err := StateIDFromString(value) + if err != nil { + panic(err) + } + return *id +} + +func (aid StateID) Value() (driver.Value, error) { + return aid.String(), nil +} + +func (aid *StateID) Scan(value interface{}) error { + if value == nil { + return errors.New("account id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := StateIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse account id %s: %v", v, err) + } + + *aid = *id + return nil + } + } + + return fmt.Errorf("failed to scan account id: %v", value) +} diff --git a/internal/models/states.go b/internal/models/states.go new file mode 100644 index 00000000..9a598515 --- /dev/null +++ b/internal/models/states.go @@ -0,0 +1,51 @@ +package models + +import ( + "encoding/json" +) + +type State struct { + ID StateID + ConnectorID ConnectorID + State json.RawMessage +} + +func (s State) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + State json.RawMessage `json:"state"` + }{ + ID: s.ID.String(), + ConnectorID: s.ConnectorID.String(), + State: s.State, + }) +} + +func (s *State) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + State json.RawMessage `json:"state"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := StateIDFromString(aux.ID) + if err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + s.ID = *id + s.ConnectorID = connectorID + s.State = aux.State + + return nil +} diff --git a/internal/models/task.go b/internal/models/task.go deleted file mode 100644 index 566c1630..00000000 --- a/internal/models/task.go +++ /dev/null @@ -1,124 +0,0 @@ -package models - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "time" - - "github.com/uptrace/bun" - - "github.com/google/uuid" -) - -type TaskID uuid.UUID - -func (id TaskID) String() string { - return uuid.UUID(id).String() -} - -type ScheduleOption int - -const ( - OPTIONS_RUN_NOW ScheduleOption = iota - OPTIONS_RUN_IN_DURATION - OPTIONS_RUN_PERIODICALLY - OPTIONS_RUN_NOW_SYNC - OPTIONS_RUN_SCHEDULED_AT -) - -type RestartOption int - -const ( - OPTIONS_RESTART_NEVER RestartOption = iota - OPTIONS_RESTART_ALWAYS - OPTIONS_RESTART_IF_NOT_ACTIVE - OPTIONS_STOP_AND_RESTART -) - -type Task struct { - bun.BaseModel `bun:"tasks.task"` - - ID uuid.UUID `bun:",pk,nullzero"` - ConnectorID ConnectorID - CreatedAt time.Time `bun:",nullzero"` - UpdatedAt time.Time `bun:",nullzero"` - Name string - Descriptor json.RawMessage - SchedulerOptions TaskSchedulerOptions - Status TaskStatus - Error string - State json.RawMessage - - Connector *Connector `bun:"rel:belongs-to,join:connector_id=id"` -} - -func (t Task) GetDescriptor() TaskDescriptor { - return TaskDescriptor(t.Descriptor) -} - -type TaskSchedulerOptions struct { - ScheduleOption ScheduleOption - Duration time.Duration - ScheduleAt time.Time - - // TODO(polo): Deprecated, will be removed in the next release, use - // RestartOption instead. - // We have to keep it for now for db compatibility. - Restart bool - RestartOption RestartOption -} - -type TaskDescriptor json.RawMessage - -func (td TaskDescriptor) ToMessage() json.RawMessage { - return json.RawMessage(td) -} - -func (td TaskDescriptor) EncodeToString() (string, error) { - data, err := json.Marshal(td) - if err != nil { - return "", fmt.Errorf("failed to encode task descriptor: %w", err) - } - - return base64.StdEncoding.EncodeToString(data), nil -} - -func EncodeTaskDescriptor(descriptor any) (TaskDescriptor, error) { - res, err := json.Marshal(descriptor) - if err != nil { - return nil, fmt.Errorf("failed to encode task descriptor: %w", err) - } - - return res, nil -} - -func DecodeTaskDescriptor[descriptor any](data TaskDescriptor) (descriptor, error) { - var res descriptor - - err := json.Unmarshal(data, &res) - if err != nil { - return res, fmt.Errorf("failed to decode task descriptor: %w", err) - } - - return res, nil -} - -type TaskStatus string - -const ( - TaskStatusStopped TaskStatus = "STOPPED" - TaskStatusPending TaskStatus = "PENDING" - TaskStatusActive TaskStatus = "ACTIVE" - TaskStatusTerminated TaskStatus = "TERMINATED" - TaskStatusFailed TaskStatus = "FAILED" -) - -func (t Task) ParseDescriptor(to interface{}) error { - err := json.Unmarshal(t.Descriptor, to) - if err != nil { - return fmt.Errorf("failed to parse descriptor: %w", err) - } - - return nil -} diff --git a/internal/models/task_id.go b/internal/models/task_id.go new file mode 100644 index 00000000..12c13c26 --- /dev/null +++ b/internal/models/task_id.go @@ -0,0 +1,80 @@ +package models + +import ( + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + + "github.com/gibson042/canonicaljson-go" +) + +type TaskID struct { + Reference string + ConnectorID ConnectorID +} + +func TaskIDReference(prefix string, connectorID ConnectorID, objectID string) string { + return fmt.Sprintf("%s-%s-%s", prefix, connectorID.String(), objectID) +} + +func (aid *TaskID) String() string { + if aid == nil || aid.Reference == "" { + return "" + } + + data, err := canonicaljson.Marshal(aid) + if err != nil { + panic(err) + } + + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) +} + +func TaskIDFromString(value string) (*TaskID, error) { + data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) + if err != nil { + return nil, err + } + ret := TaskID{} + err = canonicaljson.Unmarshal(data, &ret) + if err != nil { + return nil, err + } + + return &ret, nil +} + +func MustTaskIDFromString(value string) TaskID { + id, err := TaskIDFromString(value) + if err != nil { + panic(err) + } + return *id +} + +func (aid TaskID) Value() (driver.Value, error) { + return aid.String(), nil +} + +func (aid *TaskID) Scan(value interface{}) error { + if value == nil { + return errors.New("task id is nil") + } + + if s, err := driver.String.ConvertValue(value); err == nil { + + if v, ok := s.(string); ok { + + id, err := TaskIDFromString(v) + if err != nil { + return fmt.Errorf("failed to parse task id %s: %v", v, err) + } + + *aid = *id + return nil + } + } + + return fmt.Errorf("failed to scan task id: %v", value) +} diff --git a/internal/models/tasks.go b/internal/models/tasks.go new file mode 100644 index 00000000..452e43a4 --- /dev/null +++ b/internal/models/tasks.go @@ -0,0 +1,101 @@ +package models + +import ( + "encoding/json" + "errors" + "time" + + "github.com/formancehq/go-libs/v2/pointer" +) + +type TaskStatus string + +const ( + TASK_STATUS_PROCESSING TaskStatus = "PROCESSING" + TASK_STATUS_SUCCEEDED TaskStatus = "SUCCEEDED" + TASK_STATUS_FAILED TaskStatus = "FAILED" +) + +type Task struct { + // Unique identifier of the task + ID TaskID `json:"id"` + // Related Connector ID + ConnectorID ConnectorID `json:"connectorID"` + // Status of the task + Status TaskStatus `json:"status"` + // Time when the task was created + CreatedAt time.Time `json:"createdAt"` + // Time when the task was last updated + UpdatedAt time.Time `json:"updatedAt"` + + CreatedObjectID *string `json:"createdObjectID,omitempty"` + Error error `json:"error,omitempty"` +} + +func (t Task) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Status TaskStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CreatedObjectID *string `json:"createdObjectID,omitempty"` + Error *string `json:"error,omitempty"` + }{ + ID: t.ID.String(), + ConnectorID: t.ConnectorID.String(), + Status: t.Status, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + CreatedObjectID: t.CreatedObjectID, + Error: func() *string { + if t.Error == nil { + return nil + } + + return pointer.For(t.Error.Error()) + }(), + }) +} + +func (t *Task) UnmarshalJSON(data []byte) error { + var aux struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Status TaskStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CreatedObjectID *string `json:"createdObjectID,omitempty"` + Error *string `json:"error,omitempty"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := TaskIDFromString(aux.ID) + if err != nil { + return err + } + + connectorID, err := ConnectorIDFromString(aux.ConnectorID) + if err != nil { + return err + } + + t.ID = *id + t.ConnectorID = connectorID + t.Status = aux.Status + t.CreatedAt = aux.CreatedAt + t.UpdatedAt = aux.UpdatedAt + t.CreatedObjectID = aux.CreatedObjectID + t.Error = func() error { + if aux.Error == nil { + return nil + } + + return errors.New(*aux.Error) + }() + + return nil +} diff --git a/internal/models/transfer.go b/internal/models/transfer.go deleted file mode 100644 index cc79f475..00000000 --- a/internal/models/transfer.go +++ /dev/null @@ -1,114 +0,0 @@ -package models - -import "errors" - -type TransferInitiationStatus int - -const ( - TransferInitiationStatusWaitingForValidation TransferInitiationStatus = iota - TransferInitiationStatusProcessing - TransferInitiationStatusProcessed - TransferInitiationStatusFailed - TransferInitiationStatusRejected - TransferInitiationStatusValidated - TransferInitiationStatusAskRetried - TransferInitiationStatusAskReversed - TransferInitiationStatusReverseProcessing - TransferInitiationStatusReverseFailed - TransferInitiationStatusPartiallyReversed - TransferInitiationStatusReversed -) - -func (s TransferInitiationStatus) String() string { - return [...]string{ - "WAITING_FOR_VALIDATION", - "PROCESSING", - "PROCESSED", - "FAILED", - "REJECTED", - "VALIDATED", - "ASK_RETRIED", - "ASK_REVERSED", - "REVERSE_PROCESSING", - "REVERSE_FAILED", - "PARTIALLY_REVERSED", - "REVERSED", - }[s] -} - -func TransferInitiationStatusFromString(s string) (TransferInitiationStatus, error) { - switch s { - case "WAITING_FOR_VALIDATION": - return TransferInitiationStatusWaitingForValidation, nil - case "PROCESSING": - return TransferInitiationStatusProcessing, nil - case "PROCESSED": - return TransferInitiationStatusProcessed, nil - case "FAILED": - return TransferInitiationStatusFailed, nil - case "REJECTED": - return TransferInitiationStatusRejected, nil - case "VALIDATED": - return TransferInitiationStatusValidated, nil - case "ASK_RETRIED": - return TransferInitiationStatusAskRetried, nil - case "ASK_REVERSED": - return TransferInitiationStatusAskReversed, nil - case "REVERSE_PROCESSING": - return TransferInitiationStatusReverseProcessing, nil - case "REVERSE_FAILED": - return TransferInitiationStatusReverseFailed, nil - case "PARTIALLY_REVERSED": - return TransferInitiationStatusPartiallyReversed, nil - case "REVERSED": - return TransferInitiationStatusReversed, nil - default: - return TransferInitiationStatusWaitingForValidation, errors.New("invalid status") - } -} - -type TransferReversalStatus int - -const ( - TransferReversalStatusProcessing TransferReversalStatus = iota - TransferReversalStatusProcessed - TransferReversalStatusFailed -) - -func (s TransferReversalStatus) String() string { - return [...]string{ - "CREATED", - "PROCESSING", - "PROCESSED", - "FAILED", - }[s] -} - -func TransferReversalStatusFromString(s string) (TransferReversalStatus, error) { - switch s { - case "PROCESSING": - return TransferReversalStatusProcessing, nil - case "PROCESSED": - return TransferReversalStatusProcessed, nil - case "FAILED": - return TransferReversalStatusFailed, nil - default: - return TransferReversalStatusProcessing, errors.New("invalid status") - } -} - -func (s TransferReversalStatus) ToTransferInitiationStatus(isFullyReversed bool) TransferInitiationStatus { - switch s { - case TransferReversalStatusProcessing: - return TransferInitiationStatusReverseProcessing - case TransferReversalStatusProcessed: - if isFullyReversed { - return TransferInitiationStatusReversed - } - return TransferInitiationStatusPartiallyReversed - case TransferReversalStatusFailed: - return TransferInitiationStatusReverseFailed - default: - return TransferInitiationStatusProcessed - } -} diff --git a/internal/models/transfer_initiation.go b/internal/models/transfer_initiation.go deleted file mode 100644 index 71860aba..00000000 --- a/internal/models/transfer_initiation.go +++ /dev/null @@ -1,180 +0,0 @@ -package models - -import ( - "database/sql/driver" - "encoding/base64" - "errors" - "fmt" - "math/big" - "sort" - "time" - - "github.com/gibson042/canonicaljson-go" - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -type TransferInitiationID struct { - Reference string - ConnectorID ConnectorID -} - -func (tid TransferInitiationID) String() string { - data, err := canonicaljson.Marshal(tid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) -} - -func TransferInitiationIDFromString(value string) (TransferInitiationID, error) { - data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) - if err != nil { - return TransferInitiationID{}, err - } - ret := TransferInitiationID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return TransferInitiationID{}, err - } - - return ret, nil -} - -func MustTransferInitiationIDFromString(value string) TransferInitiationID { - id, err := TransferInitiationIDFromString(value) - if err != nil { - panic(err) - } - return id -} - -func (tid TransferInitiationID) Value() (driver.Value, error) { - return tid.String(), nil -} - -func (tid *TransferInitiationID) Scan(value interface{}) error { - if value == nil { - return errors.New("payment id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := TransferInitiationIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse paymentid %s: %v", v, err) - } - - *tid = id - return nil - } - } - - return fmt.Errorf("failed to scan paymentid: %v", value) -} - -type TransferInitiationType int - -const ( - TransferInitiationTypeTransfer TransferInitiationType = iota - TransferInitiationTypePayout -) - -func (t TransferInitiationType) String() string { - return [...]string{ - "TRANSFER", - "PAYOUT", - }[t] -} - -func TransferInitiationTypeFromString(s string) (TransferInitiationType, error) { - switch s { - case "TRANSFER": - return TransferInitiationTypeTransfer, nil - case "PAYOUT": - return TransferInitiationTypePayout, nil - default: - return TransferInitiationTypeTransfer, errors.New("invalid type") - } -} - -func MustTransferInitiationTypeFromString(s string) TransferInitiationType { - t, err := TransferInitiationTypeFromString(s) - if err != nil { - panic(err) - } - return t -} - -type TransferInitiation struct { - bun.BaseModel `bun:"transfers.transfer_initiation"` - - // Filled when created in DB - ID TransferInitiationID `bun:",pk,nullzero"` - - CreatedAt time.Time `bun:",nullzero"` - ScheduledAt time.Time `bun:",nullzero"` - Description string - - Type TransferInitiationType - - SourceAccountID *AccountID - DestinationAccountID AccountID - Provider ConnectorProvider - ConnectorID ConnectorID - - Amount *big.Int `bun:"type:numeric"` - InitialAmount *big.Int `bun:"type:numeric"` - Asset Asset - - Metadata map[string]string - - SourceAccount *Account `bun:"-"` - DestinationAccount *Account `bun:"-"` - - RelatedAdjustments []*TransferInitiationAdjustment `bun:"rel:has-many,join:id=transfer_initiation_id"` - RelatedPayments []*TransferInitiationPayment `bun:"-"` -} - -func (t *TransferInitiation) SortRelatedAdjustments() { - // Sort adjustments by created_at - sort.Slice(t.RelatedAdjustments, func(i, j int) bool { - return t.RelatedAdjustments[i].CreatedAt.After(t.RelatedAdjustments[j].CreatedAt) - }) -} - -func (t *TransferInitiation) CountRetries() int { - res := 0 - for _, adjustment := range t.RelatedAdjustments { - if adjustment.Status == TransferInitiationStatusAskRetried { - res++ - } - } - - return res -} - -type TransferInitiationPayment struct { - bun.BaseModel `bun:"transfers.transfer_initiation_payments"` - - TransferInitiationID TransferInitiationID `bun:",pk"` - PaymentID PaymentID `bun:",pk"` - - CreatedAt time.Time `bun:",nullzero"` - Status TransferInitiationStatus - Error string -} - -type TransferInitiationAdjustment struct { - bun.BaseModel `bun:"transfers.transfer_initiation_adjustments"` - - ID uuid.UUID `bun:",pk"` - TransferInitiationID TransferInitiationID - CreatedAt time.Time `bun:",nullzero"` - Status TransferInitiationStatus - Error string - Metadata map[string]string -} diff --git a/internal/models/transfer_reversal.go b/internal/models/transfer_reversal.go deleted file mode 100644 index 357c8da4..00000000 --- a/internal/models/transfer_reversal.go +++ /dev/null @@ -1,96 +0,0 @@ -package models - -import ( - "database/sql/driver" - "encoding/base64" - "errors" - "fmt" - "math/big" - "time" - - "github.com/gibson042/canonicaljson-go" - "github.com/uptrace/bun" -) - -type TransferReversalID struct { - Reference string - ConnectorID ConnectorID -} - -func (tid TransferReversalID) String() string { - data, err := canonicaljson.Marshal(tid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) -} - -func TransferReversalIDFromString(value string) (TransferReversalID, error) { - data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value) - if err != nil { - return TransferReversalID{}, err - } - ret := TransferReversalID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return TransferReversalID{}, err - } - - return ret, nil -} - -func MustTransferReversalIDFromString(value string) TransferReversalID { - id, err := TransferReversalIDFromString(value) - if err != nil { - panic(err) - } - return id -} - -func (tid TransferReversalID) Value() (driver.Value, error) { - return tid.String(), nil -} - -func (tid *TransferReversalID) Scan(value interface{}) error { - if value == nil { - return errors.New("payment id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := TransferReversalIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse paymentid %s: %v", v, err) - } - - *tid = id - return nil - } - } - - return fmt.Errorf("failed to scan paymentid: %v", value) -} - -type TransferReversal struct { - bun.BaseModel `bun:"transfers.transfer_reversal"` - - ID TransferReversalID `bun:",pk"` - TransferInitiationID TransferInitiationID - - CreatedAt time.Time - UpdatedAt time.Time - Description string - - ConnectorID ConnectorID - - Amount *big.Int - Asset Asset - - Status TransferReversalStatus - Error string - - Metadata map[string]string -} diff --git a/internal/models/webhook.go b/internal/models/webhook.go deleted file mode 100644 index f1a55a02..00000000 --- a/internal/models/webhook.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "github.com/google/uuid" - "github.com/uptrace/bun" -) - -type Webhook struct { - bun.BaseModel `bun:"connectors.webhook"` - - ID uuid.UUID - ConnectorID ConnectorID - RequestBody []byte -} diff --git a/internal/models/webhooks.go b/internal/models/webhooks.go new file mode 100644 index 00000000..10f73190 --- /dev/null +++ b/internal/models/webhooks.go @@ -0,0 +1,34 @@ +package models + +type PSPWebhookConfig struct { + Name string `json:"name"` + URLPath string `json:"urlPath"` +} + +type WebhookConfig struct { + Name string `json:"name"` + ConnectorID ConnectorID `json:"connectorID"` + URLPath string `json:"urlPath"` +} + +type BasicAuth struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type PSPWebhook struct { + BasicAuth *BasicAuth `json:"basicAuth"` + + QueryValues map[string][]string `json:"queryValues"` + Headers map[string][]string `json:"headers"` + Body []byte `json:"payload"` +} + +type Webhook struct { + ID string `json:"id"` + ConnectorID ConnectorID `json:"connectorID"` + BasicAuth *BasicAuth `json:"basicAuth"` + QueryValues map[string][]string `json:"queryValues"` + Headers map[string][]string `json:"headers"` + Body []byte `json:"payload"` +} diff --git a/internal/models/workflow_instances.go b/internal/models/workflow_instances.go new file mode 100644 index 00000000..b73db97a --- /dev/null +++ b/internal/models/workflow_instances.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" +) + +type Instance struct { + ID string + ScheduleID string + ConnectorID ConnectorID + CreatedAt time.Time + UpdatedAt time.Time + Terminated bool + TerminatedAt *time.Time + Error *string +} diff --git a/internal/otel/otel.go b/internal/otel/otel.go new file mode 100644 index 00000000..8f0729be --- /dev/null +++ b/internal/otel/otel.go @@ -0,0 +1,48 @@ +package otel + +import ( + "context" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var ( + once sync.Once + tracer trace.Tracer +) + +func Tracer() trace.Tracer { + once.Do(func() { + tracer = otel.Tracer("com.formance.payments") + }) + + return tracer +} + +func RecordError(span trace.Span, err error) { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) +} + +func StartSpan( + ctx context.Context, + spanName string, + attributes ...attribute.KeyValue, +) (context.Context, trace.Span) { + parentSpan := trace.SpanFromContext(ctx) + return Tracer().Start( + ctx, + spanName, + trace.WithNewRoot(), + trace.WithLinks(trace.Link{ + SpanContext: parentSpan.SpanContext(), + }), + trace.WithAttributes( + attributes..., + ), + ) +} diff --git a/internal/otel/tracer.go b/internal/otel/tracer.go deleted file mode 100644 index 84ab67da..00000000 --- a/internal/otel/tracer.go +++ /dev/null @@ -1,27 +0,0 @@ -package otel - -import ( - "sync" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" -) - -var ( - once sync.Once - tracer trace.Tracer -) - -func Tracer() trace.Tracer { - once.Do(func() { - tracer = otel.Tracer("com.formance.payments") - }) - - return tracer -} - -func RecordError(span trace.Span, err error) { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) -} diff --git a/internal/storage/accounts.go b/internal/storage/accounts.go new file mode 100644 index 00000000..f0f8d3bc --- /dev/null +++ b/internal/storage/accounts.go @@ -0,0 +1,186 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type account struct { + bun.BaseModel `bun:"table:accounts"` + + // Mandatory fields + ID models.AccountID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Reference string `bun:"reference,type:text,notnull"` + Type string `bun:"type,type:text,notnull"` + Raw json.RawMessage `bun:"raw,type:json,notnull"` + + // Optional fields + // c.f.: https://bun.uptrace.dev/guide/models.html#nulls + DefaultAsset *string `bun:"default_asset,type:text,nullzero"` + Name *string `bun:"name,type:text,nullzero"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +func (s *store) AccountsUpsert(ctx context.Context, accounts []models.Account) error { + if len(accounts) == 0 { + return nil + } + + toInsert := make([]account, 0, len(accounts)) + for _, a := range accounts { + toInsert = append(toInsert, fromAccountModels(a)) + } + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + + return e("failed to insert accounts", err) +} + +func (s *store) AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) { + var account account + + err := s.db.NewSelect(). + Model(&account). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to get account", err) + } + + res := toAccountModels(account) + return &res, nil +} + +func (s *store) AccountsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*account)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete account", err) +} + +type AccountQuery struct{} + +type ListAccountsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[AccountQuery]] + +func NewListAccountsQuery(opts bunpaginate.PaginatedQueryOptions[AccountQuery]) ListAccountsQuery { + return ListAccountsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) accountsQueryContext(qb query.Builder) (string, []any, error) { + return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "reference", + key == "connector_id", + key == "type", + key == "default_asset", + key == "name": + return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil + case metadataRegex.Match([]byte(key)): + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + match := metadataRegex.FindAllStringSubmatch(key, 3) + + key := "metadata" + return key + " @> ?", []any{map[string]any{ + match[0][1]: value, + }}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) +} + +func (s *store) AccountsList(ctx context.Context, q ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.accountsQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[AccountQuery], account](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[AccountQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch accounts", err) + } + + accounts := make([]models.Account, 0, len(cursor.Data)) + for _, a := range cursor.Data { + accounts = append(accounts, toAccountModels(a)) + } + + return &bunpaginate.Cursor[models.Account]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: accounts, + }, nil +} + +func fromAccountModels(from models.Account) account { + return account{ + ID: from.ID, + ConnectorID: from.ConnectorID, + CreatedAt: time.New(from.CreatedAt), + Reference: from.Reference, + Type: string(from.Type), + DefaultAsset: from.DefaultAsset, + Name: from.Name, + Metadata: from.Metadata, + Raw: from.Raw, + } +} + +func toAccountModels(from account) models.Account { + return models.Account{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Reference: from.Reference, + CreatedAt: from.CreatedAt.Time, + Type: models.AccountType(from.Type), + Name: from.Name, + DefaultAsset: from.DefaultAsset, + Metadata: from.Metadata, + Raw: from.Raw, + } +} diff --git a/internal/storage/accounts_test.go b/internal/storage/accounts_test.go new file mode 100644 index 00000000..0420cd21 --- /dev/null +++ b/internal/storage/accounts_test.go @@ -0,0 +1,602 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func defaultAccounts() []models.Account { + return []models.Account{ + { + ID: models.AccountID{ + Reference: "test1", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Type: models.ACCOUNT_TYPE_INTERNAL, + Name: pointer.For("test1"), + DefaultAsset: pointer.For("USD/2"), + Metadata: map[string]string{ + "foo": "bar", + }, + Raw: []byte(`{}`), + }, + { + ID: models.AccountID{ + Reference: "test2", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Type: models.ACCOUNT_TYPE_INTERNAL, + Metadata: map[string]string{ + "foo2": "bar2", + }, + Raw: []byte(`{}`), + }, + { + ID: models.AccountID{ + Reference: "test3", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + Reference: "test3", + CreatedAt: now.Add(-45 * time.Minute).UTC().Time, + Type: models.ACCOUNT_TYPE_EXTERNAL, + Name: pointer.For("test3"), + Metadata: map[string]string{ + "foo3": "bar3", + }, + Raw: []byte(`{}`), + }, + } +} + +func defaultAccounts2() []models.Account { + return []models.Account{ + { + ID: models.AccountID{ + Reference: "test1", + ConnectorID: defaultConnector2.ID, + }, + ConnectorID: defaultConnector2.ID, + Reference: "test1", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Type: models.ACCOUNT_TYPE_INTERNAL, + Name: pointer.For("test1"), + DefaultAsset: pointer.For("USD/2"), + Metadata: map[string]string{ + "foo5": "bar5", + }, + Raw: []byte(`{}`), + }, + } +} + +func defaultAccounts3() []models.Account { + createdAt := time.Now().UTC().Truncate(time.Minute).Add(-2 * time.Minute).UTC() + return []models.Account{ + { + ID: models.AccountID{ + Reference: "sort-test", + ConnectorID: defaultConnector2.ID, + }, + ConnectorID: defaultConnector2.ID, + Reference: "sort-test", + CreatedAt: createdAt, + Type: models.ACCOUNT_TYPE_INTERNAL, + Name: pointer.For("sort-test"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "unrelated": "keyval", + }, + Raw: []byte(`{}`), + }, + { + ID: models.AccountID{ + Reference: "sort-test2", + ConnectorID: defaultConnector2.ID, + }, + ConnectorID: defaultConnector2.ID, + Reference: "sort-test2", + CreatedAt: createdAt, + Type: models.ACCOUNT_TYPE_INTERNAL, + Name: pointer.For("sort-test2"), + DefaultAsset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "metadata": "keyval", + }, + Raw: []byte(`{}`), + }, + } +} + +func upsertAccounts(t *testing.T, ctx context.Context, storage Storage, accounts []models.Account) { + require.NoError(t, storage.AccountsUpsert(ctx, accounts)) +} + +func TestAccountsUpsert(t *testing.T) { + t.Parallel() + + now := time.Now() + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + + t.Run("same id insert", func(t *testing.T) { + id := models.AccountID{ + Reference: "test1", + ConnectorID: defaultConnector.ID, + } + + // Same account I but different fields + acc := models.Account{ + ID: id, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-12 * time.Minute).UTC().Time, + Type: models.ACCOUNT_TYPE_EXTERNAL, + Name: pointer.For("changed"), + DefaultAsset: pointer.For("EUR"), + Metadata: map[string]string{ + "foo4": "bar4", + }, + Raw: []byte(`{}`), + } + + require.NoError(t, store.AccountsUpsert(ctx, []models.Account{acc})) + + // Check that account was not updated + account, err := store.AccountsGet(ctx, id) + require.NoError(t, err) + + // Accounts should not have changed + require.Equal(t, defaultAccounts()[0], *account) + }) + + t.Run("unknown connector id", func(t *testing.T) { + unknownConnectorID := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + acc := models.Account{ + ID: models.AccountID{ + Reference: "test_unknown", + ConnectorID: unknownConnectorID, + }, + ConnectorID: unknownConnectorID, + Reference: "test_unknown", + CreatedAt: now.Add(-12 * time.Minute).UTC().Time, + Type: models.ACCOUNT_TYPE_EXTERNAL, + Name: pointer.For("changed"), + DefaultAsset: pointer.For("EUR"), + Metadata: map[string]string{ + "foo4": "bar4", + }, + Raw: []byte(`{}`), + } + + require.Error(t, store.AccountsUpsert(ctx, []models.Account{acc})) + }) +} + +func TestAccountsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + + t.Run("get account", func(t *testing.T) { + for _, acc := range defaultAccounts() { + account, err := store.AccountsGet(ctx, acc.ID) + require.NoError(t, err) + require.Equal(t, acc, *account) + } + }) + + t.Run("get unknown account", func(t *testing.T) { + acc := models.AccountID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + } + + account, err := store.AccountsGet(ctx, acc) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, account) + }) +} + +func TestAccountsDelete(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertAccounts(t, ctx, store, defaultAccounts2()) + + t.Run("delete account from unknown connector", func(t *testing.T) { + unknownConnectorID := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + require.NoError(t, store.AccountsDeleteFromConnectorID(ctx, unknownConnectorID)) + + for _, acc := range defaultAccounts() { + account, err := store.AccountsGet(ctx, acc.ID) + require.NoError(t, err) + require.Equal(t, acc, *account) + } + + for _, acc := range defaultAccounts2() { + account, err := store.AccountsGet(ctx, acc.ID) + require.NoError(t, err) + require.Equal(t, acc, *account) + } + }) + + t.Run("delete account from default connector", func(t *testing.T) { + require.NoError(t, store.AccountsDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, acc := range defaultAccounts() { + account, err := store.AccountsGet(ctx, acc.ID) + require.Error(t, err) + require.Nil(t, account) + require.ErrorIs(t, err, ErrNotFound) + } + + for _, acc := range defaultAccounts2() { + account, err := store.AccountsGet(ctx, acc.ID) + require.NoError(t, err) + require.Equal(t, acc, *account) + } + }) + +} + +func TestAccountsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertAccounts(t, ctx, store, defaultAccounts2()) + upsertAccounts(t, ctx, store, defaultAccounts3()) + + t.Run("list accounts by reference", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "test1")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Equal(t, defaultAccounts2()[0], cursor.Data[0]) + require.Equal(t, defaultAccounts()[0], cursor.Data[1]) + }) + + t.Run("list accounts by reference 2", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "test2")), + ) + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Equal(t, defaultAccounts()[1], cursor.Data[0]) + }) + + t.Run("list accounts by unknown reference", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "unknown")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list accounts by connector id", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector.ID)), + ) + accounts := defaultAccounts() + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + require.Equal(t, accounts[1], cursor.Data[0]) + require.Equal(t, accounts[2], cursor.Data[1]) + require.Equal(t, accounts[0], cursor.Data[2]) + }) + + t.Run("list accounts by connector id 2", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector2.ID)), + ) + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + require.Equal(t, defaultAccounts3()[1], cursor.Data[0]) + require.Equal(t, defaultAccounts3()[0], cursor.Data[1]) + require.Equal(t, defaultAccounts2()[0], cursor.Data[2]) + }) + + t.Run("list accounts by unknown connector id", func(t *testing.T) { + unknownConnectorID := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", unknownConnectorID)), + ) + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list accounts by type", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", models.ACCOUNT_TYPE_INTERNAL)), + ) + accounts := defaultAccounts() + accounts2 := defaultAccounts2() + accounts3 := defaultAccounts3() + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 5) + require.False(t, cursor.HasMore) + require.Equal(t, accounts3[1], cursor.Data[0]) + require.Equal(t, accounts3[0], cursor.Data[1]) + require.Equal(t, accounts[1], cursor.Data[2]) + require.Equal(t, accounts2[0], cursor.Data[3]) + require.Equal(t, accounts[0], cursor.Data[4]) + }) + + t.Run("list accounts by unknown type", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", "unknown")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list accounts by default asset", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("default_asset", "USD/2")), + ) + accounts := defaultAccounts() + accounts2 := defaultAccounts2() + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Equal(t, accounts2[0], cursor.Data[0]) + require.Equal(t, accounts[0], cursor.Data[1]) + }) + + t.Run("list accounts by unknown default asset", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("default_asset", "unknown")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list accounts by name", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "test1")), + ) + + accounts := defaultAccounts() + accounts2 := defaultAccounts2() + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Equal(t, accounts2[0], cursor.Data[0]) + require.Equal(t, accounts[0], cursor.Data[1]) + }) + + t.Run("list accounts by name 2", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "test3")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Equal(t, defaultAccounts()[2], cursor.Data[0]) + }) + + t.Run("list accounts by unknown name", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "unknown")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list accounts by metadata", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "bar")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Equal(t, defaultAccounts()[0], cursor.Data[0]) + }) + + t.Run("list accounts by unknown metadata", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "unknown")), + ) + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list accounts test cursor", func(t *testing.T) { + q := NewListAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(AccountQuery{}). + WithPageSize(1), + ) + accounts := defaultAccounts() + accounts2 := defaultAccounts2() + accounts3 := defaultAccounts3() + + cursor, err := store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.Empty(t, cursor.Previous) + require.Equal(t, accounts3[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts3[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts2[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts2[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.AccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, accounts[2], cursor.Data[0]) + }) +} diff --git a/internal/storage/balances.go b/internal/storage/balances.go new file mode 100644 index 00000000..2fe8d2dd --- /dev/null +++ b/internal/storage/balances.go @@ -0,0 +1,316 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + internalTime "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type balance struct { + bun.BaseModel `bun:"table:balances"` + + // Mandatory fields + AccountID models.AccountID `bun:"account_id,pk,type:character varying,notnull"` + CreatedAt internalTime.Time `bun:"created_at,pk,type:timestamp without time zone,notnull"` + Asset string `bun:"asset,pk,type:text,notnull"` + + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + Balance *big.Int `bun:"balance,type:numeric,notnull"` + LastUpdatedAt internalTime.Time `bun:"last_updated_at,type:timestamp without time zone,notnull"` +} + +func (s *store) BalancesUpsert(ctx context.Context, balances []models.Balance) error { + toInsert := fromBalancesModels(balances) + if len(balances) == 0 { + return nil + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return err + } + defer func() { + // There is an error sent if the transaction is already committed + _ = tx.Rollback() + }() + + for _, balance := range toInsert { + if err := s.insertBalances(ctx, tx, &balance); err != nil { + return err + } + } + + return e("failed to commit transaction", tx.Commit()) +} + +func (s *store) insertBalances(ctx context.Context, tx bun.Tx, balance *balance) error { + var lastBalance models.Balance + found := true + err := tx.NewSelect(). + Model(&lastBalance). + Where("account_id = ? AND asset = ?", balance.AccountID, balance.Asset). + Order("created_at DESC", "sort_id DESC"). + Limit(1). + Scan(ctx) + if err != nil { + pErr := e("failed to get account", err) + if !errors.Is(pErr, ErrNotFound) { + return pErr + } + found = false + } + + if found && lastBalance.CreatedAt.After(balance.CreatedAt.Time) { + // Do not insert balance if the last balance is newer + return nil + } + + switch { + case found && lastBalance.Balance.Cmp(balance.Balance) == 0: + // same balance, no need to have a new entry, just update the last one + _, err = tx.NewUpdate(). + Model((*models.Balance)(nil)). + Set("last_updated_at = ?", balance.LastUpdatedAt). + Where("account_id = ? AND created_at = ? AND asset = ?", lastBalance.AccountID, lastBalance.CreatedAt, lastBalance.Asset). + Exec(ctx) + if err != nil { + return e("failed to update balance", err) + } + + case found && lastBalance.Balance.Cmp(balance.Balance) != 0: + // different balance, insert a new entry + _, err = tx.NewInsert(). + Model(balance). + Exec(ctx) + if err != nil { + return e("failed to insert balance", err) + } + + // and update last row last updated at to this created at + _, err = tx.NewUpdate(). + Model(&lastBalance). + Set("last_updated_at = ?", balance.CreatedAt). + Where("account_id = ? AND created_at = ? AND asset = ?", lastBalance.AccountID, lastBalance.CreatedAt, lastBalance.Asset). + Exec(ctx) + if err != nil { + return e("failed to update balance", err) + } + + case !found: + // no balance found, insert a new entry + _, err = tx.NewInsert(). + Model(balance). + Exec(ctx) + if err != nil { + return e("failed to insert balance", err) + } + } + + return nil +} + +func (s *store) BalancesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*balance)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + if err != nil { + return e("delete balances", err) + } + + return nil +} + +type BalanceQuery struct { + AccountID *models.AccountID + Asset string + From time.Time + To time.Time +} + +func NewBalanceQuery() BalanceQuery { + return BalanceQuery{} +} + +func (b BalanceQuery) WithAccountID(accountID *models.AccountID) BalanceQuery { + b.AccountID = accountID + + return b +} + +func (b BalanceQuery) WithAsset(asset string) BalanceQuery { + b.Asset = asset + + return b +} + +func (b BalanceQuery) WithFrom(from time.Time) BalanceQuery { + b.From = from + + return b +} + +func (b BalanceQuery) WithTo(to time.Time) BalanceQuery { + b.To = to + + return b +} + +func applyBalanceQuery(query *bun.SelectQuery, balanceQuery BalanceQuery) *bun.SelectQuery { + if balanceQuery.AccountID != nil { + query = query.Where("balance.account_id = ?", balanceQuery.AccountID) + } + + if balanceQuery.Asset != "" { + query = query.Where("balance.asset = ?", balanceQuery.Asset) + } + + if !balanceQuery.From.IsZero() { + query = query.Where("balance.last_updated_at >= ?", balanceQuery.From) + } + + if !balanceQuery.To.IsZero() { + query = query.Where("(balance.created_at <= ?)", balanceQuery.To) + } + + return query +} + +type ListBalancesQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[BalanceQuery]] + +func NewListBalancesQuery(opts bunpaginate.PaginatedQueryOptions[BalanceQuery]) ListBalancesQuery { + return ListBalancesQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) BalancesList(ctx context.Context, q ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[BalanceQuery], balance](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[BalanceQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + + query = applyBalanceQuery(query, q.Options.Options) + + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch balances", err) + } + + balances := toBalancesModels(cursor.Data) + + return &bunpaginate.Cursor[models.Balance]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: balances, + }, nil +} + +func (s *store) balancesListAssets(ctx context.Context, accountID models.AccountID) ([]string, error) { + var assets []string + + err := s.db.NewSelect(). + ColumnExpr("DISTINCT asset"). + Model(&models.Balance{}). + Where("account_id = ?", accountID). + Scan(ctx, &assets) + if err != nil { + return nil, e("failed to list balance assets", err) + } + + return assets, nil +} + +func (s *store) balancesGetAtByAsset(ctx context.Context, accountID models.AccountID, asset string, at time.Time) (*models.Balance, error) { + var balance balance + + err := s.db.NewSelect(). + Model(&balance). + Where("account_id = ?", accountID). + Where("asset = ?", asset). + Where("created_at <= ?", at). + Where("last_updated_at >= ?", at). + Order("created_at DESC", "sort_id DESC"). + Limit(1). + Scan(ctx) + if err != nil { + return nil, e("failed to get balance", err) + } + + return pointer.For(toBalanceModels(balance)), nil +} + +func (s *store) BalancesGetAt(ctx context.Context, accountID models.AccountID, at time.Time) ([]*models.Balance, error) { + assets, err := s.balancesListAssets(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("failed to list balance assets: %w", err) + } + + var balances []*models.Balance + for _, currency := range assets { + balance, err := s.balancesGetAtByAsset(ctx, accountID, currency, at) + if err != nil { + if errors.Is(err, ErrNotFound) { + continue + } + return nil, fmt.Errorf("failed to get balance: %w", err) + } + + balances = append(balances, balance) + } + + return balances, nil +} + +func fromBalancesModels(from []models.Balance) []balance { + var to []balance + for _, b := range from { + to = append(to, fromBalanceModels(b)) + } + return to +} + +func fromBalanceModels(from models.Balance) balance { + return balance{ + AccountID: from.AccountID, + CreatedAt: internalTime.New(from.CreatedAt), + Asset: from.Asset, + ConnectorID: from.AccountID.ConnectorID, + Balance: from.Balance, + LastUpdatedAt: internalTime.New(from.LastUpdatedAt), + } +} + +func toBalancesModels(from []balance) []models.Balance { + var to []models.Balance + for _, b := range from { + to = append(to, toBalanceModels(b)) + } + return to +} + +func toBalanceModels(from balance) models.Balance { + return models.Balance{ + AccountID: from.AccountID, + CreatedAt: from.CreatedAt.Time, + Asset: from.Asset, + Balance: from.Balance, + LastUpdatedAt: from.LastUpdatedAt.Time, + } +} diff --git a/internal/storage/balances_test.go b/internal/storage/balances_test.go new file mode 100644 index 00000000..e083381e --- /dev/null +++ b/internal/storage/balances_test.go @@ -0,0 +1,495 @@ +package storage + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func defaultBalances() []models.Balance { + defaultAccounts := defaultAccounts() + return []models.Balance{ + { + AccountID: defaultAccounts[0].ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + { + AccountID: defaultAccounts[1].ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-30 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(1000), + }, + { + AccountID: defaultAccounts[0].ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(150), + }, + } +} + +func defaultBalances2() []models.Balance { + defaultAccounts := defaultAccounts() + return []models.Balance{ + { + AccountID: defaultAccounts[2].ID, + CreatedAt: now.Add(-59 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-59 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + { + AccountID: defaultAccounts[2].ID, + CreatedAt: now.Add(-31 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time, + Asset: "DKK/2", + Balance: big.NewInt(1000), + }, + } +} + +func upsertBalances(t *testing.T, ctx context.Context, storage Storage, balances []models.Balance) { + require.NoError(t, storage.BalancesUpsert(ctx, balances)) +} + +func TestBalancesUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBalances(t, ctx, store, defaultBalances()) + upsertBalances(t, ctx, store, defaultBalances2()) + + t.Run("insert balances with same asset and same balance", func(t *testing.T) { + accounts := defaultAccounts() + b := models.Balance{ + AccountID: accounts[2].ID, + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + } + + upsertBalances(t, ctx, store, []models.Balance{b}) + + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + AccountID: pointer.For(accounts[2].ID), + Asset: "USD/2", + }).WithPageSize(15), + ) + + expectedBalances := []models.Balance{ + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-59 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, // Last updated at should be updated to the new balance value + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + balances, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, balances.Data, 1) + require.Equal(t, expectedBalances, balances.Data) + }) + + t.Run("insert balances same asset different balance", func(t *testing.T) { + accounts := defaultAccounts() + b := models.Balance{ + AccountID: accounts[0].ID, + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(200), + } + + upsertBalances(t, ctx, store, []models.Balance{b}) + + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + AccountID: pointer.For(accounts[0].ID), + Asset: "USD/2", + }).WithPageSize(15), + ) + + expectedBalances := []models.Balance{ + // We should have one more balance with the new balance value + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(200), + }, + // and the old balance should have its updated at to the new balance created at + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + balances, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, balances.Data, 2) + require.Equal(t, expectedBalances, balances.Data) + }) + + t.Run("insert balances with new asset", func(t *testing.T) { + accounts := defaultAccounts() + b := models.Balance{ + AccountID: accounts[2].ID, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-10 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(200), + } + + upsertBalances(t, ctx, store, []models.Balance{b}) + + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + AccountID: pointer.For(accounts[2].ID), + }).WithPageSize(15), + ) + + expectedBalances := []models.Balance{ + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-10 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(200), + }, + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-31 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time, + Asset: "DKK/2", + Balance: big.NewInt(1000), + }, + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-59 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, // Because on the first function it was modified + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.Equal(t, expectedBalances, cursor.Data) + }) +} + +func TestBalancesDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBalances(t, ctx, store, defaultBalances()) + upsertBalances(t, ctx, store, defaultBalances2()) + + t.Run("delete balances from unknown connector id", func(t *testing.T) { + err := store.BalancesDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }) + require.NoError(t, err) + }) + + t.Run("delete balances from known connector id", func(t *testing.T) { + accounts := defaultAccounts() + err := store.BalancesDeleteFromConnectorID(ctx, defaultConnector.ID) + require.NoError(t, err) + + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + AccountID: pointer.For(accounts[0].ID), + }).WithPageSize(15), + ) + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + }) +} + +func TestBalancesList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBalances(t, ctx, store, defaultBalances()) + upsertBalances(t, ctx, store, defaultBalances2()) + + t.Run("list balances with account id", func(t *testing.T) { + accounts := defaultAccounts() + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + AccountID: pointer.For(accounts[0].ID), + }).WithPageSize(15), + ) + + expectedBalances := []models.Balance{ + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(150), + }, + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Equal(t, expectedBalances, cursor.Data) + }) + + t.Run("list balances with asset 1", func(t *testing.T) { + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + Asset: "USD/2", + }).WithPageSize(15), + ) + + accounts := defaultAccounts() + expectedBalances := []models.Balance{ + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-59 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-59 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Equal(t, expectedBalances, cursor.Data) + }) + + t.Run("list balances with asset 2", func(t *testing.T) { + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + Asset: "DKK/2", + }).WithPageSize(15), + ) + + accounts := defaultAccounts() + expectedBalances := []models.Balance{ + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-31 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time, + Asset: "DKK/2", + Balance: big.NewInt(1000), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Equal(t, expectedBalances, cursor.Data) + }) + + t.Run("list balances with from", func(t *testing.T) { + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + From: now.Add(-40 * time.Minute).UTC().Time, + }).WithPageSize(15), + ) + + accounts := defaultAccounts() + expectedBalances := []models.Balance{ + { + AccountID: accounts[1].ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-30 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(1000), + }, + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-31 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time, + Asset: "DKK/2", + Balance: big.NewInt(1000), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Equal(t, expectedBalances, cursor.Data) + }) + + t.Run("list balances with from 2", func(t *testing.T) { + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + From: now.Add(-20 * time.Minute).UTC().Time, + }).WithPageSize(15), + ) + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list balances with to", func(t *testing.T) { + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + To: now.Add(-40 * time.Minute).UTC().Time, + }).WithPageSize(15), + ) + + accounts := defaultAccounts() + expectedBalances := []models.Balance{ + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(150), + }, + { + AccountID: accounts[2].ID, + CreatedAt: now.Add(-59 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-59 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + require.Equal(t, expectedBalances, cursor.Data) + }) + + t.Run("list balances with to 2", func(t *testing.T) { + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + To: now.Add(-70 * time.Minute).UTC().Time, + }).WithPageSize(15), + ) + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list balances test cursor", func(t *testing.T) { + accounts := defaultAccounts() + q := NewListBalancesQuery( + bunpaginate.NewPaginatedQueryOptions(BalanceQuery{ + AccountID: pointer.For(accounts[0].ID), + }).WithPageSize(1), + ) + expectedBalances1 := []models.Balance{ + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time, + Asset: "EUR/2", + Balance: big.NewInt(150), + }, + } + + cursor, err := store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, expectedBalances1, cursor.Data) + + expectedBalances2 := []models.Balance{ + { + AccountID: accounts[0].ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time, + Asset: "USD/2", + Balance: big.NewInt(100), + }, + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, expectedBalances2, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.BalancesList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, expectedBalances1, cursor.Data) + }) +} diff --git a/internal/storage/bank_accounts.go b/internal/storage/bank_accounts.go new file mode 100644 index 00000000..c48e6513 --- /dev/null +++ b/internal/storage/bank_accounts.go @@ -0,0 +1,340 @@ +package storage + +import ( + "context" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type bankAccount struct { + bun.BaseModel `bun:"table:bank_accounts"` + + // Mandatory fields + ID uuid.UUID `bun:"id,pk,type:uuid,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Name string `bun:"name,type:text,notnull"` + + // Field encrypted + AccountNumber string `bun:"decrypted_account_number,scanonly"` + IBAN string `bun:"decrypted_iban,scanonly"` + SwiftBicCode string `bun:"decrypted_swift_bic_code,scanonly"` + + // Optional fields + // c.f.: https://bun.uptrace.dev/guide/models.html#nulls + Country *string `bun:"country,type:text,nullzero"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` + + RelatedAccounts []*bankAccountRelatedAccount `bun:"rel:has-many,join:id=bank_account_id"` +} + +func (s *store) BankAccountsUpsert(ctx context.Context, ba models.BankAccount) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("begin transaction", err) + } + defer tx.Rollback() + + toInsert := fromBankAccountModels(ba) + // Insert or update the bank account + res, err := tx.NewInsert(). + Model(&toInsert). + Column("id", "created_at", "name", "country", "metadata"). + On("CONFLICT (id) DO NOTHING"). + Returning("id"). + Exec(ctx) + if err != nil { + return e("insert bank account", err) + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return e("insert bank account", err) + } + + if rowsAffected > 0 { + _, err = tx.NewUpdate(). + Model((*bankAccount)(nil)). + Set("account_number = pgp_sym_encrypt(?::TEXT, ?, ?)", toInsert.AccountNumber, s.configEncryptionKey, encryptionOptions). + Set("iban = pgp_sym_encrypt(?::TEXT, ?, ?)", toInsert.IBAN, s.configEncryptionKey, encryptionOptions). + Set("swift_bic_code = pgp_sym_encrypt(?::TEXT, ?, ?)", toInsert.SwiftBicCode, s.configEncryptionKey, encryptionOptions). + Where("id = ?", toInsert.ID). + Exec(ctx) + if err != nil { + return e("update bank account", err) + } + } + + if len(toInsert.RelatedAccounts) > 0 { + // Insert or update the related accounts + _, err = tx.NewInsert(). + Model(&toInsert.RelatedAccounts). + On("CONFLICT (bank_account_id, account_id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("insert related accounts", err) + } + } + + return e("commit transaction", tx.Commit()) +} + +func (s *store) BankAccountsUpdateMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("update bank account metadata", err) + } + defer tx.Rollback() + + var account bankAccount + err = tx.NewSelect(). + Model(&account). + Column("id", "metadata"). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return e("update bank account metadata", err) + } + + if account.Metadata == nil { + account.Metadata = make(map[string]string) + } + + for k, v := range metadata { + account.Metadata[k] = v + } + + _, err = tx.NewUpdate(). + Model(&account). + Column("metadata"). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return e("update bank account metadata", err) + } + + return e("commit transaction", tx.Commit()) +} + +func (s *store) BankAccountsGet(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { + var account bankAccount + query := s.db.NewSelect(). + Model(&account). + Column("id", "created_at", "name", "country", "metadata"). + Relation("RelatedAccounts") + if expand { + query = query.ColumnExpr("pgp_sym_decrypt(account_number, ?, ?) AS decrypted_account_number", s.configEncryptionKey, encryptionOptions). + ColumnExpr("pgp_sym_decrypt(iban, ?, ?) AS decrypted_iban", s.configEncryptionKey, encryptionOptions). + ColumnExpr("pgp_sym_decrypt(swift_bic_code, ?, ?) AS decrypted_swift_bic_code", s.configEncryptionKey, encryptionOptions) + } + err := query.Where("id = ?", id).Scan(ctx) + if err != nil { + return nil, e("get bank account", err) + } + + return pointer.For(toBankAccountModels(account)), nil +} + +type BankAccountQuery struct{} + +type ListBankAccountsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[BankAccountQuery]] + +func NewListBankAccountsQuery(opts bunpaginate.PaginatedQueryOptions[BankAccountQuery]) ListBankAccountsQuery { + return ListBankAccountsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) bankAccountsQueryContext(qb query.Builder) (string, []any, error) { + return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "name", key == "country": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("'%s' column can only be used with $match", key)) + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + case metadataRegex.Match([]byte(key)): + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + match := metadataRegex.FindAllStringSubmatch(key, 3) + + key := "metadata" + return key + " @> ?", []any{map[string]any{ + match[0][1]: value, + }}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) +} + +func (s *store) BankAccountsList(ctx context.Context, q ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.bankAccountsQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[BankAccountQuery], bankAccount](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[BankAccountQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + query = query.Relation("RelatedAccounts") + if where != "" { + query = query.Where(where, args...) + } + + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch accounts", err) + } + + bankAccounts := make([]models.BankAccount, 0, len(cursor.Data)) + for _, a := range cursor.Data { + bankAccounts = append(bankAccounts, toBankAccountModels(a)) + } + + return &bunpaginate.Cursor[models.BankAccount]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: bankAccounts, + }, nil +} + +type bankAccountRelatedAccount struct { + bun.BaseModel `bun:"table:bank_accounts_related_accounts"` + + // Mandatory fields + BankAccountID uuid.UUID `bun:"bank_account_id,pk,type:uuid,notnull"` + AccountID models.AccountID `bun:"account_id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` +} + +func (s *store) BankAccountsAddRelatedAccount(ctx context.Context, relatedAccount models.BankAccountRelatedAccount) error { + toInsert := fromBankAccountRelatedAccountModels(relatedAccount) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (bank_account_id, account_id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("add bank account related account", err) + } + + return nil +} + +func (s *store) BankAccountsDeleteRelatedAccountFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*bankAccountRelatedAccount)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + if err != nil { + return e("delete bank account related account", err) + } + + return nil +} + +func fromBankAccountModels(from models.BankAccount) bankAccount { + ba := bankAccount{ + ID: from.ID, + CreatedAt: time.New(from.CreatedAt), + Name: from.Name, + Country: from.Country, + Metadata: from.Metadata, + } + + if from.AccountNumber != nil { + ba.AccountNumber = *from.AccountNumber + } + + if from.IBAN != nil { + ba.IBAN = *from.IBAN + } + + if from.SwiftBicCode != nil { + ba.SwiftBicCode = *from.SwiftBicCode + } + + relatedAccounts := make([]*bankAccountRelatedAccount, 0, len(from.RelatedAccounts)) + for _, ra := range from.RelatedAccounts { + relatedAccounts = append(relatedAccounts, pointer.For(fromBankAccountRelatedAccountModels(ra))) + } + ba.RelatedAccounts = relatedAccounts + + return ba +} + +func toBankAccountModels(from bankAccount) models.BankAccount { + ba := models.BankAccount{ + ID: from.ID, + CreatedAt: from.CreatedAt.Time, + Name: from.Name, + Country: from.Country, + Metadata: from.Metadata, + } + + if from.AccountNumber != "" { + ba.AccountNumber = &from.AccountNumber + } + + if from.IBAN != "" { + ba.IBAN = &from.IBAN + } + + if from.SwiftBicCode != "" { + ba.SwiftBicCode = &from.SwiftBicCode + } + + relatedAccounts := make([]models.BankAccountRelatedAccount, 0, len(from.RelatedAccounts)) + for _, ra := range from.RelatedAccounts { + relatedAccounts = append(relatedAccounts, toBankAccountRelatedAccountModels(*ra)) + } + ba.RelatedAccounts = relatedAccounts + + return ba +} + +func fromBankAccountRelatedAccountModels(from models.BankAccountRelatedAccount) bankAccountRelatedAccount { + return bankAccountRelatedAccount{ + BankAccountID: from.BankAccountID, + AccountID: from.AccountID, + ConnectorID: from.ConnectorID, + CreatedAt: time.New(from.CreatedAt), + } +} + +func toBankAccountRelatedAccountModels(from bankAccountRelatedAccount) models.BankAccountRelatedAccount { + return models.BankAccountRelatedAccount{ + BankAccountID: from.BankAccountID, + AccountID: from.AccountID, + ConnectorID: from.ConnectorID, + CreatedAt: from.CreatedAt.Time, + } +} diff --git a/internal/storage/bank_accounts_test.go b/internal/storage/bank_accounts_test.go new file mode 100644 index 00000000..5625d09c --- /dev/null +++ b/internal/storage/bank_accounts_test.go @@ -0,0 +1,718 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultBankAccount = models.BankAccount{ + ID: uuid.New(), + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Name: "test1", + AccountNumber: pointer.For("12345678"), + Country: pointer.For("US"), + Metadata: map[string]string{ + "foo": "bar", + }, + } + + bcID2 = uuid.New() + defaultBankAccount2 = models.BankAccount{ + ID: bcID2, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Name: "test2", + IBAN: pointer.For("DE89370400440532013000"), + SwiftBicCode: pointer.For("COBADEFFXXX"), + Country: pointer.For("DE"), + Metadata: map[string]string{ + "foo2": "bar2", + }, + RelatedAccounts: []models.BankAccountRelatedAccount{ + { + BankAccountID: bcID2, + AccountID: defaultAccounts()[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + }, + }, + } + + // No metadata + defaultBankAccount3 = models.BankAccount{ + ID: uuid.New(), + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Name: "test1", + AccountNumber: pointer.For("12345678"), + Country: pointer.For("US"), + } +) + +func upsertBankAccount(t *testing.T, ctx context.Context, storage Storage, bankAccounts models.BankAccount) { + require.NoError(t, storage.BankAccountsUpsert(ctx, bankAccounts)) +} + +func TestBankAccountsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBankAccount(t, ctx, store, defaultBankAccount) + upsertBankAccount(t, ctx, store, defaultBankAccount2) + + t.Run("upsert with same id", func(t *testing.T) { + ba := models.BankAccount{ + ID: defaultBankAccount.ID, + CreatedAt: now.UTC().Time, + Name: "changed", + AccountNumber: pointer.For("987654321"), + Country: pointer.For("CA"), + Metadata: map[string]string{ + "changed": "changed", + }, + } + + require.NoError(t, store.BankAccountsUpsert(ctx, ba)) + + actual, err := store.BankAccountsGet(ctx, ba.ID, true) + require.NoError(t, err) + // Should not update the bank account + compareBankAccounts(t, defaultBankAccount, *actual) + }) + + t.Run("unknown connector id", func(t *testing.T) { + ba := models.BankAccount{ + ID: uuid.New(), + CreatedAt: now.UTC().Time, + Name: "foo", + AccountNumber: pointer.For("12345678"), + Country: pointer.For("US"), + Metadata: map[string]string{ + "foo": "bar", + }, + RelatedAccounts: []models.BankAccountRelatedAccount{ + { + BankAccountID: uuid.New(), + AccountID: defaultAccounts()[0].ID, + ConnectorID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, + CreatedAt: now.UTC().Time, + }, + }, + } + + require.Error(t, store.BankAccountsUpsert(ctx, ba)) + b, err := store.BankAccountsGet(ctx, ba.ID, true) + require.Error(t, err) + require.Nil(t, b) + }) +} + +func TestBankAccountsUpdateMetadata(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBankAccount(t, ctx, store, defaultBankAccount) + upsertBankAccount(t, ctx, store, defaultBankAccount2) + upsertBankAccount(t, ctx, store, defaultBankAccount3) + + t.Run("update metadata", func(t *testing.T) { + metadata := map[string]string{ + "test1": "test2", + "test3": "test4", + } + + // redeclare it in order to not update the map of global variable + acc := models.BankAccount{ + ID: defaultBankAccount.ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Name: "test1", + AccountNumber: pointer.For("12345678"), + Country: pointer.For("US"), + Metadata: map[string]string{ + "foo": "bar", + }, + } + for k, v := range metadata { + acc.Metadata[k] = v + } + + require.NoError(t, store.BankAccountsUpdateMetadata(ctx, defaultBankAccount.ID, metadata)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount.ID, true) + require.NoError(t, err) + compareBankAccounts(t, acc, *actual) + }) + + t.Run("update same metadata", func(t *testing.T) { + metadata := map[string]string{ + "foo2": "bar3", + } + + acc := models.BankAccount{ + ID: bcID2, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Name: "test2", + IBAN: pointer.For("DE89370400440532013000"), + SwiftBicCode: pointer.For("COBADEFFXXX"), + Country: pointer.For("DE"), + Metadata: map[string]string{ + "foo2": "bar2", + }, + RelatedAccounts: []models.BankAccountRelatedAccount{ + { + BankAccountID: bcID2, + AccountID: defaultAccounts()[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + }, + }, + } + for k, v := range metadata { + acc.Metadata[k] = v + } + + require.NoError(t, store.BankAccountsUpdateMetadata(ctx, defaultBankAccount2.ID, metadata)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount2.ID, true) + require.NoError(t, err) + compareBankAccounts(t, acc, *actual) + }) + + t.Run("update metadata of bank accounts with nil map", func(t *testing.T) { + metadata := map[string]string{ + "test1": "test2", + "test3": "test4", + } + + // redeclare it in order to not update the map of global variable + acc := models.BankAccount{ + ID: defaultBankAccount3.ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Name: "test1", + AccountNumber: pointer.For("12345678"), + Country: pointer.For("US"), + } + acc.Metadata = make(map[string]string) + for k, v := range metadata { + acc.Metadata[k] = v + } + + require.NoError(t, store.BankAccountsUpdateMetadata(ctx, defaultBankAccount3.ID, metadata)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount3.ID, true) + require.NoError(t, err) + compareBankAccounts(t, acc, *actual) + }) +} + +func TestBankAccountsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBankAccount(t, ctx, store, defaultBankAccount) + upsertBankAccount(t, ctx, store, defaultBankAccount2) + upsertBankAccount(t, ctx, store, defaultBankAccount3) + + t.Run("get bank account without related accounts", func(t *testing.T) { + actual, err := store.BankAccountsGet(ctx, defaultBankAccount.ID, true) + require.NoError(t, err) + compareBankAccounts(t, defaultBankAccount, *actual) + }) + + t.Run("get bank account without metadata", func(t *testing.T) { + actual, err := store.BankAccountsGet(ctx, defaultBankAccount3.ID, true) + require.NoError(t, err) + compareBankAccounts(t, defaultBankAccount3, *actual) + }) + + t.Run("get bank account with related accounts", func(t *testing.T) { + actual, err := store.BankAccountsGet(ctx, defaultBankAccount2.ID, true) + require.NoError(t, err) + compareBankAccounts(t, defaultBankAccount2, *actual) + }) + + t.Run("get unknown bank account", func(t *testing.T) { + actual, err := store.BankAccountsGet(ctx, uuid.New(), true) + require.Error(t, err) + require.Nil(t, actual) + }) + + t.Run("get bank account with expand to false", func(t *testing.T) { + acc := models.BankAccount{ + ID: defaultBankAccount.ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Name: "test1", + Country: pointer.For("US"), + Metadata: map[string]string{ + "foo": "bar", + }, + } + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount.ID, false) + require.NoError(t, err) + compareBankAccounts(t, acc, *actual) + }) + + t.Run("get bank account with expand to false 2", func(t *testing.T) { + acc := models.BankAccount{ + ID: bcID2, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Name: "test2", + Country: pointer.For("DE"), + Metadata: map[string]string{ + "foo2": "bar2", + }, + RelatedAccounts: []models.BankAccountRelatedAccount{ + { + BankAccountID: bcID2, + AccountID: defaultAccounts()[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + }, + }, + } + + actual, err := store.BankAccountsGet(ctx, bcID2, false) + require.NoError(t, err) + compareBankAccounts(t, acc, *actual) + }) +} + +func TestBankAccountsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + d1 := models.BankAccount{ + ID: defaultBankAccount.ID, + CreatedAt: defaultBankAccount.CreatedAt, + Name: defaultBankAccount.Name, + Country: defaultBankAccount.Country, + Metadata: defaultBankAccount.Metadata, + } + + d2 := models.BankAccount{ + ID: defaultBankAccount2.ID, + CreatedAt: defaultBankAccount2.CreatedAt, + Name: defaultBankAccount2.Name, + Country: defaultBankAccount2.Country, + Metadata: defaultBankAccount2.Metadata, + RelatedAccounts: defaultBankAccount2.RelatedAccounts, + } + _ = d2 + + d3 := models.BankAccount{ + ID: defaultBankAccount3.ID, + CreatedAt: defaultBankAccount3.CreatedAt, + Name: defaultBankAccount3.Name, + Country: defaultBankAccount3.Country, + } + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBankAccount(t, ctx, store, defaultBankAccount) + upsertBankAccount(t, ctx, store, defaultBankAccount2) + upsertBankAccount(t, ctx, store, defaultBankAccount3) + + t.Run("list bank accounts by name", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "test1")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + compareBankAccounts(t, d3, cursor.Data[0]) + compareBankAccounts(t, d1, cursor.Data[1]) + }) + + t.Run("list bank accounts by name 2", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "test2")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + compareBankAccounts(t, d2, cursor.Data[0]) + }) + + t.Run("list bank accounts by unknown name", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "unknown")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list bank accounts by country", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("country", "US")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + compareBankAccounts(t, d3, cursor.Data[0]) + compareBankAccounts(t, d1, cursor.Data[1]) + }) + + t.Run("list bank accounts by country 2", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("country", "DE")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + compareBankAccounts(t, d2, cursor.Data[0]) + }) + + t.Run("list bank accounts by unknown country", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("country", "unknown")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list bank accounts by metadata", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "bar")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + compareBankAccounts(t, d1, cursor.Data[0]) + }) + + t.Run("list bank accounts by unknown metadata", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[unknown]", "bar")), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list bank accounts test cursor", func(t *testing.T) { + q := NewListBankAccountsQuery( + bunpaginate.NewPaginatedQueryOptions(BankAccountQuery{}). + WithPageSize(1), + ) + + cursor, err := store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + compareBankAccounts(t, d2, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + compareBankAccounts(t, d3, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + compareBankAccounts(t, d1, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + compareBankAccounts(t, d3, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.BankAccountsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + compareBankAccounts(t, d2, cursor.Data[0]) + }) +} + +func TestBankAccountsAddRelatedAccount(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBankAccount(t, ctx, store, defaultBankAccount) + upsertBankAccount(t, ctx, store, defaultBankAccount2) + upsertBankAccount(t, ctx, store, defaultBankAccount3) + + t.Run("add related account when empty", func(t *testing.T) { + acc := models.BankAccountRelatedAccount{ + BankAccountID: defaultBankAccount.ID, + AccountID: defaultAccounts()[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.UTC().Time, + } + + ba := defaultBankAccount + ba.RelatedAccounts = append(ba.RelatedAccounts, acc) + + require.NoError(t, store.BankAccountsAddRelatedAccount(ctx, acc)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount.ID, true) + require.NoError(t, err) + compareBankAccounts(t, ba, *actual) + }) + + t.Run("add related account when not empty", func(t *testing.T) { + acc := models.BankAccountRelatedAccount{ + BankAccountID: defaultBankAccount2.ID, + AccountID: defaultAccounts()[1].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.UTC().Time, + } + + ba := defaultBankAccount2 + ba.RelatedAccounts = append(ba.RelatedAccounts, acc) + + require.NoError(t, store.BankAccountsAddRelatedAccount(ctx, acc)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount2.ID, true) + require.NoError(t, err) + compareBankAccounts(t, ba, *actual) + }) + + t.Run("add related account with unknown bank account", func(t *testing.T) { + acc := models.BankAccountRelatedAccount{ + BankAccountID: uuid.New(), + AccountID: defaultAccounts()[1].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.UTC().Time, + } + + require.Error(t, store.BankAccountsAddRelatedAccount(ctx, acc)) + }) + + t.Run("add related account with unknown connector", func(t *testing.T) { + acc := models.BankAccountRelatedAccount{ + BankAccountID: defaultBankAccount2.ID, + AccountID: defaultAccounts()[2].ID, + ConnectorID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, + CreatedAt: now.UTC().Time, + } + + require.Error(t, store.BankAccountsAddRelatedAccount(ctx, acc)) + }) + + t.Run("add related account with existing related account", func(t *testing.T) { + acc := models.BankAccountRelatedAccount{ + BankAccountID: defaultBankAccount3.ID, + AccountID: defaultAccounts()[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + } + + ba := defaultBankAccount3 + ba.RelatedAccounts = append(ba.RelatedAccounts, acc) + + require.NoError(t, store.BankAccountsAddRelatedAccount(ctx, acc)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount3.ID, true) + require.NoError(t, err) + compareBankAccounts(t, ba, *actual) + + require.NoError(t, store.BankAccountsAddRelatedAccount(ctx, acc)) + + actual, err = store.BankAccountsGet(ctx, defaultBankAccount3.ID, true) + require.NoError(t, err) + compareBankAccounts(t, ba, *actual) + }) +} + +func TestBankAccountsDeleteRelatedAccountFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertBankAccount(t, ctx, store, defaultBankAccount) + upsertBankAccount(t, ctx, store, defaultBankAccount2) + upsertBankAccount(t, ctx, store, defaultBankAccount3) + + t.Run("delete related account with unknown connector", func(t *testing.T) { + require.NoError(t, store.BankAccountsDeleteRelatedAccountFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount2.ID, true) + require.NoError(t, err) + compareBankAccounts(t, defaultBankAccount2, *actual) + }) + + t.Run("delete related account with another connector id", func(t *testing.T) { + require.NoError(t, store.BankAccountsDeleteRelatedAccountFromConnectorID(ctx, defaultConnector2.ID)) + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount2.ID, true) + require.NoError(t, err) + compareBankAccounts(t, defaultBankAccount2, *actual) + }) + + t.Run("delete related account", func(t *testing.T) { + require.NoError(t, store.BankAccountsDeleteRelatedAccountFromConnectorID(ctx, defaultConnector.ID)) + + ba := defaultBankAccount2 + ba.RelatedAccounts = nil + + actual, err := store.BankAccountsGet(ctx, defaultBankAccount2.ID, true) + require.NoError(t, err) + compareBankAccounts(t, ba, *actual) + }) +} + +func compareBankAccounts(t *testing.T, expected, actual models.BankAccount) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Name, actual.Name) + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + require.Equal(t, v, actual.Metadata[k]) + } + for k, v := range actual.Metadata { + require.Equal(t, v, expected.Metadata[k]) + } + + switch { + case expected.AccountNumber != nil && actual.AccountNumber != nil: + require.Equal(t, *expected.AccountNumber, *actual.AccountNumber) + case expected.AccountNumber == nil && actual.AccountNumber == nil: + // Nothing to do + default: + require.Fail(t, "AccountNumber mismatch") + } + + switch { + case expected.IBAN != nil && actual.IBAN != nil: + require.Equal(t, *expected.IBAN, *actual.IBAN) + case expected.IBAN == nil && actual.IBAN == nil: + // Nothing to do + default: + require.Fail(t, "IBAN mismatch") + } + + switch { + case expected.SwiftBicCode != nil && actual.SwiftBicCode != nil: + require.Equal(t, *expected.SwiftBicCode, *actual.SwiftBicCode) + case expected.SwiftBicCode == nil && actual.SwiftBicCode == nil: + // Nothing to do + default: + require.Fail(t, "SwiftBicCode mismatch") + } + + switch { + case expected.Country != nil && actual.Country != nil: + require.Equal(t, *expected.Country, *actual.Country) + case expected.Country == nil && actual.Country == nil: + // Nothing to do + default: + require.Fail(t, "Country mismatch") + } + + require.Equal(t, len(expected.RelatedAccounts), len(actual.RelatedAccounts)) + for i := range expected.RelatedAccounts { + require.Equal(t, expected.RelatedAccounts[i], actual.RelatedAccounts[i]) + } +} diff --git a/internal/storage/connector_tasks_tree.go b/internal/storage/connector_tasks_tree.go new file mode 100644 index 00000000..a9a2ddc3 --- /dev/null +++ b/internal/storage/connector_tasks_tree.go @@ -0,0 +1,65 @@ +package storage + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type connectorTasksTree struct { + bun.BaseModel `bun:"table:connector_tasks_tree"` + + // Mandatory fields + ConnectorID models.ConnectorID `bun:"connector_id,pk,type:character varying,notnull"` + TasksTree json.RawMessage `bun:"tasks,type:json,notnull"` +} + +func (s *store) ConnectorTasksTreeUpsert(ctx context.Context, connectorID models.ConnectorID, ts models.ConnectorTasksTree) error { + payload, err := json.Marshal(&ts) + if err != nil { + return errors.Wrap(err, "failed to marshal tasks") + } + + tasks := connectorTasksTree{ + ConnectorID: connectorID, + TasksTree: payload, + } + + _, err = s.db.NewInsert(). + Model(&tasks). + On("CONFLICT (connector_id) DO UPDATE"). + Set("tasks = EXCLUDED.tasks"). + Exec(ctx) + return e("failed to insert tasks", err) +} + +func (s *store) ConnectorTasksTreeGet(ctx context.Context, connectorID models.ConnectorID) (*models.ConnectorTasksTree, error) { + var ts connectorTasksTree + + err := s.db.NewSelect(). + Model(&ts). + Where("connector_id = ?", connectorID). + Scan(ctx) + if err != nil { + return nil, e("failed to fetch tasks", err) + } + + var tasks models.ConnectorTasksTree + if err := json.Unmarshal(ts.TasksTree, &tasks); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal tasks") + } + + return &tasks, nil +} + +func (s *store) ConnectorTasksTreeDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*connectorTasksTree)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete tasks", err) +} diff --git a/internal/storage/connector_tasks_tree_test.go b/internal/storage/connector_tasks_tree_test.go new file mode 100644 index 00000000..73c55432 --- /dev/null +++ b/internal/storage/connector_tasks_tree_test.go @@ -0,0 +1,160 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultTasksTree = models.ConnectorTasksTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_beneficiaries", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + } + + defaultTasksTree2 = models.ConnectorTasksTree{ + { + TaskType: models.TASK_FETCH_ACCOUNTS, + Name: "fetch_accounts", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{ + { + TaskType: models.TASK_FETCH_BALANCES, + Name: "fetch_balances", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_PAYMENTS, + Name: "fetch_payments", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + { + TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, + Name: "fetch_recipients", + Periodically: true, + NextTasks: []models.ConnectorTaskTree{}, + }, + }, + }, + } +) + +func upsertTasksTree(t *testing.T, ctx context.Context, storage Storage, connectorID models.ConnectorID, tasksTree []models.ConnectorTaskTree) { + require.NoError(t, storage.ConnectorTasksTreeUpsert(ctx, connectorID, tasksTree)) +} + +func TestConnectorTasksTreeUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertTasksTree(t, ctx, store, defaultConnector.ID, defaultTasksTree) + + t.Run("upsert with unknown connector id", func(t *testing.T) { + require.Error(t, store.ConnectorTasksTreeUpsert(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, defaultTasksTree2)) + }) + + t.Run("upsert with same connector id", func(t *testing.T) { + upsertTasksTree(t, ctx, store, defaultConnector.ID, defaultTasksTree2) + + tasks, err := store.ConnectorTasksTreeGet(ctx, defaultConnector.ID) + require.NoError(t, err) + require.Equal(t, defaultTasksTree2, *tasks) + }) +} + +func TestConnectorTasksTreeGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertTasksTree(t, ctx, store, defaultConnector.ID, defaultTasksTree) + + t.Run("get tasks", func(t *testing.T) { + tasks, err := store.ConnectorTasksTreeGet(ctx, defaultConnector.ID) + require.NoError(t, err) + require.Equal(t, defaultTasksTree, *tasks) + }) + + t.Run("get tasks with unknown connector id", func(t *testing.T) { + _, err := store.ConnectorTasksTreeGet(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }) + require.Error(t, err) + }) +} + +func TestConnectorTasksTreeDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertTasksTree(t, ctx, store, defaultConnector.ID, defaultTasksTree) + upsertTasksTree(t, ctx, store, defaultConnector2.ID, defaultTasksTree2) + + t.Run("delete tasks with unknown connector id", func(t *testing.T) { + require.NoError(t, store.ConnectorTasksTreeDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + tasks, err := store.ConnectorTasksTreeGet(ctx, defaultConnector.ID) + require.NoError(t, err) + require.Equal(t, defaultTasksTree, *tasks) + + tasks, err = store.ConnectorTasksTreeGet(ctx, defaultConnector2.ID) + require.NoError(t, err) + require.Equal(t, defaultTasksTree2, *tasks) + }) + + t.Run("delete tasks", func(t *testing.T) { + require.NoError(t, store.ConnectorTasksTreeDeleteFromConnectorID(ctx, defaultConnector.ID)) + + _, err := store.ConnectorTasksTreeGet(ctx, defaultConnector.ID) + require.Error(t, err) + + tasks, err := store.ConnectorTasksTreeGet(ctx, defaultConnector2.ID) + require.NoError(t, err) + require.Equal(t, defaultTasksTree2, *tasks) + }) +} diff --git a/internal/storage/connectors.go b/internal/storage/connectors.go new file mode 100644 index 00000000..9490b416 --- /dev/null +++ b/internal/storage/connectors.go @@ -0,0 +1,242 @@ +package storage + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgxlisten" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type ConnectorChanges string + +const ( + ConnectorChangesInsert ConnectorChanges = "insert_" + ConnectorChangesUpdate ConnectorChanges = "update_" + ConnectorChangesDelete ConnectorChanges = "delete_" +) + +type HandlerConnectorsChanges map[ConnectorChanges]func(context.Context, models.ConnectorID) error + +type connector struct { + bun.BaseModel `bun:"table:connectors"` + + // Mandatory fields + ID models.ConnectorID `bun:"id,pk,type:character varying,notnull"` + Name string `bun:"name,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Provider string `bun:"provider,type:text,notnull"` + ScheduledForDeletion bool `bun:"scheduled_for_deletion,type:boolean,notnull"` + + // EncryptedConfig is a PGP-encrypted JSON string. + EncryptedConfig string `bun:"config,type:bytea,notnull"` + + // Config is a decrypted config. It is not stored in the database. + DecryptedConfig json.RawMessage `bun:"decrypted_config,scanonly"` +} + +func (s *store) ListenConnectorsChanges(ctx context.Context, handlers HandlerConnectorsChanges) error { + conn, err := s.db.Conn(ctx) + if err != nil { + return errors.Wrap(err, "cannot get connection") + } + + s.rwMutex.Lock() + s.conns = append(s.conns, conn) + s.rwMutex.Unlock() + + if err := conn.Raw(func(driverConn any) error { + listener := pgxlisten.Listener{ + Connect: func(ctx context.Context) (*pgx.Conn, error) { + return pgx.Connect(ctx, driverConn.(*stdlib.Conn).Conn().Config().ConnString()) + }, + } + listener.Handle("connectors", pgxlisten.HandlerFunc(func(ctx context.Context, notification *pgconn.Notification, conn *pgx.Conn) error { + for prefix, handler := range handlers { + if strings.HasPrefix(notification.Payload, string(prefix)) { + return handler( + ctx, + models.MustConnectorIDFromString(strings.TrimPrefix(notification.Payload, string(prefix))), + ) + } + } + return nil + })) + go func() { + s.logger.Info("listening for connectors changes") + if err := listener.Listen(ctx); err != nil { + if errors.Is(err, context.Canceled) { + return + } + + s.logger.Errorf("failed to listen for connectors changes: %w", err) + } + }() + return nil + }); err != nil { + return errors.Wrap(err, "cannot get driver connection") + } + return nil +} + +func (s *store) ConnectorsInstall(ctx context.Context, c models.Connector) error { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return errors.Wrap(err, "cannot begin transaction") + } + defer tx.Rollback() + + toInsert := connector{ + ID: c.ID, + Name: c.Name, + CreatedAt: time.New(c.CreatedAt), + Provider: c.Provider, + ScheduledForDeletion: false, + } + + _, err = tx.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert connector", err) + } + + _, err = tx.NewUpdate(). + Model((*connector)(nil)). + Set("config = pgp_sym_encrypt(?::TEXT, ?, ?)", c.Config, s.configEncryptionKey, encryptionOptions). + Where("id = ?", toInsert.ID). + Exec(ctx) + if err != nil { + return e("failed to encrypt config", err) + } + + return e("failed to commit transaction", tx.Commit()) +} + +func (s *store) ConnectorsScheduleForDeletion(ctx context.Context, id models.ConnectorID) error { + _, err := s.db.NewUpdate(). + Model((*connector)(nil)). + Set("scheduled_for_deletion = ?", true). + Where("id = ?", id). + Exec(ctx) + return e("failed to schedule connector for deletion", err) +} + +func (s *store) ConnectorsUninstall(ctx context.Context, id models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*connector)(nil)). + Where("id = ?", id). + Exec(ctx) + return e("failed to delete connector", err) +} + +func (s *store) ConnectorsGet(ctx context.Context, id models.ConnectorID) (*models.Connector, error) { + var connector connector + + err := s.db.NewSelect(). + Model(&connector). + ColumnExpr("*, pgp_sym_decrypt(config, ?, ?) AS decrypted_config", s.configEncryptionKey, encryptionOptions). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to fetch connector", err) + } + + return &models.Connector{ + ID: connector.ID, + Name: connector.Name, + CreatedAt: connector.CreatedAt.Time, + Provider: connector.Provider, + Config: connector.DecryptedConfig, + ScheduledForDeletion: connector.ScheduledForDeletion, + }, nil +} + +type ConnectorQuery struct{} + +type ListConnectorsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[ConnectorQuery]] + +func NewListConnectorsQuery(opts bunpaginate.PaginatedQueryOptions[ConnectorQuery]) ListConnectorsQuery { + return ListConnectorsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) connectorsQueryContext(qb query.Builder) (string, []any, error) { + return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "name", + key == "provider": + return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) +} + +func (s *store) ConnectorsList(ctx context.Context, q ListConnectorsQuery) (*bunpaginate.Cursor[models.Connector], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.connectorsQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[ConnectorQuery], connector](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[ConnectorQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + query = query.ColumnExpr("*, pgp_sym_decrypt(config, ?, ?) AS decrypted_config", s.configEncryptionKey, encryptionOptions) + + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch connectors", err) + } + + connectors := make([]models.Connector, 0, len(cursor.Data)) + for _, c := range cursor.Data { + connectors = append(connectors, models.Connector{ + ID: c.ID, + Name: c.Name, + CreatedAt: c.CreatedAt.Time, + Provider: c.Provider, + Config: c.DecryptedConfig, + ScheduledForDeletion: c.ScheduledForDeletion, + }) + } + + return &bunpaginate.Cursor[models.Connector]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: connectors, + }, nil +} diff --git a/internal/storage/connectors_test.go b/internal/storage/connectors_test.go new file mode 100644 index 00000000..dc6b95b0 --- /dev/null +++ b/internal/storage/connectors_test.go @@ -0,0 +1,309 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + now = time.Now() + defaultConnector = models.Connector{ + ID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "default", + }, + Name: "default", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Provider: "default", + Config: []byte(`{}`), + } + + defaultConnector2 = models.Connector{ + ID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "default2", + }, + Name: "default2", + CreatedAt: now.Add(-45 * time.Minute).UTC().Time, + Provider: "default2", + Config: []byte(`{}`), + } + + defaultConnector3 = models.Connector{ + ID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "default", + }, + Name: "default3", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Provider: "default", + Config: []byte(`{}`), + } +) + +func upsertConnector(t *testing.T, ctx context.Context, storage Storage, connector models.Connector) { + require.NoError(t, storage.ConnectorsInstall(ctx, connector)) +} + +func TestConnectorsInstall(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + + t.Run("same id upsert", func(t *testing.T) { + c := models.Connector{ + ID: defaultConnector.ID, + Name: "test changed", + CreatedAt: time.Now().UTC().Time, + Provider: "test", + Config: []byte(`{}`), + } + + require.NoError(t, store.ConnectorsInstall(ctx, c)) + + connector, err := store.ConnectorsGet(ctx, c.ID) + require.NoError(t, err) + require.NotNil(t, connector) + require.Equal(t, defaultConnector, *connector) + }) + + t.Run("unique same upsert", func(t *testing.T) { + c := models.Connector{ + ID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "test", + }, + Name: "default", + CreatedAt: now.Add(-23 * time.Minute).UTC().Time, + Provider: "test", + Config: []byte(`{}`), + } + + require.Error(t, store.ConnectorsInstall(ctx, c)) + }) +} + +func TestConnectorsUninstall(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + + t.Run("uninstall default connector", func(t *testing.T) { + require.NoError(t, store.ConnectorsUninstall(ctx, defaultConnector.ID)) + + connector, err := store.ConnectorsGet(ctx, defaultConnector.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, connector) + }) + + t.Run("uninstall unknown connector", func(t *testing.T) { + id := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + require.NoError(t, store.ConnectorsUninstall(ctx, id)) + }) +} + +func TestConnectorsScheduleForDeletion(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + + t.Run("schedule for deletion of unknown connector", func(t *testing.T) { + id := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + require.NoError(t, store.ConnectorsScheduleForDeletion(ctx, id)) + }) + + t.Run("schedule for deletion of default connector", func(t *testing.T) { + require.NoError(t, store.ConnectorsScheduleForDeletion(ctx, defaultConnector.ID)) + + connector, err := store.ConnectorsGet(ctx, defaultConnector.ID) + require.NoError(t, err) + require.NotNil(t, connector) + require.True(t, connector.ScheduledForDeletion) + }) +} + +func TestConnectorsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + + t.Run("get connector", func(t *testing.T) { + connector, err := store.ConnectorsGet(ctx, defaultConnector.ID) + require.NoError(t, err) + require.NotNil(t, connector) + require.Equal(t, defaultConnector, *connector) + }) + + t.Run("get unknown connector", func(t *testing.T) { + id := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + connector, err := store.ConnectorsGet(ctx, id) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, connector) + }) +} + +func TestConnectorsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertConnector(t, ctx, store, defaultConnector3) + + t.Run("list connectors by name", func(t *testing.T) { + q := NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "default")), + ) + + cursor, err := store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.Empty(t, cursor.Previous) + require.Equal(t, defaultConnector, cursor.Data[0]) + }) + + t.Run("list connectors by unknown name", func(t *testing.T) { + q := NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "unknown")), + ) + + cursor, err := store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Empty(t, cursor.Data) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.Empty(t, cursor.Previous) + }) + + t.Run("list connectors by provider", func(t *testing.T) { + q := NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("provider", "default")), + ) + + cursor, err := store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.Empty(t, cursor.Previous) + require.Equal(t, defaultConnector3, cursor.Data[0]) + require.Equal(t, defaultConnector, cursor.Data[1]) + }) + + t.Run("list connectors by unknown provider", func(t *testing.T) { + q := NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("provider", "unknown")), + ) + + cursor, err := store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Empty(t, cursor.Data) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.Empty(t, cursor.Previous) + }) + + t.Run("list connectors test cursor", func(t *testing.T) { + q := NewListConnectorsQuery( + bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}). + WithPageSize(1), + ) + + cursor, err := store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.Empty(t, cursor.Previous) + require.Equal(t, defaultConnector3, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, defaultConnector2, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, defaultConnector, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.NotEmpty(t, cursor.Previous) + require.Equal(t, defaultConnector2, cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.ConnectorsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Next) + require.Empty(t, cursor.Previous) + require.Equal(t, defaultConnector3, cursor.Data[0]) + }) +} diff --git a/cmd/api/internal/storage/error.go b/internal/storage/error.go similarity index 100% rename from cmd/api/internal/storage/error.go rename to internal/storage/error.go diff --git a/internal/storage/events.go b/internal/storage/events.go new file mode 100644 index 00000000..f9ba9100 --- /dev/null +++ b/internal/storage/events.go @@ -0,0 +1,83 @@ +package storage + +import ( + "context" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type eventSent struct { + bun.BaseModel `bun:"table:events_sent"` + + ID models.EventID `bun:"id,pk,type:character varying,notnull"` + ConnectorID *models.ConnectorID `bun:"connector_id,type:character varying"` + SentAt time.Time `bun:"sent_at,type:timestamp without time zone,notnull"` +} + +func (s *store) EventsSentUpsert(ctx context.Context, event models.EventSent) error { + toInsert := fromEventSentModel(event) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + + return e("failed to insert event sent", err) +} + +func (s *store) EventsSentGet(ctx context.Context, id models.EventID) (*models.EventSent, error) { + var event eventSent + + err := s.db.NewSelect(). + Model(&event). + Where("id = ?", id). + Limit(1). + Scan(ctx) + + if err != nil { + return nil, e("failed to get event sent", err) + } + + return pointer.For(toEventSentModel(event)), nil +} + +func (s *store) EventsSentExists(ctx context.Context, id models.EventID) (bool, error) { + exists, err := s.db.NewSelect(). + Model((*eventSent)(nil)). + Where("id = ?", id). + Limit(1). + Exists(ctx) + if err != nil { + return false, e("failed to get event sent", err) + } + + return exists, nil +} + +func (s *store) EventsSentDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*eventSent)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete event sent", err) +} + +func fromEventSentModel(from models.EventSent) eventSent { + return eventSent{ + ID: from.ID, + ConnectorID: from.ConnectorID, + SentAt: time.New(from.SentAt), + } +} + +func toEventSentModel(from eventSent) models.EventSent { + return models.EventSent{ + ID: from.ID, + ConnectorID: from.ConnectorID, + SentAt: from.SentAt.Time, + } +} diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go new file mode 100644 index 00000000..99c501be --- /dev/null +++ b/internal/storage/events_test.go @@ -0,0 +1,203 @@ +package storage + +import ( + "context" + "testing" + "time" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultEventsSent = []models.EventSent{ + { + ID: models.EventID{ + EventIdempotencyKey: "test1", + ConnectorID: &defaultConnector.ID, + }, + ConnectorID: &defaultConnector.ID, + SentAt: now.UTC().Time, + }, + { + ID: models.EventID{ + EventIdempotencyKey: "test2", + ConnectorID: &defaultConnector.ID, + }, + ConnectorID: &defaultConnector.ID, + SentAt: now.Add(-1 * time.Hour).UTC().Time, + }, + { + ID: models.EventID{ + EventIdempotencyKey: "test3", + ConnectorID: &defaultConnector2.ID, + }, + ConnectorID: &defaultConnector2.ID, + SentAt: now.Add(-2 * time.Hour).UTC().Time, + }, + { + ID: models.EventID{ + EventIdempotencyKey: "test4", + ConnectorID: nil, + }, + ConnectorID: nil, + SentAt: now.Add(-3 * time.Hour).UTC().Time, + }, + } +) + +func upsertEventsSent(t *testing.T, ctx context.Context, storage Storage, eventsSent []models.EventSent) { + for _, e := range eventsSent { + require.NoError(t, storage.EventsSentUpsert(ctx, e)) + } +} + +func TestEventsSentUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertEventsSent(t, ctx, store, defaultEventsSent) + + t.Run("same id insert", func(t *testing.T) { + id := models.EventID{ + EventIdempotencyKey: "test1", + ConnectorID: &defaultConnector.ID, + } + + e := models.EventSent{ + ID: id, + ConnectorID: &defaultConnector.ID, + SentAt: now.Add(-3 * time.Hour).UTC().Time, // changed + } + + require.NoError(t, store.EventsSentUpsert(ctx, e)) + + got, err := store.EventsSentGet(ctx, id) + require.NoError(t, err) + require.Equal(t, defaultEventsSent[0], *got) + }) + + t.Run("unknown connector id", func(t *testing.T) { + unknownConnectorID := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + e := models.EventSent{ + ID: models.EventID{ + EventIdempotencyKey: "test5", + ConnectorID: &unknownConnectorID, + }, + ConnectorID: &unknownConnectorID, + SentAt: now.Add(-3 * time.Hour).UTC().Time, + } + + require.Error(t, store.EventsSentUpsert(ctx, e)) + }) +} + +func TestEventsSentGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertEventsSent(t, ctx, store, defaultEventsSent) + + t.Run("get event sent", func(t *testing.T) { + for _, e := range defaultEventsSent { + got, err := store.EventsSentGet(ctx, e.ID) + require.NoError(t, err) + require.Equal(t, e, *got) + } + }) + + t.Run("unknown event sent", func(t *testing.T) { + got, err := store.EventsSentGet(ctx, models.EventID{ + EventIdempotencyKey: "unknown", + ConnectorID: &defaultConnector.ID, + }) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, got) + }) +} + +func TestEventsSentExist(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertEventsSent(t, ctx, store, defaultEventsSent) + + t.Run("existing", func(t *testing.T) { + for _, e := range defaultEventsSent { + got, err := store.EventsSentExists(ctx, e.ID) + require.NoError(t, err) + require.Equal(t, true, got) + } + }) + + t.Run("not existing", func(t *testing.T) { + got, err := store.EventsSentExists(ctx, models.EventID{ + EventIdempotencyKey: "unknown", + ConnectorID: &defaultConnector.ID, + }) + require.NoError(t, err) + require.Equal(t, false, got) + }) +} + +func TestEventsSentDelete(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertEventsSent(t, ctx, store, defaultEventsSent) + + t.Run("delete from unknown connector id", func(t *testing.T) { + unknownConnectorID := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + require.NoError(t, store.EventsSentDeleteFromConnectorID(ctx, unknownConnectorID)) + + for _, e := range defaultEventsSent { + got, err := store.EventsSentGet(ctx, e.ID) + require.NoError(t, err) + require.Equal(t, e, *got) + } + }) + + t.Run("delete from connector id", func(t *testing.T) { + require.NoError(t, store.EventsSentDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, e := range defaultEventsSent { + if e.ConnectorID != nil && *e.ConnectorID == defaultConnector.ID { + got, err := store.EventsSentGet(ctx, e.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, got) + } else { + got, err := store.EventsSentGet(ctx, e.ID) + require.NoError(t, err) + require.Equal(t, e, *got) + } + } + }) +} diff --git a/internal/storage/main_test.go b/internal/storage/main_test.go new file mode 100644 index 00000000..1a141b6c --- /dev/null +++ b/internal/storage/main_test.go @@ -0,0 +1,58 @@ +package storage + +import ( + "context" + "crypto/rand" + "database/sql" + "os" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunconnect" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/testing/docker" + "github.com/formancehq/go-libs/v2/testing/platform/pgtesting" + "github.com/formancehq/go-libs/v2/testing/utils" + "github.com/stretchr/testify/require" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/pgdialect" +) + +var ( + srv *pgtesting.PostgresServer + bunDB *bun.DB +) + +func TestMain(m *testing.M) { + utils.WithTestMain(func(t *utils.TestingTForMain) int { + srv = pgtesting.CreatePostgresServer(t, docker.NewPool(t, logging.Testing())) + + db, err := sql.Open("pgx", srv.GetDSN()) + if err != nil { + logging.Error(err) + os.Exit(1) + } + + bunDB = bun.NewDB(db, pgdialect.New()) + + return m.Run() + }) +} + +func newStore(t *testing.T) Storage { + t.Helper() + ctx := logging.TestingContext() + + pgServer := srv.NewDatabase(t) + + db, err := bunconnect.OpenSQLDB(ctx, pgServer.ConnectionOptions()) + require.NoError(t, err) + + key := make([]byte, 64) + _, err = rand.Read(key) + require.NoError(t, err) + + err = Migrate(context.Background(), db, "test") + require.NoError(t, err) + + return newStorage(logging.Testing(), db, string(key)) +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 88686eb8..42e220ac 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -2,8 +2,12 @@ package storage import ( "context" + "database/sql" + _ "embed" + "fmt" - "github.com/formancehq/go-libs/migrations" + "github.com/formancehq/go-libs/v2/migrations" + paymentsMigration "github.com/formancehq/payments/internal/storage/migrations" "github.com/uptrace/bun" ) @@ -13,10 +17,138 @@ import ( //nolint:gochecknoglobals // This is a global variable by design. var EncryptionKey string -func Migrate(ctx context.Context, db *bun.DB) error { - migrator := migrations.NewMigrator() - registerMigrationsV0(migrator) - registerMigrationsV1(ctx, migrator) +//go:embed migrations/0-init-schema.sql +var initSchema string - return migrator.Up(ctx, db) +//go:embed migrations/5-migrate-bank-accounts-from-v2.sql +var migrateBankAccountsFromV2 string + +//go:embed migrations/7-migrate-transfer-initiations-from-v2.sql +var migrateTransferInitiationsFromV2 string + +//go:embed migrations/9-migrate-pools-from-v2.sql +var migratePoolsFromV2 string + +func registerMigrations(migrator *migrations.Migrator, encryptionKey string) { + migrator.RegisterMigrations( + migrations.Migration{ + Name: "init schema", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + _, err := tx.ExecContext(ctx, initSchema) + return err + }) + }, + }, + migrations.Migration{ + Name: "migrate connectors from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigrateConnectorsFromV2(ctx, db, encryptionKey) + }) + }, + }, + migrations.Migration{ + Name: "migrate accounts events from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigrateAccountEventsFromV2(ctx, db) + }) + }, + }, + migrations.Migration{ + Name: "migrate payments adjustments events from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigratePaymentsAdjustmentsFromV2(ctx, db) + }) + }, + }, + migrations.Migration{ + Name: "migrate payments events from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigratePaymentsFromV2(ctx, db) + }) + }, + }, + migrations.Migration{ + Name: "migrate balances events from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigrateBalancesFromV2(ctx, db) + }) + }, + }, + migrations.Migration{ + Name: "migrate bank accounts from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + _, err := tx.ExecContext(ctx, migrateBankAccountsFromV2) + return err + }) + }, + }, + migrations.Migration{ + Name: "fix missing reference for v2 transfer initiations", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.FixMissingReferenceTransferInitiation(ctx, db) + }) + }, + }, + migrations.Migration{ + Name: "migrate transfer initiations from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + _, err := tx.ExecContext(ctx, migrateTransferInitiationsFromV2) + return err + }) + }, + }, + migrations.Migration{ + Name: "migrate payment initiation adjustments from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigrateTransferInitiationAdjustmentsFromV2(ctx, db) + }) + }, + }, + migrations.Migration{ + Name: "migrate pools from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + _, err := tx.ExecContext(ctx, migratePoolsFromV2) + return err + }) + }, + }, + migrations.Migration{ + Name: "migrate payment initiation reversals from v2", + Up: func(ctx context.Context, db bun.IDB) error { + return db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + return paymentsMigration.MigrateTransferReversalsFromV2(ctx, db) + }) + }, + }, + ) +} + +func getMigrator(db *bun.DB, encryptionKey string, opts ...migrations.Option) *migrations.Migrator { + migrator := migrations.NewMigrator(db, opts...) + registerMigrations(migrator, encryptionKey) + return migrator +} + +func Migrate(ctx context.Context, db bun.IDB, encryptionKey string) error { + d, ok := db.(*bun.DB) + if !ok { + return fmt.Errorf("db of type %T was not of expected *bun.DB type", db) + } + + options := []migrations.Option{ + migrations.WithTableName("goose_db_version_v3"), + } + + return getMigrator(d, encryptionKey, options...).Up(ctx) } diff --git a/internal/storage/migrations/0-init-schema.sql b/internal/storage/migrations/0-init-schema.sql new file mode 100644 index 00000000..b6b01fcc --- /dev/null +++ b/internal/storage/migrations/0-init-schema.sql @@ -0,0 +1,542 @@ +create extension if not exists pgcrypto; + +-- connectors +create table if not exists connectors ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + name text not null, + created_at timestamp without time zone not null, + provider text not null, + scheduled_for_deletion boolean not null default false, + + -- Optional fields + config bytea, + + -- Primary key + primary key (id) +); +create index connectors_created_at_sort_id on connectors (created_at, sort_id); +create unique index connectors_unique_name on connectors (name); + +CREATE OR REPLACE FUNCTION connectors_notify_after_modifications() RETURNS TRIGGER as $$ +BEGIN + IF (TG_OP = 'DELETE') THEN + PERFORM pg_notify('connectors', 'delete_' || OLD.id); + RETURN NULL; + ELSIF (TG_OP = 'INSERT') THEN + PERFORM pg_notify('connectors', 'insert_' || NEW.id); + RETURN NULL; + ELSE + PERFORM pg_notify('connectors', 'update_' || NEW.id); + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER connectors_trigger +AFTER INSERT OR UPDATE OR DELETE ON connectors FOR EACH ROW +EXECUTE PROCEDURE connectors_notify_after_modifications(); + +-- accounts +create table if not exists accounts ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + connector_id varchar not null, + created_at timestamp without time zone not null, + reference text not null, + type text not null, + raw json not null, + + -- Optional fields + default_asset text, + name text, + + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + + -- Primary key + primary key (id) +); +create index accounts_created_at_sort_id on accounts (created_at, sort_id); +alter table accounts + add constraint accounts_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- balances +create table if not exists balances ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + account_id varchar not null, + connector_id varchar not null, + created_at timestamp without time zone not null, + last_updated_at timestamp without time zone not null, + asset text not null, + balance numeric not null, + + -- Primary key + primary key (account_id, created_at, asset) +); +create index balances_created_at_sort_id on balances (created_at, sort_id); +create index balances_account_id_created_at_asset on balances (account_id, last_updated_at desc, asset); +alter table balances + add constraint balances_connector_id foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- bank accounts +create table if not exists bank_accounts ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id uuid not null, + created_at timestamp without time zone not null, + name text not null, + + -- Optional fields + account_number bytea, + iban bytea, + swift_bic_code bytea, + country text, + + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + + -- Primary key + primary key (id) +); +create index bank_accounts_created_at_sort_id on bank_accounts (created_at, sort_id); +create table if not exists bank_accounts_related_accounts ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + bank_account_id uuid not null, + account_id varchar not null, + connector_id varchar not null, + created_at timestamp without time zone not null, + + -- Primary key + primary key (bank_account_id, account_id) +); +create index bank_accounts_related_accounts_created_at_sort_id on bank_accounts_related_accounts (created_at, sort_id); +alter table bank_accounts_related_accounts + add constraint bank_accounts_related_accounts_bank_account_id_fk foreign key (bank_account_id) + references bank_accounts (id) + on delete cascade; +alter table bank_accounts_related_accounts + add constraint bank_accounts_related_accounts_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- payments +create table if not exists payments ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + connector_id varchar not null, + reference text not null, + created_at timestamp without time zone not null, + type text not null, + initial_amount numeric not null, + amount numeric not null, + asset text not null, + scheme text not null, + + -- Optional fields + source_account_id varchar, + destination_account_id varchar, + + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + + -- Primary key + primary key (id) +); +create index payments_created_at_sort_id on payments (created_at, sort_id); +alter table payments + add constraint payments_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- payment adjustments +create table if not exists payment_adjustments ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + payment_id varchar not null, + reference text not null, + created_at timestamp without time zone not null, + status text not null, + raw json not null, + + -- Optional fields + amount numeric, + asset text, + + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + + -- Primary key + primary key (id) +); +create index payment_adjustments_created_at_sort_id on payment_adjustments (created_at, sort_id); +alter table payment_adjustments + add constraint payment_adjustments_payment_id_fk foreign key (payment_id) + references payments (id) + on delete cascade; + +-- pools +create table if not exists pools ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id uuid not null, + name text not null, + created_at timestamp without time zone not null, + + -- Primary key + primary key (id) +); +create index pools_created_at_sort_id on pools (created_at, sort_id); +create unique index pools_unique_name on pools (name); + +create table if not exists pool_accounts ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + pool_id uuid not null, + account_id varchar not null, + connector_id varchar not null, + + -- Primary key + primary key (pool_id, account_id) +); +create unique index pool_accounts_unique_sort_id on pool_accounts (sort_id); +alter table pool_accounts + add constraint pool_accounts_pool_id_fk foreign key (pool_id) + references pools (id) + on delete cascade; + +-- schedules +create table if not exists schedules ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id text not null, + connector_id varchar not null, + created_at timestamp without time zone not null, + + -- Primary key + primary key (id, connector_id) +); +create index schedules_created_at_sort_id on schedules (created_at, sort_id); +alter table schedules + add constraint schedules_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- states +create table if not exists states ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + connector_id varchar not null, + + -- Optional fields with default + state json not null default '{}'::json, + + -- Primary key + primary key (id) +); +create unique index states_unique_sort_id on states (sort_id); +alter table states + add constraint states_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- connector tasks tree +create table if not exists connector_tasks_tree ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + connector_id varchar not null, + tasks json not null, + + -- Primary key + primary key (connector_id) +); +create unique index connector_tasks_tree_unique_sort_id on connector_tasks_tree (sort_id); +alter table connector_tasks_tree + add constraint connector_tasks_tree_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- Workflow instance +create table if not exists workflows_instances ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id text not null, + schedule_id text not null, + connector_id varchar not null, + created_at timestamp without time zone not null, + updated_at timestamp without time zone not null, + + -- Optional fields with default + terminated boolean not null default false, + + -- Optional fields + terminated_at timestamp without time zone, + error text, + + -- Primary key + primary key (id, schedule_id, connector_id) +); +create index workflows_instances_created_at_sort_id on workflows_instances (created_at, sort_id); +alter table workflows_instances + add constraint workflows_instances_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; +alter table workflows_instances + add constraint workflows_instances_schedule_id_fk foreign key (schedule_id, connector_id) + references schedules (id, connector_id) + on delete cascade; + +-- Webhook configs +create table if not exists webhooks_configs ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + name text not null, + connector_id varchar not null, + url_path text not null, + + -- Primary key + primary key (name, connector_id) +); +create unique index webhooks_configs_unique_sort_id on webhooks_configs (sort_id); +alter table webhooks_configs + add constraint webhooks_configs_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- Webhooks +create table if not exists webhooks ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id text not null, + connector_id varchar not null, + + -- Optional fields + headers json, + query_values json, + body bytea, + + -- Primary key + primary key (id) +); +create unique index webhooks_unique_sort_id on webhooks (sort_id); +alter table webhooks + add constraint webhooks_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- Payment Initiations +create table if not exists payment_initiations ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id text not null, + connector_id varchar not null, + reference text not null, + created_at timestamp without time zone not null, + scheduled_at timestamp without time zone not null, + description text not null, + type text not null, + amount numeric not null, + asset text not null, + + -- Optional fields + source_account_id varchar, + destination_account_id varchar, + + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + + -- Primary key + primary key (id) +); +create index payment_initiations_created_at_sort_id on payment_initiations (created_at, sort_id); +alter table payment_initiations + add constraint payment_initiations_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- Payment Initiation Related Payments +create table if not exists payment_initiation_related_payments( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + payment_initiation_id varchar not null, + payment_id varchar not null, + created_at timestamp without time zone not null, + + -- Primary key + primary key (payment_initiation_id, payment_id) +); +create index payment_initiation_related_payments_created_at_sort_id on payment_initiation_related_payments (created_at, sort_id); +alter table payment_initiation_related_payments + add constraint payment_initiation_related_payments_payment_initiation_id_fk foreign key (payment_initiation_id) + references payment_initiations (id) + on delete cascade; + +-- Payment Initiation Adjustments +create table if not exists payment_initiation_adjustments( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + payment_initiation_id varchar not null, + created_at timestamp without time zone not null, + status text not null, + + -- Optional fields + error text, + amount numeric, + asset text, + + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + + -- Primary key + primary key (id) +); +create index payment_initiation_adjustments_created_at_sort_id on payment_initiation_adjustments (created_at, sort_id); +alter table payment_initiation_adjustments + add constraint payment_initiation_adjustments_payment_initiation_id_fk foreign key (payment_initiation_id) + references payment_initiations (id) + on delete cascade; + +-- Payment Initiations reversals +create table if not exists payment_initiation_reversals ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id text not null, + connector_id varchar not null, + payment_initiation_id varchar not null, + reference text not null, + created_at timestamp without time zone not null, + description text not null, + amount numeric not null, + asset text not null, + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + -- Primary key + primary key (id) +); +create index payment_initiation_reversals_created_at_sort_id on payment_initiation_reversals (created_at, sort_id); +alter table payment_initiation_reversals + add constraint payment_initiation_reversals_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; +alter table payment_initiation_reversals + add constraint payment_initiation_reversals_payment_initiation_id_fk foreign key (payment_initiation_id) + references payment_initiations (id) + on delete cascade; +-- Payment Initiation Reversal Adjustments +create table if not exists payment_initiation_reversal_adjustments( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + payment_initiation_reversal_id varchar not null, + created_at timestamp without time zone not null, + status text not null, + -- Optional fields + error text, + -- Optional fields with default + metadata jsonb not null default '{}'::jsonb, + -- Primary key + primary key (id) +); +create index payment_initiation_reversal_adjustments_created_at_sort_id on payment_initiation_reversal_adjustments (created_at, sort_id); +alter table payment_initiation_reversal_adjustments + add constraint payment_initiation_reversal_adjustments_payment_initiation_id_fk foreign key (payment_initiation_reversal_id) + references payment_initiation_reversals (id) + on delete cascade; + +-- Events sent +create table if not exists events_sent ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + sent_at timestamp without time zone not null, + + -- Optional fields + connector_id varchar, + + -- Primary key + primary key (id) +); +create unique index events_sent_unique_sort_id on events_sent (sort_id); +alter table events_sent + add constraint events_sent_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; + +-- tasks +create table if not exists tasks ( + -- Autoincrement fields + sort_id bigserial not null, + + -- Mandatory fields + id varchar not null, + connector_id varchar not null, + status text not null, + created_at timestamp without time zone not null, + updated_at timestamp without time zone not null, + + -- Optional fields + created_object_id varchar, + error text, + + -- Primary key + primary key (id) +); +create index tasks_created_at_sort_id on tasks (created_at, sort_id); +alter table tasks + add constraint tasks_connector_id_fk foreign key (connector_id) + references connectors (id) + on delete cascade; diff --git a/internal/storage/migrations/1-connector-configs.go b/internal/storage/migrations/1-connector-configs.go new file mode 100644 index 00000000..89a530e9 --- /dev/null +++ b/internal/storage/migrations/1-connector-configs.go @@ -0,0 +1,462 @@ +package migrations + +import ( + "encoding/json" +) + +const ( + defaultPageSize = 25 +) + +func transformV2ConfigToV3Config(provider string, v2Config json.RawMessage) (bool, json.RawMessage, error) { + switch provider { + case "ADYEN": + v3Config, err := transformV2AdyenConfigToV3(v2Config) + return true, v3Config, err + case "ATLAR": + v3Config, err := transformV2AtlarConfigToV3(v2Config) + return true, v3Config, err + case "BANKING-CIRCLE": + v3Config, err := transformV2BankingCircleConfigToV3(v2Config) + return true, v3Config, err + case "CURRENCY-CLOUD": + v3Config, err := transformV2CurrencyCloudConfigToV3(v2Config) + return true, v3Config, err + case "GENERIC": + v3Config, err := transformV2GenericConfigToV3(v2Config) + return true, v3Config, err + case "MANGOPAY": + v3Config, err := transformV2MangopayConfigToV3(v2Config) + return true, v3Config, err + case "MODULR": + v3Config, err := transformV2ModulrConfigToV3(v2Config) + return true, v3Config, err + case "MONEYCORP": + v3Config, err := transformV2MoneycorpConfigToV3(v2Config) + return true, v3Config, err + case "STRIPE": + v3Config, err := transformV2StripeConfigToV3(v2Config) + return true, v3Config, err + case "WISE": + v3Config, err := transformV2WiseConfigToV3(v2Config) + return true, v3Config, err + default: + return false, nil, nil + } +} + +func transformV2AdyenConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2AdyenConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3AdyenConfig{ + APIKey: v2.APIKey, + LiveEndpointPrefix: v2.LiveEndpointPrefix, + } + + type res struct { + v3DefaultConfig + v3AdyenConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3AdyenConfig: v3Config, + }) +} + +func transformV2AtlarConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2AtlarConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3AtlarConfig{ + BaseURL: v2.BaseUrl, + AccessKey: v2.AccessKey, + Secret: v2.Secret, + } + + type res struct { + v3DefaultConfig + v3AtlarConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: int(v2.PageSize), + }, + v3AtlarConfig: v3Config, + }) +} + +func transformV2BankingCircleConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2BankingCircleConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3BankingCircleConfig{ + Username: v2.Username, + Password: v2.Password, + Endpoint: v2.Endpoint, + AuthorizationEndpoint: v2.AuthorizationEndpoint, + UserCertificate: v2.UserCertificate, + UserCertificateKey: v2.UserCertificateKey, + } + + type res struct { + v3DefaultConfig + v3BankingCircleConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3BankingCircleConfig: v3Config, + }) +} + +func transformV2CurrencyCloudConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2CurrencyCloudConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3CurrencyCloudConfig{ + LoginID: v2.LoginID, + APIKey: v2.APIKey, + Endpoint: v2.Endpoint, + } + + type res struct { + v3DefaultConfig + v3CurrencyCloudConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3CurrencyCloudConfig: v3Config, + }) +} + +func transformV2GenericConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2GenericConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3GenericConfig{ + APIKey: v2.APIKey, + Endpoint: v2.Endpoint, + } + + type res struct { + v3DefaultConfig + v3GenericConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3GenericConfig: v3Config, + }) +} + +func transformV2MangopayConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2MangopayConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3MangopayConfig{ + ClientID: v2.ClientID, + APIKey: v2.APIKey, + Endpoint: v2.Endpoint, + } + + type res struct { + v3DefaultConfig + v3MangopayConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3MangopayConfig: v3Config, + }) +} + +func transformV2ModulrConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2ModulrConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3ModulrConfig{ + APIKey: v2.APIKey, + APISecret: v2.APISecret, + Endpoint: v2.Endpoint, + } + + type res struct { + v3DefaultConfig + v3ModulrConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: v2.PageSize, + }, + v3ModulrConfig: v3Config, + }) +} + +func transformV2MoneycorpConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2MoneycorpConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3MoneycorpConfig{ + ClientID: v2.ClientID, + APIKey: v2.APIKey, + Endpoint: v2.Endpoint, + } + + type res struct { + v3DefaultConfig + v3MoneycorpConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3MoneycorpConfig: v3Config, + }) +} + +func transformV2StripeConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2StripeConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3StripeConfig{ + APIKey: v2.APIKey, + } + + type res struct { + v3DefaultConfig + v3StripeConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: int(v2.PageSize), + }, + v3StripeConfig: v3Config, + }) +} + +func transformV2WiseConfigToV3(v2Config json.RawMessage) (json.RawMessage, error) { + var v2 v2WiseConfig + if err := json.Unmarshal(v2Config, &v2); err != nil { + return nil, err + } + + v3Config := v3WiseConfig{ + APIKey: v2.APIKey, + } + + type res struct { + v3DefaultConfig + v3WiseConfig + } + + return json.Marshal(res{ + v3DefaultConfig: v3DefaultConfig{ + Name: v2.Name, + PollingPeriod: v2.PollingPeriod.Duration.String(), + PageSize: defaultPageSize, + }, + v3WiseConfig: v3Config, + }) +} + +type v2AdyenConfig struct { + Name string `json:"name"` + APIKey string `json:"apiKey"` + HMACKey string `json:"hmacKey"` + LiveEndpointPrefix string `json:"liveEndpointPrefix"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v2AtlarConfig struct { + Name string `json:"name"` + PollingPeriod v2Duration `json:"pollingPeriod"` + TransferInitiationStatusPollingPeriod v2Duration `json:"transferInitiationStatusPollingPeriod"` + BaseUrl string `json:"baseURL"` + AccessKey string `json:"accessKey"` + Secret string `json:"secret"` + PageSize uint64 `json:"pageSize"` +} + +type v2BankingCircleConfig struct { + Name string `json:"name"` + Username string `json:"username"` + Password string `json:"password"` + Endpoint string `json:"endpoint"` + AuthorizationEndpoint string `json:"authorizationEndpoint"` + UserCertificate string `json:"userCertificate"` + UserCertificateKey string `json:"userCertificateKey"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v2CurrencyCloudConfig struct { + Name string `json:"name"` + LoginID string `json:"loginID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v2GenericConfig struct { + Name string `json:"name"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v2MangopayConfig struct { + Name string `json:"name"` + ClientID string `json:"clientID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v2ModulrConfig struct { + Name string `json:"name"` + APIKey string `json:"apiKey"` + APISecret string `json:"apiSecret"` + Endpoint string `json:"endpoint"` + PollingPeriod v2Duration `json:"pollingPeriod"` + PageSize int `json:"pageSize"` +} + +type v2MoneycorpConfig struct { + Name string `json:"name"` + ClientID string `json:"clientID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v2StripeConfig struct { + Name string `json:"name"` + PollingPeriod v2Duration `json:"pollingPeriod"` + APIKey string `json:"apiKey"` + PageSize uint64 `json:"pageSize"` +} + +type v2WiseConfig struct { + Name string `json:"name"` + APIKey string `json:"apiKey"` + PollingPeriod v2Duration `json:"pollingPeriod"` +} + +type v3DefaultConfig struct { + Name string `json:"name"` + PollingPeriod string `json:"pollingPeriod"` + PageSize int `json:"pageSize"` +} + +type v3AdyenConfig struct { + APIKey string `json:"apiKey"` + WebhookUsername string `json:"webhookUsername"` + WebhookPassword string `json:"webhookPassword"` + CompanyID string `json:"companyID"` + LiveEndpointPrefix string `json:"liveEndpointPrefix"` +} + +type v3AtlarConfig struct { + BaseURL string `json:"baseURL"` + AccessKey string `json:"accessKey"` + Secret string `json:"secret"` +} + +type v3BankingCircleConfig struct { + Username string `json:"username" yaml:"username" ` + Password string `json:"password" yaml:"password" ` + Endpoint string `json:"endpoint" yaml:"endpoint"` + AuthorizationEndpoint string `json:"authorizationEndpoint" yaml:"authorizationEndpoint" ` + UserCertificate string `json:"userCertificate" yaml:"userCertificate" ` + UserCertificateKey string `json:"userCertificateKey" yaml:"userCertificateKey"` +} + +type v3CurrencyCloudConfig struct { + LoginID string `json:"loginID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +type v3GenericConfig struct { + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +type v3MangopayConfig struct { + ClientID string `json:"clientID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +type v3ModulrConfig struct { + APIKey string `json:"apiKey"` + APISecret string `json:"apiSecret"` + Endpoint string `json:"endpoint"` +} + +type v3MoneycorpConfig struct { + ClientID string `json:"clientID"` + APIKey string `json:"apiKey"` + Endpoint string `json:"endpoint"` +} + +type v3StripeConfig struct { + APIKey string `json:"apiKey"` +} + +type v3WiseConfig struct { + APIKey string `json:"apiKey"` + WebhookPublicKey string `json:"webhookPublicKey"` +} diff --git a/internal/storage/migrations/1-migrate-connectors-from-v2.go b/internal/storage/migrations/1-migrate-connectors-from-v2.go new file mode 100644 index 00000000..6a5bf11b --- /dev/null +++ b/internal/storage/migrations/1-migrate-connectors-from-v2.go @@ -0,0 +1,130 @@ +package migrations + +import ( + "context" + "encoding/json" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +const ( + encryptionOptions = "compress-algo=1, cipher-algo=aes256" +) + +type v2Connector struct { + bun.BaseModel `bun:"connectors.connector"` + ID models.ConnectorID `bun:"id,pk,nullzero"` + Name string `bun:"name"` + CreatedAt time.Time `bun:"created_at,nullzero"` + Provider string + // EncryptedConfig is a PGP-encrypted JSON string. + EncryptedConfig string `bun:"config"` + // Config is a decrypted config. It is not stored in the database. + Config json.RawMessage `bun:"decrypted_config,scanonly"` +} + +type v3Connector struct { + bun.BaseModel `bun:"table:connectors"` + ID models.ConnectorID `bun:"id,pk,type:character varying,notnull"` + Name string `bun:"name,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Provider string `bun:"provider,type:text,notnull"` + ScheduledForDeletion bool `bun:"scheduled_for_deletion,type:boolean,notnull"` + EncryptedConfig string `bun:"config,type:bytea,notnull"` + DecryptedConfig json.RawMessage `bun:"decrypted_config,scanonly"` +} + +type ConnectorQuery struct{} + +func MigrateConnectorsFromV2(ctx context.Context, db bun.IDB, encryptionKey string) error { + exist, err := isTableExisting(ctx, db, "connectors", "connector") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE connectors.connector ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[ConnectorQuery]]{ + Order: bunpaginate.OrderAsc, + PageSize: 100, + Options: bunpaginate.PaginatedQueryOptions[ConnectorQuery]{ + PageSize: 100, + Options: ConnectorQuery{}, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[ConnectorQuery], v2Connector]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[ConnectorQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query. + ColumnExpr("*, pgp_sym_decrypt(config, ?, ?) AS decrypted_config", encryptionKey, encryptionOptions). + Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + for _, connector := range cursor.Data { + v3 := v3Connector{ + ID: connector.ID, + Name: connector.Name, + CreatedAt: connector.CreatedAt, + Provider: connector.Provider, + ScheduledForDeletion: false, + } + + shouldInsert, v3Config, err := transformV2ConfigToV3Config(connector.Provider, connector.Config) + if err != nil { + return err + } + + if !shouldInsert { + continue + } + + _, err = db.NewInsert(). + Model(&v3). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + + _, err = db.NewUpdate(). + Model((*v3Connector)(nil)). + Set("config = pgp_sym_encrypt(?::TEXT, ?, ?)", v3Config, encryptionKey, encryptionOptions). + Where("id = ?", v3.ID). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/migrations/10-migrate-payments-reversal.go b/internal/storage/migrations/10-migrate-payments-reversal.go new file mode 100644 index 00000000..8482780e --- /dev/null +++ b/internal/storage/migrations/10-migrate-payments-reversal.go @@ -0,0 +1,184 @@ +package migrations + +import ( + "context" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" + "github.com/uptrace/bun/schema" +) + +type v2TransferReversal struct { + bun.BaseModel `bun:"transfers.transfer_reversal"` + + ID models.PaymentInitiationReversalID `bun:"id,pk"` + TransferInitiationID models.PaymentInitiationID `bun:"transfer_initiation_id"` + + CreatedAt time.Time `bun:"created_at"` + UpdatedAt time.Time `bun:"updated_at"` + Description string `bun:"description"` + + ConnectorID models.ConnectorID `bun:"connector_id"` + + Amount *big.Int `bun:"amount"` + Asset string `bun:"asset"` + + Status models.PaymentInitiationReversalAdjustmentStatus `bun:"status"` + Error string `bun:"error"` + + Metadata map[string]string `bun:"metadata"` +} + +type v3PaymentInitiationReversal struct { + bun.BaseModel `bun:"payment_initiation_reversals"` + + ID models.PaymentInitiationReversalID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + PaymentInitiationID models.PaymentInitiationID `bun:"payment_initiation_id,type:character varying,notnull"` + Reference string `bun:"reference,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Description string `bun:"description,type:text,notnull"` + Amount *big.Int `bun:"amount,type:numeric,notnull"` + Asset string `bun:"asset,type:text,notnull"` + + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +type v3PaymentInitiationReversalAdjustment struct { + bun.BaseModel `bun:"payment_initiation_reversal_adjustments"` + + ID models.PaymentInitiationReversalAdjustmentID `bun:"id,pk,type:character varying,notnull"` + PaymentInitiationReversalID models.PaymentInitiationReversalID `bun:"payment_initiation_reversal_id,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Status models.PaymentInitiationReversalAdjustmentStatus + + Error *string `bun:"error,type:text"` + + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +func MigrateTransferReversalsFromV2(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "transfers", "transfer_reversal") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE transfers.transfer_reversal ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 1000, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 1000, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2TransferReversal]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query.Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + v3Reversals := make([]v3PaymentInitiationReversal, 0, len(cursor.Data)) + v3ReversalAdjustments := make([]v3PaymentInitiationReversalAdjustment, 0) + for _, reversal := range cursor.Data { + reversal.Status++ // needed as we added the unknown status as 0 in v3 + + v3Reversals = append(v3Reversals, v3PaymentInitiationReversal{ + ID: reversal.ID, + ConnectorID: reversal.ConnectorID, + PaymentInitiationID: reversal.TransferInitiationID, + Reference: reversal.ID.Reference, + CreatedAt: reversal.CreatedAt, + Description: reversal.Description, + Amount: reversal.Amount, + Asset: reversal.Asset, + Metadata: reversal.Metadata, + }) + + v3ReversalAdjustments = append(v3ReversalAdjustments, v3PaymentInitiationReversalAdjustment{ + ID: models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reversal.ID, + CreatedAt: reversal.CreatedAt, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, + }, + PaymentInitiationReversalID: reversal.ID, + CreatedAt: reversal.CreatedAt, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, + Metadata: reversal.Metadata, + }) + + if reversal.Status != models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING { + v3ReversalAdjustments = append(v3ReversalAdjustments, v3PaymentInitiationReversalAdjustment{ + BaseModel: schema.BaseModel{}, + ID: models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: reversal.ID, + CreatedAt: reversal.CreatedAt, + Status: reversal.Status, + }, + PaymentInitiationReversalID: reversal.ID, + CreatedAt: reversal.CreatedAt, + Status: reversal.Status, + Error: func() *string { + if reversal.Error == "" { + return nil + } + + return &reversal.Error + }(), + Metadata: reversal.Metadata, + }) + } + } + + if len(v3Reversals) > 0 { + _, err = db.NewInsert(). + Model(&v3Reversals). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if len(v3ReversalAdjustments) > 0 { + _, err = db.NewInsert(). + Model(&v3ReversalAdjustments). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/migrations/2-migrate-accounts-events-from-v2.go b/internal/storage/migrations/2-migrate-accounts-events-from-v2.go new file mode 100644 index 00000000..f8c4968f --- /dev/null +++ b/internal/storage/migrations/2-migrate-accounts-events-from-v2.go @@ -0,0 +1,90 @@ +package migrations + +import ( + "context" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type v2Accounts struct { + bun.BaseModel `bun:"accounts.account"` + + ID models.AccountID `bun:"id,pk,type:character varying,nullzero"` + CreatedAt time.Time `bun:"created_at,type:timestamp with time zone,notnull"` +} + +func MigrateAccountEventsFromV2(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "accounts", "account") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 1000, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 1000, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2Accounts]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query.Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + events := make([]v3eventSent, 0, len(cursor.Data)) + for _, account := range cursor.Data { + events = append(events, v3eventSent{ + ID: models.EventID{ + EventIdempotencyKey: account.ID.String(), + ConnectorID: &account.ID.ConnectorID, + }, + ConnectorID: &account.ID.ConnectorID, + SentAt: account.CreatedAt, + }) + } + + if len(events) > 0 { + _, err = db.NewInsert(). + Model(&events). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/migrations/3-migrate-payments-events-from-v2.go b/internal/storage/migrations/3-migrate-payments-events-from-v2.go new file mode 100644 index 00000000..6fe6ecaf --- /dev/null +++ b/internal/storage/migrations/3-migrate-payments-events-from-v2.go @@ -0,0 +1,195 @@ +package migrations + +import ( + "context" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +type v2Payment struct { + bun.BaseModel `bun:"payments.payment"` + + ID models.PaymentID `bun:"id,pk,type:character varying"` + ConnectorID models.ConnectorID `bun:"connector_id"` + CreatedAt time.Time `bun:"created_at"` + Reference string `bun:"reference"` + Status models.PaymentStatus `bun:"status"` +} + +type v2PaymentAdjustments struct { + bun.BaseModel `bun:"payments.adjustment"` + + ID uuid.UUID `bun:"id,pk,nullzero"` + PaymentID models.PaymentID `bun:"payment_id,pk,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero"` + Reference string `bun:"reference"` + Amount *big.Int `bun:"amount"` + Status models.PaymentStatus `bun:"status"` +} + +func MigratePaymentsAdjustmentsFromV2(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "payments", "adjustment") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE payments.adjustment ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 100, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 100, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2PaymentAdjustments]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query.Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + events := make([]v3eventSent, 0, len(cursor.Data)) + for _, adjustment := range cursor.Data { + events = append(events, v3eventSent{ + ID: models.EventID{ + EventIdempotencyKey: models.PaymentAdjustmentID{ + PaymentID: adjustment.PaymentID, + Reference: adjustment.Reference, + CreatedAt: adjustment.CreatedAt, + Status: adjustment.Status, + }.String(), + ConnectorID: &adjustment.PaymentID.ConnectorID, + }, + ConnectorID: &adjustment.PaymentID.ConnectorID, + SentAt: adjustment.CreatedAt, + }) + } + + if len(events) > 0 { + _, err = db.NewInsert(). + Model(&events). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} + +// We also have to migrate the payments table from v2 to v3 because some +// connectors do not use the adjustments table. +func MigratePaymentsFromV2(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "payments", "payment") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE payments.payment ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 1000, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 1000, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2Payment]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query. + Column("id", "connector_id", "created_at", "reference", "status"). + Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + events := make([]v3eventSent, 0, len(cursor.Data)) + for _, payment := range cursor.Data { + events = append(events, v3eventSent{ + ID: models.EventID{ + EventIdempotencyKey: models.PaymentAdjustmentID{ + PaymentID: payment.ID, + Reference: payment.Reference, + CreatedAt: payment.CreatedAt, + Status: payment.Status, + }.String(), + ConnectorID: &payment.ConnectorID, + }, + ConnectorID: &payment.ConnectorID, + SentAt: payment.CreatedAt, + }) + } + + if len(events) > 0 { + _, err = db.NewInsert(). + Model(&events). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} + +type PaymentType string diff --git a/internal/storage/migrations/4-migrate-balances-events-from-v2.go b/internal/storage/migrations/4-migrate-balances-events-from-v2.go new file mode 100644 index 00000000..c537f5d7 --- /dev/null +++ b/internal/storage/migrations/4-migrate-balances-events-from-v2.go @@ -0,0 +1,102 @@ +package migrations + +import ( + "context" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type v2Balance struct { + bun.BaseModel `bun:"accounts.balances"` + + AccountID models.AccountID `bun:"account_id"` + Asset string `bun:"currency"` + Balance *big.Int `bun:"balance"` + CreatedAt time.Time `bun:"created_at"` + LastUpdatedAt time.Time `bun:"last_updated_at"` +} + +func MigrateBalancesFromV2(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "accounts", "balances") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE accounts.balances ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 100, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 100, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2Balance]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query.Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + events := make([]v3eventSent, 0, len(cursor.Data)) + for _, balance := range cursor.Data { + b := models.Balance{ + AccountID: balance.AccountID, + CreatedAt: balance.CreatedAt, + LastUpdatedAt: balance.LastUpdatedAt, + Asset: balance.Asset, + Balance: balance.Balance, + } + + events = append(events, v3eventSent{ + ID: models.EventID{ + EventIdempotencyKey: b.IdempotencyKey(), + ConnectorID: &balance.AccountID.ConnectorID, + }, + ConnectorID: &balance.AccountID.ConnectorID, + SentAt: balance.LastUpdatedAt, + }) + } + + if len(events) > 0 { + _, err = db.NewInsert(). + Model(&events). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/migrations/5-migrate-bank-accounts-from-v2.sql b/internal/storage/migrations/5-migrate-bank-accounts-from-v2.sql new file mode 100644 index 00000000..7e42d1f7 --- /dev/null +++ b/internal/storage/migrations/5-migrate-bank-accounts-from-v2.sql @@ -0,0 +1,21 @@ +DO $$ + BEGIN + IF (SELECT count(*) FROM information_schema.tables WHERE table_schema ='accounts' AND table_name ='bank_account') > 0 + THEN + INSERT INTO bank_accounts (id, created_at, name, country, account_number, iban, swift_bic_code, metadata) + SELECT id, created_at, name, country, account_number, iban, swift_bic_code, metadata from accounts.bank_account + On CONFLICT (id) DO NOTHING; + END IF; + END; +$$; + +DO $$ + BEGIN + IF (SELECT count(*) FROM information_schema.tables WHERE table_schema ='accounts' AND table_name ='bank_account_related_accounts') > 0 + THEN + INSERT INTO bank_accounts_related_accounts (bank_account_id, account_id, connector_id, created_at) + SELECT bank_account_id, account_id, connector_id, created_at from accounts.bank_account_related_accounts + ON CONFLICT (bank_account_id, account_id) DO NOTHING; + END IF; + END; +$$; \ No newline at end of file diff --git a/internal/storage/migrations/6-add-missing-reference-transfer-initiations-for-v2.go b/internal/storage/migrations/6-add-missing-reference-transfer-initiations-for-v2.go new file mode 100644 index 00000000..344d1f1a --- /dev/null +++ b/internal/storage/migrations/6-add-missing-reference-transfer-initiations-for-v2.go @@ -0,0 +1,89 @@ +package migrations + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type v2TransferInitiation struct { + bun.BaseModel `bun:"transfers.transfer_initiation"` + + ID models.PaymentInitiationID `bun:"id"` + Reference string `bun:"reference"` +} + +func FixMissingReferenceTransferInitiation(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "transfers", "transfer_initiation") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE transfers.transfer_initiation ADD COLUMN IF NOT EXISTS reference text; + `) + if err != nil { + return err + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE transfers.transfer_initiation ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 1000, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 1000, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2TransferInitiation]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query. + Column("id"). + Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + for i := range cursor.Data { + cursor.Data[i].Reference = cursor.Data[i].ID.Reference + + _, err = db.NewUpdate(). + Model(&cursor.Data[i]). + Set("reference = ?", cursor.Data[i].Reference). + Where("id = ?", cursor.Data[i].ID). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/migrations/7-migrate-transfer-initiations-from-v2.sql b/internal/storage/migrations/7-migrate-transfer-initiations-from-v2.sql new file mode 100644 index 00000000..f9674833 --- /dev/null +++ b/internal/storage/migrations/7-migrate-transfer-initiations-from-v2.sql @@ -0,0 +1,21 @@ +DO $$ + BEGIN + IF (SELECT count(*) FROM information_schema.tables WHERE table_schema ='transfers' AND table_name ='transfer_initiation') > 0 + THEN + INSERT INTO payment_initiations (id, connector_id, reference, created_at, scheduled_at, description, type, amount, asset, source_account_id, destination_account_id, metadata) + SELECT id, connector_id, reference, created_at, COALESCE(scheduled_at, '0001-01-01 00:00:00+00'::timestamp without time zone) as scheduled_at, description, type+1 as type, amount, asset, source_account_id, destination_account_id, metadata from transfers.transfer_initiation + ON CONFLICT (id) DO NOTHING; + END IF; + END; +$$; + +DO $$ + BEGIN + IF (SELECT count(*) FROM information_schema.tables WHERE table_schema ='transfers' AND table_name ='transfer_initiation_payments') > 0 + THEN + INSERT INTO payment_initiation_related_payments (payment_initiation_id, payment_id, created_at) + SELECT transfer_initiation_id, payment_id, created_at from transfers.transfer_initiation_payments + ON CONFLICT (payment_initiation_id, payment_id) DO NOTHING; + END IF; + END; +$$; \ No newline at end of file diff --git a/internal/storage/migrations/8-migrate-transfer-initiation-adjustments-from-v2.go b/internal/storage/migrations/8-migrate-transfer-initiation-adjustments-from-v2.go new file mode 100644 index 00000000..f9d1790c --- /dev/null +++ b/internal/storage/migrations/8-migrate-transfer-initiation-adjustments-from-v2.go @@ -0,0 +1,136 @@ +package migrations + +import ( + "context" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +type v2TransferInitiationAdjustment struct { + bun.BaseModel `bun:"transfers.transfer_initiation_adjustments"` + + ID uuid.UUID `bun:"id,pk"` + TransferInitiationID models.PaymentInitiationID `bun:"transfer_initiation_id"` + CreatedAt time.Time `bun:"created_at,nullzero"` + Status string `bun:"status"` + Error string `bun:"error"` + Metadata map[string]string `bun:"metadata"` +} + +type v3PaymentInitiationAdjustment struct { + bun.BaseModel `bun:"payment_initiation_adjustments"` + + // Mandatory fields + ID models.PaymentInitiationAdjustmentID `bun:"id,pk,type:character varying,notnull"` + PaymentInitiationID models.PaymentInitiationID `bun:"payment_initiation_id,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Status models.PaymentInitiationAdjustmentStatus `bun:"status,type:text,notnull"` + + // Optional fields + Error *string `bun:"error,type:text"` + Amount *big.Int `bun:"amount,type:numeric"` + Asset *string `bun:"asset,type:text"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +func MigrateTransferInitiationAdjustmentsFromV2(ctx context.Context, db bun.IDB) error { + exist, err := isTableExisting(ctx, db, "transfers", "transfer_initiation_adjustments") + if err != nil { + return err + } + + if !exist { + // Nothing to migrate + return nil + } + + _, err = db.ExecContext(ctx, ` + ALTER TABLE transfers.transfer_initiation_adjustments ADD COLUMN IF NOT EXISTS sort_id bigserial; + `) + if err != nil { + return err + } + + q := bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]]{ + Order: bunpaginate.OrderAsc, + PageSize: 1000, + Options: bunpaginate.PaginatedQueryOptions[any]{ + PageSize: 1000, + }, + } + for { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[any], v2TransferInitiationAdjustment]( + ctx, + db, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[any]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + return query.Order("created_at ASC", "sort_id ASC") + }, + ) + if err != nil { + return err + } + + v3Adjs := make([]v3PaymentInitiationAdjustment, 0, len(cursor.Data)) + for _, adjustment := range cursor.Data { + status, err := models.PaymentInitiationAdjustmentStatusFromString(adjustment.Status) + if err != nil { + // Some status disappeared in v3, let's skip them + continue + } + + amount := big.NewInt(0) + asset := "" + err = db.NewRaw(`SELECT amount, asset + FROM transfers.transfer_initiation WHERE id = ?`, adjustment.TransferInitiationID). + Scan(ctx, &amount, &asset) + if err != nil { + return err + } + + v3Adjs = append(v3Adjs, v3PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: adjustment.TransferInitiationID, + CreatedAt: adjustment.CreatedAt, + Status: status, + }, + PaymentInitiationID: adjustment.TransferInitiationID, + CreatedAt: adjustment.CreatedAt, + Status: status, + Error: &adjustment.Error, + Amount: amount, + Asset: &asset, + Metadata: adjustment.Metadata, + }) + } + + if len(v3Adjs) > 0 { + _, err = db.NewInsert(). + Model(&v3Adjs). + On("conflict (id) do nothing"). + Exec(ctx) + if err != nil { + return err + } + } + + if !cursor.HasMore { + break + } + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/migrations/9-migrate-pools-from-v2.sql b/internal/storage/migrations/9-migrate-pools-from-v2.sql new file mode 100644 index 00000000..eb5e7f77 --- /dev/null +++ b/internal/storage/migrations/9-migrate-pools-from-v2.sql @@ -0,0 +1,24 @@ +DO $$ + BEGIN + IF (SELECT count(*) FROM information_schema.tables WHERE table_schema ='accounts' AND table_name ='pools') > 0 + THEN + INSERT INTO pools (id, name, created_at) + SELECT id, name, created_at FROM accounts.pools + ON CONFLICT (id) DO NOTHING; + END IF; + END; +$$; + +DO $$ + BEGIN + IF (SELECT count(*) FROM information_schema.tables WHERE table_schema ='accounts' AND table_name ='pool_accounts') > 0 + THEN + INSERT INTO pool_accounts (pool_id, account_id, connector_id) + SELECT pool_accounts.pool_id, pool_accounts.account_id, account.connector_id + FROM accounts.pool_accounts + JOIN accounts.account + ON pool_accounts.account_id = account.id + ON CONFLICT (pool_id, account_id) DO NOTHING; + END IF; + END; +$$; \ No newline at end of file diff --git a/internal/storage/migrations/utils.go b/internal/storage/migrations/utils.go new file mode 100644 index 00000000..3d10687f --- /dev/null +++ b/internal/storage/migrations/utils.go @@ -0,0 +1,77 @@ +package migrations + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type v3eventSent struct { + bun.BaseModel `bun:"table:events_sent"` + + ID models.EventID `bun:"id,pk,type:character varying,notnull"` + ConnectorID *models.ConnectorID `bun:"connector_id,type:character varying"` + SentAt time.Time `bun:"sent_at,type:timestamp without time zone,notnull"` +} + +func isTableExisting(ctx context.Context, db bun.IDB, schema, table string) (bool, error) { + var count int + err := db.NewRaw(fmt.Sprintf(`SELECT count(*) + FROM information_schema.tables + WHERE table_schema ='%s' AND table_name ='%s' + `, schema, table)).Scan(ctx, &count) + if err != nil { + return false, err + } + + return count > 0, nil +} + +type v2Duration struct { + time.Duration `json:"duration"` +} + +func (d *v2Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration.String()) +} + +func (d *v2Duration) UnmarshalJSON(b []byte) error { + var rawValue any + + if err := json.Unmarshal(b, &rawValue); err != nil { + return fmt.Errorf("custom Duration UnmarshalJSON: %w", err) + } + + switch value := rawValue.(type) { + case string: + var err error + d.Duration, err = time.ParseDuration(value) + if err != nil { + return fmt.Errorf("custom Duration UnmarshalJSON: time.ParseDuration: %w", err) + } + + return nil + case map[string]interface{}: + switch val := value["duration"].(type) { + case float64: + d.Duration = time.Duration(int64(val)) + + return nil + default: + return fmt.Errorf("custom Duration UnmarshalJSON from map: invalid type: value:%v, type:%T", val, val) + } + default: + return fmt.Errorf("custom Duration UnmarshalJSON: invalid type: value:%v, type:%T", value, value) + } +} + +func paginateWithOffset[FILTERS any, RETURN any](ctx context.Context, db bun.IDB, + q *bunpaginate.OffsetPaginatedQuery[FILTERS], builders ...func(query *bun.SelectQuery) *bun.SelectQuery) (*bunpaginate.Cursor[RETURN], error) { + query := db.NewSelect() + return bunpaginate.UsingOffset[FILTERS, RETURN](ctx, query, *q, builders...) +} diff --git a/internal/storage/migrations_v0.x.go b/internal/storage/migrations_v0.x.go deleted file mode 100644 index 29a890ba..00000000 --- a/internal/storage/migrations_v0.x.go +++ /dev/null @@ -1,625 +0,0 @@ -package storage - -import ( - "fmt" - - "github.com/formancehq/go-libs/migrations" - "github.com/pkg/errors" - "github.com/uptrace/bun" -) - -func registerMigrationsV0(migrator *migrations.Migrator) { - migrator.RegisterMigrations( - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE SCHEMA IF NOT EXISTS connectors; - CREATE SCHEMA IF NOT EXISTS tasks; - CREATE SCHEMA IF NOT EXISTS accounts; - CREATE SCHEMA IF NOT EXISTS payments; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TYPE "public".connector_provider AS ENUM ('BANKING-CIRCLE', 'CURRENCY-CLOUD', 'DUMMY-PAY', 'MODULR', 'STRIPE', 'WISE');; - CREATE TABLE connectors.connector ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - provider connector_provider NOT NULL UNIQUE, - enabled boolean NOT NULL DEFAULT false, - config json NULL, - CONSTRAINT connector_pk PRIMARY KEY (id) - ); - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TYPE "public".task_status AS ENUM ('STOPPED', 'PENDING', 'ACTIVE', 'TERMINATED', 'FAILED');; - CREATE TABLE tasks.task ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - connector_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - updated_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=updated_at), - name text NOT NULL, - descriptor json NULL, - status task_status NOT NULL, - error text NULL, - state json NULL, - CONSTRAINT task_pk PRIMARY KEY (id) - ); - ALTER TABLE tasks.task ADD CONSTRAINT task_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TYPE "public".account_type AS ENUM('SOURCE', 'TARGET', 'UNKNOWN');; - - CREATE TABLE accounts.account ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - reference text NOT NULL UNIQUE, - provider text NOT NULL, - type account_type NOT NULL, - CONSTRAINT account_pk PRIMARY KEY (id) - ); - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TYPE "public".payment_type AS ENUM ('PAY-IN', 'PAYOUT', 'TRANSFER', 'OTHER'); - CREATE TYPE "public".payment_status AS ENUM ('SUCCEEDED', 'CANCELLED', 'FAILED', 'PENDING', 'OTHER');; - - CREATE TABLE payments.adjustment ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - payment_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - amount bigint NOT NULL DEFAULT 0, - reference text NOT NULL UNIQUE, - status payment_status NOT NULL, - absolute boolean NOT NULL DEFAULT FALSE, - raw_data json NULL, - CONSTRAINT adjustment_pk PRIMARY KEY (id) - ); - - CREATE TABLE payments.metadata ( - payment_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - key text NOT NULL, - value text NOT NULL, - changelog jsonb NOT NULL, - CONSTRAINT metadata_pk PRIMARY KEY (payment_id,key) - ); - - CREATE TABLE payments.payment ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - connector_id uuid NOT NULL, - account_id uuid DEFAULT NULL, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - reference text NOT NULL UNIQUE, - type payment_type NOT NULL, - status payment_status NOT NULL, - amount bigint NOT NULL DEFAULT 0, - raw_data json NULL, - scheme text NOT NULL, - asset text NOT NULL, - CONSTRAINT payment_pk PRIMARY KEY (id) - ); - - ALTER TABLE payments.adjustment ADD CONSTRAINT adjustment_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.metadata ADD CONSTRAINT metadata_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.payment ADD CONSTRAINT payment_account - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.payment ADD CONSTRAINT payment_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - //nolint:varnamelen - migrations.Migration{ - Up: func(tx bun.Tx) error { - var exists bool - - err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM connectors.connector)").Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check if connectors table exists: %w", err) - } - - if exists && EncryptionKey == "" { - return errors.New("encryption key is not set") - } - - _, err = tx.Exec(` - CREATE EXTENSION IF NOT EXISTS pgcrypto; - ALTER TABLE connectors.connector RENAME COLUMN config TO config_unencrypted; - ALTER TABLE connectors.connector ADD COLUMN config bytea NULL; - `) - if err != nil { - return fmt.Errorf("failed to create config column: %w", err) - } - - _, err = tx.Exec(` - UPDATE connectors.connector SET config = pgp_sym_encrypt(config_unencrypted::TEXT, ?, 'compress-algo=1, cipher-algo=aes256'); - `, EncryptionKey) - if err != nil { - return fmt.Errorf("failed to encrypt config: %w", err) - } - - _, err = tx.Exec(` - ALTER TABLE connectors.connector DROP COLUMN config_unencrypted; - `) - if err != nil { - return fmt.Errorf("failed to drop config_unencrypted column: %w", err) - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TYPE "public".transfer_status AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED'); - - CREATE TABLE payments.transfers ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - connector_id uuid NOT NULL, - payment_id uuid NULL, - reference text UNIQUE, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - amount bigint NOT NULL DEFAULT 0, - currency text NOT NULL, - source text NOT NULL, - destination text NOT NULL, - status transfer_status NOT NULL DEFAULT 'PENDING', - error text NULL, - CONSTRAINT transfer_pk PRIMARY KEY (id) - ); - - ALTER TABLE payments.transfers ADD CONSTRAINT transfer_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.transfers ADD CONSTRAINT transfer_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE payments.payment ALTER COLUMN id DROP DEFAULT; - - ALTER TABLE payments.adjustment drop constraint IF EXISTS adjustment_payment; - ALTER TABLE payments.metadata drop constraint IF EXISTS metadata_payment; - ALTER TABLE payments.transfers drop constraint IF EXISTS transfer_payment; - ALTER TABLE payments.payment ALTER COLUMN id TYPE CHARACTER VARYING; - ALTER TABLE payments.adjustment ALTER COLUMN payment_id TYPE CHARACTER VARYING; - ALTER TABLE payments.metadata ALTER COLUMN payment_id TYPE CHARACTER VARYING; - ALTER TABLE payments.transfers ALTER COLUMN payment_id TYPE CHARACTER VARYING; - - ALTER TABLE payments.metadata ADD CONSTRAINT metadata_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.adjustment ADD CONSTRAINT adjustment_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'MANGOPAY'; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'MONEYCORP'; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE tasks.task ADD COLUMN IF NOT EXISTS "scheduler_options" json; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.account DROP COLUMN IF EXISTS "type"; - DROP TYPE IF EXISTS "public".account_type; - CREATE TYPE "public".account_type AS ENUM('INTERNAL', 'EXTERNAL', 'UNKNOWN');; - ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS "type" "public".account_type; - - ALTER TABLE accounts.account drop constraint IF EXISTS account_reference_key; - ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS "raw_data" json; - ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS "default_currency" text NOT NULL DEFAULT ''; - ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS "account_name" text NOT NULL DEFAULT ''; - - ALTER TABLE accounts.account ALTER COLUMN id DROP DEFAULT; - ALTER TABLE payments.payment DROP CONSTRAINT IF EXISTS payment_account; - ALTER TABLE payments.payment DROP COLUMN IF EXISTS "account_id"; - ALTER TABLE payments.payment ADD COLUMN IF NOT EXISTS "source_account_id" CHARACTER VARYING; - ALTER TABLE payments.payment ADD COLUMN IF NOT EXISTS "destination_account_id" CHARACTER VARYING; - ALTER TABLE accounts.account ALTER COLUMN id TYPE CHARACTER VARYING; - - ALTER TABLE payments.payment DROP CONSTRAINT IF EXISTS payment_source_account; - ALTER TABLE payments.payment ADD CONSTRAINT payment_source_account - FOREIGN KEY (source_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.payment DROP CONSTRAINT IF EXISTS payment_destination_account; - ALTER TABLE payments.payment ADD CONSTRAINT payment_destination_account - FOREIGN KEY (destination_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - // Since only one connector is inserting accounts, - // let's just delete the table, since connectors will be - // resetted. Delete it cascade, or we will have an error - _, err := tx.Exec(` - DELETE FROM accounts.account CASCADE; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - // Since only one connector is inserting accounts, - // let's just delete the table, since connectors will be - // resetted. Delete it cascade, or we will have an error - _, err := tx.Exec(` - CREATE TABLE IF NOT EXISTS accounts.balances ( - created_at timestamp with time zone NOT NULL, - account_id CHARACTER VARYING NOT NULL, - currency text NOT NULL, - balance numeric NOT NULL DEFAULT 0, - last_updated_at timestamp with time zone NOT NULL, - PRIMARY KEY (account_id, created_at, currency) - ); - - DROP INDEX IF EXISTS accounts.idx_created_at_account_id_currency; - CREATE INDEX idx_created_at_account_id_currency ON accounts.balances(account_id, last_updated_at desc, currency); - - ALTER TABLE accounts.balances DROP CONSTRAINT IF EXISTS balances_account; - ALTER TABLE accounts.balances ADD CONSTRAINT balances_account - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE payments.adjustment ALTER COLUMN amount TYPE numeric; - ALTER TABLE payments.payment ALTER COLUMN amount TYPE numeric; - ALTER TABLE payments.transfers ALTER COLUMN amount TYPE numeric; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - // In this migration, we have to delete the accounts table since - // we wanna reset the connector, but the connector_id was not - // added, hence the table will not be cleaned up when resetting. - _, err := tx.Exec(` - DELETE FROM accounts.account CASCADE; - ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS "connector_id" uuid; - - ALTER TABLE accounts.account ADD CONSTRAINT accounts_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.account ADD COLUMN IF NOT EXISTS metadata jsonb; - - CREATE SCHEMA IF NOT EXISTS transfers; - - CREATE TABLE IF NOT EXISTS transfers.transfer_initiation ( - id character varying NOT NULL, - reference text, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - description text NOT NULL, - type int NOT NULL, - source_account_id character varying NOT NULL, - destination_account_id character varying NOT NULL, - provider connector_provider NOT NULL, - amount numeric NOT NULL, - asset text NOT NULL, - status int NOT NULL, - error text, - PRIMARY KEY (id) - ); - - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT source_account - FOREIGN KEY (source_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT destination_account - FOREIGN KEY (destination_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation ALTER COLUMN source_account_id DROP NOT NULL; - ALTER TABLE transfers.transfer_initiation RENAME COLUMN reference TO payment_id; - ALTER TYPE "public".account_type ADD VALUE IF NOT EXISTS 'EXTERNAL_FORMANCE'; - ALTER TABLE transfers.transfer_initiation ADD COLUMN attempts int NOT NULL DEFAULT 0; - - CREATE TABLE IF NOT EXISTS accounts.bank_account ( - id uuid NOT NULL DEFAULT gen_random_uuid(), - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - name text NOT NULL, - provider connector_provider NOT NULL, - account_number bytea, - iban bytea, - swift_bic_code bytea, - country text, - CONSTRAINT bank_account_pk PRIMARY KEY (id) - ); - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation DROP COLUMN payment_id; - - CREATE TABLE IF NOT EXISTS transfers.transfer_initiation_payments ( - transfer_initiation_id CHARACTER VARYING NOT NULL, - payment_id CHARACTER VARYING NOT NULL, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - status int NOT NULL, - error text, - PRIMARY KEY (transfer_initiation_id, payment_id) - ); - - ALTER TABLE transfers.transfer_initiation_payments ADD CONSTRAINT transfer_initiation_id_constraint - FOREIGN KEY (transfer_initiation_id) - REFERENCES transfers.transfer_initiation (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.bank_account ADD COLUMN account_id CHARACTER VARYING; - - ALTER TABLE accounts.bank_account ADD CONSTRAINT bank_account_account_id - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation ADD COLUMN scheduled_at timestamp with time zone; - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.bank_account ADD COLUMN IF NOT EXISTS "connector_id" uuid; - - ALTER TABLE accounts.bank_account ADD CONSTRAINT bank_accounts_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - ) -} diff --git a/internal/storage/migrations_v1.x.go b/internal/storage/migrations_v1.x.go deleted file mode 100644 index 2b9e50a9..00000000 --- a/internal/storage/migrations_v1.x.go +++ /dev/null @@ -1,1090 +0,0 @@ -package storage - -import ( - "context" - "database/sql/driver" - "encoding/base64" - "encoding/json" - "fmt" - "time" - - "github.com/formancehq/go-libs/migrations" - "github.com/formancehq/payments/internal/models" - "github.com/gibson042/canonicaljson-go" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/uptrace/bun" -) - -func registerMigrationsV1(ctx context.Context, migrator *migrations.Migrator) { - migrator.RegisterMigrations( - migrations.Migration{ - Name: "", - Up: func(tx bun.Tx) error { - if err := migrateConnectors(ctx, tx); err != nil { - return err - } - - if err := migrateAccountID(ctx, tx); err != nil { - return err - } - - if err := migratePaymentID(ctx, tx); err != nil { - return err - } - - if err := migrateTransferInitiationID(ctx, tx); err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'ATLAR'; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TABLE IF NOT EXISTS accounts.pool_accounts ( - pool_id uuid NOT NULL, - account_id CHARACTER VARYING NOT NULL, - CONSTRAINT pool_accounts_pk PRIMARY KEY (pool_id, account_id) - ); - - CREATE TABLE IF NOT EXISTS accounts.pools ( - id uuid NOT NULL, - name text NOT NULL UNIQUE, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - CONSTRAINT pools_pk PRIMARY KEY (id) - ); - - ALTER TABLE accounts.pool_accounts ADD CONSTRAINT pool_accounts_pool_id - FOREIGN KEY (pool_id) - REFERENCES accounts.pools (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.pool_accounts ADD CONSTRAINT pool_accounts_account_id - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'ADYEN'; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.pools ALTER COLUMN id SET DEFAULT gen_random_uuid(); - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.bank_account ADD COLUMN IF NOT EXISTS metadata jsonb; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE payments.payment ADD COLUMN IF NOT EXISTS initial_amount numeric NOT NULL DEFAULT 0; - UPDATE payments.payment SET initial_amount = amount; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'EXPIRED'; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'REFUNDED'; - - CREATE TABLE IF NOT EXISTS connectors.webhook ( - id uuid NOT NULL, - connector_id CHARACTER VARYING NOT NULL, - request_body bytea NOT NULL, - CONSTRAINT webhook_pk PRIMARY KEY (id) - ); - - ALTER TABLE connectors.webhook DROP CONSTRAINT IF EXISTS webhook_connector_id; - ALTER TABLE connectors.webhook ADD CONSTRAINT webhook_connector_id - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation ADD COLUMN IF NOT EXISTS metadata jsonb; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation ALTER COLUMN description DROP NOT NULL; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TABLE IF NOT EXISTS transfers.transfer_initiation_adjustments ( - id uuid NOT NULL, - transfer_initiation_id CHARACTER VARYING NOT NULL, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - status int NOT NULL, - error text, - metadata jsonb, - CONSTRAINT transfer_initiation_adjustments_pk PRIMARY KEY (id) - ); - - ALTER TABLE transfers.transfer_initiation_adjustments ADD CONSTRAINT adjusmtents_transfer_initiation_id_constraint - FOREIGN KEY (transfer_initiation_id) - REFERENCES transfers.transfer_initiation (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - INSERT INTO transfers.transfer_initiation_adjustments (id, transfer_initiation_id, created_at, status, error, metadata) - SELECT gen_random_uuid(), id, updated_at, status, error, '{}'::jsonb FROM transfers.transfer_initiation; - - ALTER TABLE transfers.transfer_initiation DROP COLUMN IF EXISTS status; - ALTER TABLE transfers.transfer_initiation DROP COLUMN IF EXISTS error; - ALTER TABLE transfers.transfer_initiation DROP COLUMN IF EXISTS updated_at; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - // Drop check constraint on created at since it's created by the code and - // not by the user. - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation_adjustments DROP CONSTRAINT transfer_initiation_adjustments_created_at_check; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - // Drop check constraint on created at since it's created by the code and - // not by the user. - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation_payments DROP CONSTRAINT transfer_initiation_payments_created_at_check; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TABLE IF NOT EXISTS transfers.transfer_reversal ( - id character varying NOT NULL, - transfer_initiation_id character varying NOT NULL, - reference text, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - description text NOT NULL, - connector_id CHARACTER VARYING NOT NULL, - amount numeric NOT NULL, - asset text NOT NULL, - status int NOT NULL, - error text, - metadata jsonb, - PRIMARY KEY (id) - ); - - -- UNIQUE constrait for processing only one reversal at a time. - CREATE UNIQUE INDEX transfer_reversal_processing_unique_constraint ON transfers.transfer_reversal (transfer_initiation_id) WHERE status = 1; - - ALTER TABLE transfers.transfer_reversal ADD CONSTRAINT transfer_reversal_connector_id - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE transfers.transfer_reversal ADD CONSTRAINT transfer_reversal_transfer_initiation_id - FOREIGN KEY (transfer_initiation_id) - REFERENCES transfers.transfer_initiation (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE transfers.transfer_initiation ADD COLUMN IF NOT EXISTS initial_amount numeric NOT NULL DEFAULT 0; - UPDATE transfers.transfer_initiation SET initial_amount = amount; - - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT amount_non_negative CHECK (amount >= 0); - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT initial_amount_non_negative CHECK (initial_amount >= 0); - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'EXPIRED'; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'REFUNDED'; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'REFUNDED_FAILURE'; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'DISPUTE'; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'DISPUTE_WON'; - ALTER TYPE "public".payment_status ADD VALUE IF NOT EXISTS 'DISPUTE_LOST'; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - CREATE TABLE IF NOT EXISTS accounts.bank_account_adjustments ( - id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - bank_account_id uuid NOT NULL, - connector_id CHARACTER VARYING NOT NULL, - account_id CHARACTER VARYING NOT NULL, - CONSTRAINT transfer_initiation_adjustments_pk PRIMARY KEY (id) - ); - - ALTER TABLE accounts.bank_account_adjustments ADD CONSTRAINT bank_account_adjustments_bank_account_id - FOREIGN KEY (bank_account_id) - REFERENCES accounts.bank_account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.bank_account_adjustments ADD CONSTRAINT bank_account_adjustments_connector_id - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.bank_account_adjustments ADD CONSTRAINT bank_account_adjustments_account_id - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - INSERT INTO accounts.bank_account_adjustments (id, created_at, bank_account_id, connector_id, account_id) - SELECT gen_random_uuid(), created_at, id, connector_id, account_id FROM accounts.bank_account WHERE account_id IS NOT NULL; - - ALTER TABLE accounts.bank_account DROP COLUMN IF EXISTS account_id; - ALTER TABLE accounts.bank_account DROP COLUMN IF EXISTS connector_id; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.bank_account_adjustments RENAME TO bank_account_related_accounts; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'GENERIC'; - `) - if err != nil { - return err - } - - return nil - }, - }, - migrations.Migration{ - Up: func(tx bun.Tx) error { - return fixExpandingChangelogs(ctx, tx) - }, - }, - ) -} - -type PreviousConnector struct { - bun.BaseModel `bun:"connectors.connector"` - - ID uuid.UUID `bun:",pk,nullzero"` - CreatedAt time.Time `bun:",nullzero"` - Provider models.ConnectorProvider - - // EncryptedConfig is a PGP-encrypted JSON string. - EncryptedConfig []byte `bun:"config"` - - // Config is a decrypted config. It is not stored in the database. - Config json.RawMessage `bun:"decrypted_config,scanonly"` -} - -type Connector struct { - bun.BaseModel `bun:"connectors.connector_v2"` - - ID models.ConnectorID `bun:",pk,nullzero"` - Name string - CreatedAt time.Time `bun:",nullzero"` - Provider models.ConnectorProvider - - // EncryptedConfig is a PGP-encrypted JSON string. - EncryptedConfig []byte `bun:"config"` -} - -func migrateConnectors(ctx context.Context, tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.account ALTER COLUMN connector_id SET NOT NULL; - ALTER TABLE accounts.bank_account ALTER COLUMN connector_id SET NOT NULL; - - CREATE TABLE connectors.connector_v2 ( - id CHARACTER VARYING NOT NULL, - name text NOT NULL UNIQUE, - created_at timestamp with time zone NOT NULL DEFAULT NOW() CHECK (created_at<=NOW()), - provider connector_provider NOT NULL, - config bytea NULL, - CONSTRAINT connector_v2_pk PRIMARY KEY (id) - ); - `) - if err != nil { - return err - } - - var oldConnectors []*PreviousConnector - err = tx.NewSelect(). - Model(&oldConnectors). - Scan(ctx) - if err != nil { - return err - } - - newConnectors := make([]*Connector, 0, len(oldConnectors)) - for _, oldConnector := range oldConnectors { - newConnectors = append(newConnectors, &Connector{ - ID: models.ConnectorID{ - Reference: uuid.New(), - Provider: oldConnector.Provider, - }, - Name: oldConnector.Provider.String(), - CreatedAt: oldConnector.CreatedAt, - Provider: oldConnector.Provider, - EncryptedConfig: oldConnector.EncryptedConfig, - }) - } - - if len(newConnectors) > 0 { - _, err = tx.NewInsert(). - Model(&newConnectors). - Exec(ctx) - if err != nil { - return err - } - } - - _, err = tx.Exec(` - ALTER TABLE tasks.task ADD COLUMN IF NOT EXISTS provider connector_provider; - UPDATE tasks.task SET provider = (SELECT provider FROM connectors.connector WHERE id = task.connector_id); - ALTER TABLE payments.payment ADD COLUMN IF NOT EXISTS provider connector_provider; - UPDATE payments.payment SET provider = (SELECT provider FROM connectors.connector WHERE id = payment.connector_id); - ALTER TABLE tasks.task DROP CONSTRAINT IF EXISTS task_connector; - ALTER TABLE tasks.task ALTER COLUMN connector_id TYPE CHARACTER VARYING; - ALTER TABLE payments.payment DROP CONSTRAINT IF EXISTS payment_connector; - ALTER TABLE payments.payment ALTER COLUMN connector_id TYPE CHARACTER VARYING; - ALTER TABLE payments.transfers DROP CONSTRAINT IF EXISTS transfer_connector; - ALTER TABLE payments.transfers ALTER COLUMN connector_id TYPE CHARACTER VARYING; - ALTER TABLE accounts.account DROP CONSTRAINT IF EXISTS accounts_connector; - ALTER TABLE accounts.account ALTER COLUMN connector_id TYPE CHARACTER VARYING; - ALTER TABLE accounts.bank_account DROP CONSTRAINT IF EXISTS bank_accounts_connector; - ALTER TABLE accounts.bank_account ALTER COLUMN connector_id TYPE CHARACTER VARYING; - ALTER TABLE transfers.transfer_initiation DROP CONSTRAINT IF EXISTS transfer_initiation_connector_id; - - DROP TABLE connectors.connector; - ALTER TABLE connectors.connector_v2 RENAME TO connector; - - UPDATE tasks.task SET connector_id = (SELECT id FROM connectors.connector WHERE provider = task.provider); - UPDATE payments.payment SET connector_id = (SELECT id FROM connectors.connector WHERE provider = payment.provider); - UPDATE accounts.account SET connector_id = (SELECT id FROM connectors.connector WHERE provider::text = account.provider); - UPDATE accounts.bank_account SET connector_id = (SELECT id FROM connectors.connector WHERE provider = bank_account.provider); - - ALTER TABLE tasks.task DROP COLUMN IF EXISTS provider; - ALTER TABLE accounts.account DROP COLUMN IF EXISTS provider; - ALTER TABLE accounts.bank_account DROP COLUMN IF EXISTS provider; - ALTER TABLE payments.payment DROP COLUMN IF EXISTS provider; - - ALTER TABLE tasks.task ADD CONSTRAINT task_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.payment ADD CONSTRAINT payment_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.account ADD CONSTRAINT accounts_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.bank_account ADD CONSTRAINT bank_accounts_connector - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE transfers.transfer_initiation ADD COLUMN IF NOT EXISTS connector_id CHARACTER VARYING NOT NULL; - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT transfer_initiation_connector_id - FOREIGN KEY (connector_id) - REFERENCES connectors.connector (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - UPDATE transfers.transfer_initiation SET connector_id = (SELECT id FROM connectors.connector WHERE provider = transfer_initiation.provider); - `) - if err != nil { - return err - } - - return nil -} - -type PreviousAccountID struct { - Reference string - Provider models.ConnectorProvider -} - -func PreviousAccountIDFromString(value string) (*PreviousAccountID, error) { - data, err := base64.URLEncoding.DecodeString(value) - if err != nil { - return nil, err - } - ret := PreviousAccountID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return nil, err - } - - return &ret, nil -} - -func (aid *PreviousAccountID) Scan(value interface{}) error { - if value == nil { - return errors.New("account id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := PreviousAccountIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse account id %s: %v", v, err) - } - - *aid = *id - return nil - } - } - - return fmt.Errorf("failed to scan account id: %v", value) -} - -func (aid PreviousAccountID) Value() (driver.Value, error) { - return aid.String(), nil -} - -func (aid *PreviousAccountID) String() string { - if aid == nil || aid.Reference == "" { - return "" - } - - data, err := canonicaljson.Marshal(aid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.EncodeToString(data) -} - -func migrateAccountID(ctx context.Context, tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE accounts.balances DROP CONSTRAINT IF EXISTS balances_account; - ALTER TABLE accounts.bank_account DROP CONSTRAINT IF EXISTS bank_account_account_id; - ALTER TABLE transfers.transfer_initiation DROP CONSTRAINT IF EXISTS destination_account; - ALTER TABLE transfers.transfer_initiation DROP CONSTRAINT IF EXISTS source_account; - ALTER TABLE payments.payment DROP CONSTRAINT IF EXISTS payment_destination_account; - ALTER TABLE payments.payment DROP CONSTRAINT IF EXISTS payment_source_account; - `) - if err != nil { - return err - } - - var previousIDs []PreviousAccountID - var connectorIDs []models.ConnectorID - err = tx.NewSelect().Model((*models.Account)(nil)).Column("id", "connector_id").Scan(ctx, &previousIDs, &connectorIDs) - if err != nil { - return err - } - - if len(previousIDs) != len(connectorIDs) { - return fmt.Errorf("migrateAccountID: previousIDs and connectorIDs have different length") - } - - type AccoutIDMigration struct { - PreviousAccountID PreviousAccountID - NewAccountID models.AccountID - } - migrations := make([]AccoutIDMigration, 0, len(previousIDs)) - for i, previousID := range previousIDs { - migrations = append(migrations, AccoutIDMigration{ - PreviousAccountID: previousID, - NewAccountID: models.AccountID{ - Reference: previousID.Reference, - ConnectorID: connectorIDs[i], - }, - }) - } - - for _, migration := range migrations { - _, err := tx.NewUpdate(). - Model((*models.Account)(nil)). - Set("id = ?", migration.NewAccountID). - Where("id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.Balance)(nil)). - Set("account_id = ?", migration.NewAccountID). - Where("account_id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.BankAccount)(nil)). - Set("account_id = ?", migration.NewAccountID). - Where("account_id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.TransferInitiation)(nil)). - Set("source_account_id = ?", migration.NewAccountID). - Where("source_account_id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.TransferInitiation)(nil)). - Set("destination_account_id = ?", migration.NewAccountID). - Where("destination_account_id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.Payment)(nil)). - Set("source_account_id = ?", migration.NewAccountID). - Where("source_account_id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.Payment)(nil)). - Set("destination_account_id = ?", migration.NewAccountID). - Where("destination_account_id = ?", migration.PreviousAccountID). - Exec(ctx) - if err != nil { - return err - } - } - - _, err = tx.Exec(` - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT source_account - FOREIGN KEY (source_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE transfers.transfer_initiation ADD CONSTRAINT destination_account - FOREIGN KEY (destination_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.balances ADD CONSTRAINT balances_account - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE accounts.bank_account ADD CONSTRAINT bank_account_account_id - FOREIGN KEY (account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.payment ADD CONSTRAINT payment_source_account - FOREIGN KEY (source_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.payment ADD CONSTRAINT payment_destination_account - FOREIGN KEY (destination_account_id) - REFERENCES accounts.account (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil -} - -type PreviousPaymentID struct { - models.PaymentReference - Provider models.ConnectorProvider -} - -func (pid PreviousPaymentID) String() string { - data, err := canonicaljson.Marshal(pid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.EncodeToString(data) -} - -func PaymentIDFromString(value string) (*PreviousPaymentID, error) { - data, err := base64.URLEncoding.DecodeString(value) - if err != nil { - return nil, err - } - ret := PreviousPaymentID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return nil, err - } - - return &ret, nil -} - -func (pid PreviousPaymentID) Value() (driver.Value, error) { - return pid.String(), nil -} - -func (pid *PreviousPaymentID) Scan(value interface{}) error { - if value == nil { - return errors.New("payment id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := PaymentIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse paymentid %s: %v", v, err) - } - - *pid = *id - return nil - } - } - - return fmt.Errorf("failed to scan paymentid: %v", value) -} - -func migratePaymentID(ctx context.Context, tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE payments.adjustment DROP CONSTRAINT IF EXISTS adjustment_payment; - ALTER TABLE payments.metadata DROP CONSTRAINT IF EXISTS metadata_payment; - `) - if err != nil { - return err - } - - var previousIDs []PreviousPaymentID - var connectorIDs []models.ConnectorID - err = tx.NewSelect().Model((*models.Payment)(nil)).Column("id", "connector_id").Scan(ctx, &previousIDs, &connectorIDs) - if err != nil { - return err - } - - if len(previousIDs) != len(connectorIDs) { - return fmt.Errorf("migrateAccountID: previousIDs and connectorIDs have different length") - } - - type PaymentIDMigration struct { - PreviousPaymentID PreviousPaymentID - NewPaymentID models.PaymentID - } - migrations := make([]PaymentIDMigration, 0, len(previousIDs)) - for i, previousID := range previousIDs { - migrations = append(migrations, PaymentIDMigration{ - PreviousPaymentID: previousID, - NewPaymentID: models.PaymentID{ - PaymentReference: previousID.PaymentReference, - ConnectorID: connectorIDs[i], - }, - }) - } - - for _, migration := range migrations { - _, err := tx.NewUpdate(). - Model((*models.Payment)(nil)). - Set("id = ?", migration.NewPaymentID). - Where("id = ?", migration.PreviousPaymentID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.PaymentAdjustment)(nil)). - Set("payment_id = ?", migration.NewPaymentID). - Where("payment_id = ?", migration.PreviousPaymentID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.PaymentMetadata)(nil)). - Set("payment_id = ?", migration.NewPaymentID). - Where("payment_id = ?", migration.PreviousPaymentID). - Exec(ctx) - if err != nil { - return err - } - } - - _, err = tx.Exec(` - ALTER TABLE payments.adjustment ADD CONSTRAINT adjustment_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - - ALTER TABLE payments.metadata ADD CONSTRAINT metadata_payment - FOREIGN KEY (payment_id) - REFERENCES payments.payment (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil -} - -type PreviousTransferInitiationID struct { - Reference string - Provider models.ConnectorProvider -} - -func (tid PreviousTransferInitiationID) String() string { - data, err := canonicaljson.Marshal(tid) - if err != nil { - panic(err) - } - - return base64.URLEncoding.EncodeToString(data) -} - -func TransferInitiationIDFromString(value string) (PreviousTransferInitiationID, error) { - data, err := base64.URLEncoding.DecodeString(value) - if err != nil { - return PreviousTransferInitiationID{}, err - } - ret := PreviousTransferInitiationID{} - err = canonicaljson.Unmarshal(data, &ret) - if err != nil { - return PreviousTransferInitiationID{}, err - } - - return ret, nil -} - -func (tid PreviousTransferInitiationID) Value() (driver.Value, error) { - return tid.String(), nil -} - -func (tid *PreviousTransferInitiationID) Scan(value interface{}) error { - if value == nil { - return errors.New("payment id is nil") - } - - if s, err := driver.String.ConvertValue(value); err == nil { - - if v, ok := s.(string); ok { - - id, err := TransferInitiationIDFromString(v) - if err != nil { - return fmt.Errorf("failed to parse paymentid %s: %v", v, err) - } - - *tid = id - return nil - } - } - - return fmt.Errorf("failed to scan paymentid: %v", value) -} - -func migrateTransferInitiationID(ctx context.Context, tx bun.Tx) error { - _, err := tx.Exec(` - ALTER TABLE transfers.transfer_initiation_payments DROP CONSTRAINT IF EXISTS transfer_initiation_id_constraint; - `) - if err != nil { - return err - } - - var previousIDs []PreviousTransferInitiationID - var connectorIDs []models.ConnectorID - err = tx.NewSelect().Model((*models.TransferInitiation)(nil)).Column("id", "connector_id").Scan(ctx, &previousIDs, &connectorIDs) - if err != nil { - return err - } - - if len(previousIDs) != len(connectorIDs) { - return fmt.Errorf("migrateAccountID: previousIDs and connectorIDs have different length") - } - - type TransferInitiationIDMigration struct { - PreviousTransferInitiationID PreviousTransferInitiationID - NewTransferInitiationID models.TransferInitiationID - } - - migrations := make([]TransferInitiationIDMigration, 0, len(previousIDs)) - for i, previousID := range previousIDs { - migrations = append(migrations, TransferInitiationIDMigration{ - PreviousTransferInitiationID: previousID, - NewTransferInitiationID: models.TransferInitiationID{ - Reference: previousID.Reference, - ConnectorID: connectorIDs[i], - }, - }) - } - - for _, migration := range migrations { - _, err := tx.NewUpdate(). - Model((*models.TransferInitiation)(nil)). - Set("id = ?", migration.NewTransferInitiationID). - Where("id = ?", migration.PreviousTransferInitiationID). - Exec(ctx) - if err != nil { - return err - } - - _, err = tx.NewUpdate(). - Model((*models.TransferInitiationPayment)(nil)). - Set("transfer_initiation_id = ?", migration.NewTransferInitiationID). - Where("transfer_initiation_id = ?", migration.PreviousTransferInitiationID). - Exec(ctx) - if err != nil { - return err - } - } - - _, err = tx.Exec(` - ALTER TABLE transfers.transfer_initiation_payments ADD CONSTRAINT transfer_initiation_id_constraint - FOREIGN KEY (transfer_initiation_id) - REFERENCES transfers.transfer_initiation (id) - ON DELETE CASCADE - NOT DEFERRABLE - INITIALLY IMMEDIATE - ; - `) - if err != nil { - return err - } - - return nil -} - -func fixExpandingChangelogs(ctx context.Context, tx bun.Tx) error { - var createdAt time.Time - for { - var metadata []models.PaymentMetadata - query := tx.NewSelect(). - Model(&metadata). - Order("created_at ASC"). - Limit(100) - - if !createdAt.IsZero() { - query.Where("created_at > ?", createdAt) - } - - err := query.Scan(ctx) - if err != nil { - return err - } - - if len(metadata) == 0 { - break - } - - for i, m := range metadata { - if m.Changelog == nil { - continue - } - - var newChangelogs []models.MetadataChangelog - for _, cl := range m.Changelog { - if len(newChangelogs) > 0 && cl.Value == newChangelogs[len(newChangelogs)-1].Value { - continue - } - - newChangelogs = append(newChangelogs, cl) - } - - metadata[i].Changelog = newChangelogs - createdAt = m.CreatedAt - } - - fmt.Println("Updating", len(metadata), "metadata") - - _, err = tx.NewInsert(). - Model(&metadata). - On("CONFLICT (payment_id, key) DO UPDATE"). - Set("changelog = EXCLUDED.changelog"). - Exec(ctx) - if err != nil { - return err - } - } - - return nil -} diff --git a/internal/storage/module.go b/internal/storage/module.go new file mode 100644 index 00000000..4fd5063a --- /dev/null +++ b/internal/storage/module.go @@ -0,0 +1,28 @@ +package storage + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunconnect" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/service" + "github.com/spf13/cobra" + "github.com/uptrace/bun" + "go.uber.org/fx" +) + +func Module(cmd *cobra.Command, connectionOptions bunconnect.ConnectionOptions, configEncryptionKey string) fx.Option { + return fx.Options( + bunconnect.Module(connectionOptions, service.IsDebug(cmd)), + fx.Provide(func(logger logging.Logger, db *bun.DB) Storage { + return newStorage(logger, db, configEncryptionKey) + }), + fx.Invoke(func(s Storage, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + return s.Close() + }, + }) + }), + ) +} diff --git a/internal/storage/paginate.go b/internal/storage/paginate.go new file mode 100644 index 00000000..ba9c946c --- /dev/null +++ b/internal/storage/paginate.go @@ -0,0 +1,14 @@ +package storage + +import ( + "context" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/uptrace/bun" +) + +func paginateWithOffset[FILTERS any, RETURN any](s *store, ctx context.Context, + q *bunpaginate.OffsetPaginatedQuery[FILTERS], builders ...func(query *bun.SelectQuery) *bun.SelectQuery) (*bunpaginate.Cursor[RETURN], error) { + query := s.db.NewSelect() + return bunpaginate.UsingOffset[FILTERS, RETURN](ctx, query, *q, builders...) +} diff --git a/internal/storage/payment_initiation_reversals.go b/internal/storage/payment_initiation_reversals.go new file mode 100644 index 00000000..38305d39 --- /dev/null +++ b/internal/storage/payment_initiation_reversals.go @@ -0,0 +1,332 @@ +package storage + +import ( + "context" + "fmt" + "math/big" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type paymentInitiationReversal struct { + bun.BaseModel `bun:"payment_initiation_reversals"` + + // Mandatory fields + ID models.PaymentInitiationReversalID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + PaymentInitiationID models.PaymentInitiationID `bun:"payment_initiation_id,type:character varying,notnull"` + Reference string `bun:"reference,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Description string `bun:"description,type:text,notnull"` + Amount *big.Int `bun:"amount,type:numeric,notnull"` + Asset string `bun:"asset,type:text,notnull"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +type paymentInitiationReversalAdjustment struct { + bun.BaseModel `bun:"payment_initiation_reversal_adjustments"` + + // Mandatory fields + ID models.PaymentInitiationReversalAdjustmentID `bun:"id,pk,type:character varying,notnull"` + PaymentInitiationReversalID models.PaymentInitiationReversalID `bun:"payment_initiation_reversal_id,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Status models.PaymentInitiationReversalAdjustmentStatus + + // Optional fields + Error *string `bun:"error,type:text"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +func (s *store) PaymentInitiationReversalsUpsert( + ctx context.Context, + pir models.PaymentInitiationReversal, + reversalAdjustments []models.PaymentInitiationReversalAdjustment, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("upsert payment initiation reversal", err) + } + defer tx.Rollback() + + toInsert := fromPaymentInitiationReversalModels(pir) + reversalAdjustementsToInsert := make([]paymentInitiationReversalAdjustment, 0, len(reversalAdjustments)) + for _, adj := range reversalAdjustments { + reversalAdjustementsToInsert = append(reversalAdjustementsToInsert, fromPaymentInitiationReversalAdjustmentModels(adj)) + } + + _, err = tx.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("upsert payment initiation reversal", err) + } + + if len(reversalAdjustementsToInsert) > 0 { + _, err = tx.NewInsert(). + Model(&reversalAdjustementsToInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("upsert payment initiation reversal adjustments", err) + } + } + + return e("failed to commit transaction", tx.Commit()) +} + +func (s *store) PaymentInitiationReversalsGet(ctx context.Context, id models.PaymentInitiationReversalID) (*models.PaymentInitiationReversal, error) { + var pir paymentInitiationReversal + err := s.db.NewSelect(). + Model(&pir). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("get payment initiation reversal", err) + } + + res := toPaymentInitiationReversalModels(pir) + return &res, nil +} + +func (s *store) PaymentInitiationReversalsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*paymentInitiationReversal)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + if err != nil { + return e("delete payment initiation reversal", err) + } + + return nil +} + +type PaymentInitiationReversalQuery struct{} + +type ListPaymentInitiationReversalsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalQuery]] + +func NewListPaymentInitiationReversalsQuery(opts bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalQuery]) ListPaymentInitiationReversalsQuery { + return ListPaymentInitiationReversalsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) paymentsInitiationReversalQueryContext(qb query.Builder) (string, []any, error) { + where, args, err := qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "reference", + key == "connector_id", + key == "asset", + key == "payment_initiation_id": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'type' column can only be used with $match") + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + + case key == "amount": + return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil + case metadataRegex.Match([]byte(key)): + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + match := metadataRegex.FindAllStringSubmatch(key, 3) + + key := "metadata" + return key + " @> ?", []any{map[string]any{ + match[0][1]: value, + }}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) + + return where, args, err +} + +func (s *store) PaymentInitiationReversalsList(ctx context.Context, q ListPaymentInitiationReversalsQuery) (*bunpaginate.Cursor[models.PaymentInitiationReversal], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.paymentsInitiationReversalQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalQuery], paymentInitiationReversal](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch payment initiation reversals", err) + } + + pis := make([]models.PaymentInitiationReversal, 0, len(cursor.Data)) + for _, pi := range cursor.Data { + pis = append(pis, toPaymentInitiationReversalModels(pi)) + } + + return &bunpaginate.Cursor[models.PaymentInitiationReversal]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pis, + }, nil +} + +func (s *store) PaymentInitiationReversalAdjustmentsUpsert(ctx context.Context, adj models.PaymentInitiationReversalAdjustment) error { + toInsert := fromPaymentInitiationReversalAdjustmentModels(adj) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("upsert payment initiation reversal adjustment", err) + } + + return nil +} + +func (s *store) PaymentInitiationReversalAdjustmentsGet(ctx context.Context, id models.PaymentInitiationReversalAdjustmentID) (*models.PaymentInitiationReversalAdjustment, error) { + var adj paymentInitiationReversalAdjustment + err := s.db.NewSelect(). + Model(&adj). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to get payment initiation reversal adjustment", err) + } + + res := toPaymentInitiationReversalAdjustmentModels(adj) + return &res, nil +} + +type PaymentInitiationReversalAdjustmentsQuery struct{} + +type ListPaymentInitiationReversalAdjustmentsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalAdjustmentsQuery]] + +func NewListPaymentInitiationReversalAdjustmentsQuery(opts bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalAdjustmentsQuery]) ListPaymentInitiationReversalAdjustmentsQuery { + return ListPaymentInitiationReversalAdjustmentsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) PaymentInitiationReversalAdjustmentsList(ctx context.Context, piID models.PaymentInitiationReversalID, q ListPaymentInitiationReversalAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationReversalAdjustment], error) { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalAdjustmentsQuery], paymentInitiationReversalAdjustment](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationReversalAdjustmentsQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + query.Where("payment_initiation_reversal_id = ?", piID) + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch accounts", err) + } + + pis := make([]models.PaymentInitiationReversalAdjustment, 0, len(cursor.Data)) + for _, pi := range cursor.Data { + pis = append(pis, toPaymentInitiationReversalAdjustmentModels(pi)) + } + + return &bunpaginate.Cursor[models.PaymentInitiationReversalAdjustment]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pis, + }, nil +} + +func fromPaymentInitiationReversalModels(from models.PaymentInitiationReversal) paymentInitiationReversal { + return paymentInitiationReversal{ + ID: from.ID, + ConnectorID: from.ConnectorID, + PaymentInitiationID: from.PaymentInitiationID, + Reference: from.Reference, + CreatedAt: time.New(from.CreatedAt), + Description: from.Description, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + } +} + +func toPaymentInitiationReversalModels(from paymentInitiationReversal) models.PaymentInitiationReversal { + return models.PaymentInitiationReversal{ + ID: from.ID, + ConnectorID: from.ConnectorID, + PaymentInitiationID: from.PaymentInitiationID, + Reference: from.Reference, + CreatedAt: from.CreatedAt.Time, + Description: from.Description, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + } +} + +func fromPaymentInitiationReversalAdjustmentModels(from models.PaymentInitiationReversalAdjustment) paymentInitiationReversalAdjustment { + return paymentInitiationReversalAdjustment{ + ID: from.ID, + PaymentInitiationReversalID: from.PaymentInitiationReversalID, + CreatedAt: time.New(from.CreatedAt), + Status: from.Status, + Error: func() *string { + if from.Error == nil { + return nil + } + return pointer.For(from.Error.Error()) + }(), + Metadata: from.Metadata, + } +} + +func toPaymentInitiationReversalAdjustmentModels(from paymentInitiationReversalAdjustment) models.PaymentInitiationReversalAdjustment { + return models.PaymentInitiationReversalAdjustment{ + ID: from.ID, + PaymentInitiationReversalID: from.PaymentInitiationReversalID, + CreatedAt: from.CreatedAt.Time, + Status: from.Status, + Error: func() error { + if from.Error == nil { + return nil + } + + return errors.New(*from.Error) + }(), + Metadata: from.Metadata, + } +} diff --git a/internal/storage/payment_initiation_reversals_test.go b/internal/storage/payment_initiation_reversals_test.go new file mode 100644 index 00000000..ff624fc8 --- /dev/null +++ b/internal/storage/payment_initiation_reversals_test.go @@ -0,0 +1,681 @@ +package storage + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +var ( + pirID1 = models.PaymentInitiationReversalID{ + Reference: "test1", + ConnectorID: defaultConnector.ID, + } + + pirID2 = models.PaymentInitiationReversalID{ + Reference: "test2", + ConnectorID: defaultConnector.ID, + } + + pirID3 = models.PaymentInitiationReversalID{ + Reference: "test3", + ConnectorID: defaultConnector.ID, + } +) + +func defaultPaymentInitiationReversals() []models.PaymentInitiationReversal { + return []models.PaymentInitiationReversal{ + { + ID: pirID1, + ConnectorID: defaultConnector.ID, + PaymentInitiationID: piID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Description: "test1", + Amount: big.NewInt(100), + Asset: "EUR/2", + Metadata: map[string]string{}, + }, + { + ID: pirID2, + ConnectorID: defaultConnector.ID, + PaymentInitiationID: piID1, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Description: "test2", + Amount: big.NewInt(150), + Asset: "USD/2", + Metadata: map[string]string{"foo": "bar"}, + }, + { + ID: pirID3, + ConnectorID: defaultConnector.ID, + PaymentInitiationID: piID2, + Reference: "test3", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Description: "test3", + Amount: big.NewInt(200), + Asset: "EUR/2", + Metadata: map[string]string{"foo2": "bar2"}, + }, + } +} + +func upsertPaymentInitiationReversals(t *testing.T, ctx context.Context, storage Storage, paymentInitiationReversals []models.PaymentInitiationReversal) { + for _, pi := range paymentInitiationReversals { + err := storage.PaymentInitiationReversalsUpsert(ctx, pi, nil) + require.NoError(t, err) + } +} + +func TestPaymentInitiationReversalsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + + t.Run("upsert with unknown connector", func(t *testing.T) { + connector := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + p := defaultPaymentInitiationReversals()[0] + p.ID.ConnectorID = connector + p.ConnectorID = connector + + err := store.PaymentInitiationReversalsUpsert(ctx, p, nil) + require.Error(t, err) + }) + + t.Run("upsert with same id", func(t *testing.T) { + pi := models.PaymentInitiationReversal{ + ID: pirID1, + ConnectorID: defaultConnector.ID, + PaymentInitiationID: piID1, + Reference: "test_changed", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Description: "test_changed", + Amount: big.NewInt(100), + Asset: "DKK/2", + Metadata: map[string]string{}, + } + + upsertPaymentInitiationReversals(t, ctx, store, []models.PaymentInitiationReversal{pi}) + + actual, err := store.PaymentInitiationReversalsGet(ctx, pirID1) + require.NoError(t, err) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[0], *actual) + }) +} + +func TestPaymentInitiationReversalsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + + t.Run("get unknown payment initiation reversal", func(t *testing.T) { + _, err := store.PaymentInitiationReversalsGet(ctx, models.PaymentInitiationReversalID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + }) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("get existing payment initiation", func(t *testing.T) { + for _, pi := range defaultPaymentInitiationReversals() { + actual, err := store.PaymentInitiationReversalsGet(ctx, pi.ID) + require.NoError(t, err) + comparePaymentInitiationReversals(t, pi, *actual) + } + }) +} + +func TestPaymentInitiationReversalsDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + + t.Run("delete from unknown connector", func(t *testing.T) { + require.NoError(t, store.PaymentInitiationReversalsDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, pi := range defaultPaymentInitiationReversals() { + actual, err := store.PaymentInitiationReversalsGet(ctx, pi.ID) + require.NoError(t, err) + comparePaymentInitiationReversals(t, pi, *actual) + } + }) + + t.Run("delete from existing connector", func(t *testing.T) { + require.NoError(t, store.PaymentInitiationReversalsDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, pi := range defaultPaymentInitiationReversals() { + _, err := store.PaymentInitiationReversalsGet(ctx, pi.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} + +func TestPaymentInitiationReversalsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + + t.Run("list payment intitiations reversals by reference", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "test1")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[0], cursor.Data[0]) + }) + + t.Run("list payment initiations reversals by unknown reference", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "unknown")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals by connector_id", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector.ID.String())), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[1], cursor.Data[0]) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[2], cursor.Data[1]) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[0], cursor.Data[2]) + }) + + t.Run("list payment initiations reversals by unknown connector_id", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", "unknown")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals by asset", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "EUR/2")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[2], cursor.Data[0]) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[0], cursor.Data[1]) + }) + + t.Run("list payment initiations reversals by asset 2", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "USD/2")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[1], cursor.Data[0]) + }) + + t.Run("list payment initiations reversals by unknown asset", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "unknown")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals by payment initiation id", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("payment_initiation_id", piID1)), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[1], cursor.Data[0]) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[0], cursor.Data[1]) + }) + + t.Run("list payment initiations reversals by unknowns payment initiation id", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("payment_initiation_id", "unknown")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals by amount", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("amount", 200)), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[2], cursor.Data[0]) + }) + + t.Run("list payment initiations reversals by unknown amount", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("amount", 0)), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals by metadata", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "bar")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[1], cursor.Data[0]) + }) + + t.Run("list payment initiations reversals by unknown metadata", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "unknown")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals by unknown metadata 2", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[unknown]", "bar")), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations reversals test cursor", func(t *testing.T) { + q := NewListPaymentInitiationReversalsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalQuery{}). + WithPageSize(1), + ) + + cursor, err := store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationReversalsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationReversals(t, defaultPaymentInitiationReversals()[1], cursor.Data[0]) + }) +} + +var ( + pirAdjID1 = models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: defaultPaymentInitiationReversals()[0].ID, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, + } + pirAdjID2 = models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: defaultPaymentInitiationReversals()[0].ID, + CreatedAt: now.Add(-5 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_FAILED, + } + pirAdjID3 = models.PaymentInitiationReversalAdjustmentID{ + PaymentInitiationReversalID: defaultPaymentInitiationReversals()[1].ID, + CreatedAt: now.Add(-7 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, + } + + defaultPaymentInitiationReversalAdjustments = []models.PaymentInitiationReversalAdjustment{ + { + ID: pirAdjID1, + PaymentInitiationReversalID: defaultPaymentInitiationReversals()[0].ID, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, + Metadata: map[string]string{ + "foo": "bar", + }, + }, + { + ID: pirAdjID2, + PaymentInitiationReversalID: defaultPaymentInitiationReversals()[0].ID, + CreatedAt: now.Add(-5 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_FAILED, + Error: errors.New("test"), + Metadata: map[string]string{ + "foo2": "bar2", + }, + }, + { + ID: pirAdjID3, + PaymentInitiationReversalID: defaultPaymentInitiationReversals()[1].ID, + CreatedAt: now.Add(-7 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, + Metadata: map[string]string{ + "foo3": "bar3", + }, + }, + } +) + +func upsertPaymentInitiationReversalAdjustments(t *testing.T, ctx context.Context, storage Storage, adjustments []models.PaymentInitiationReversalAdjustment) { + for _, adj := range adjustments { + require.NoError(t, storage.PaymentInitiationReversalAdjustmentsUpsert(ctx, adj)) + } +} + +func TestPaymentInitiationReversalAdjustmentsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + upsertPaymentInitiationReversalAdjustments(t, ctx, store, defaultPaymentInitiationReversalAdjustments) + + t.Run("upsert with unknown payment initiation reversal", func(t *testing.T) { + p := models.PaymentInitiationReversalAdjustment{ + ID: models.PaymentInitiationReversalAdjustmentID{}, + PaymentInitiationReversalID: models.PaymentInitiationReversalID{}, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSING, + Metadata: map[string]string{ + "foo": "bar", + }, + } + + require.Error(t, store.PaymentInitiationReversalAdjustmentsUpsert(ctx, p)) + }) + + t.Run("upsert with same id", func(t *testing.T) { + p := models.PaymentInitiationReversalAdjustment{ + ID: pirAdjID1, + PaymentInitiationReversalID: defaultPaymentInitiationReversalAdjustments[0].PaymentInitiationReversalID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_REVERSAL_STATUS_PROCESSED, + Metadata: map[string]string{ + "foo": "changed", + }, + } + + require.NoError(t, store.PaymentInitiationReversalAdjustmentsUpsert(ctx, p)) + + for _, pa := range defaultPaymentInitiationReversalAdjustments { + actual, err := store.PaymentInitiationReversalAdjustmentsGet(ctx, pa.ID) + require.NoError(t, err) + comparePaymentInitiationReversalAdjustments(t, pa, *actual) + } + }) +} + +func TestPaymentInitiationReversalAdjustmentsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + upsertPaymentInitiationReversalAdjustments(t, ctx, store, defaultPaymentInitiationReversalAdjustments) + + t.Run("get unknown payment initiation adjustment", func(t *testing.T) { + _, err := store.PaymentInitiationReversalAdjustmentsGet(ctx, models.PaymentInitiationReversalAdjustmentID{}) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("get existing payment initiation adjustment", func(t *testing.T) { + for _, pa := range defaultPaymentInitiationReversalAdjustments { + actual, err := store.PaymentInitiationReversalAdjustmentsGet(ctx, pa.ID) + require.NoError(t, err) + comparePaymentInitiationReversalAdjustments(t, pa, *actual) + } + }) +} + +func TestPaymentInitiationReversalAdjustmentsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationReversals(t, ctx, store, defaultPaymentInitiationReversals()) + upsertPaymentInitiationReversalAdjustments(t, ctx, store, defaultPaymentInitiationReversalAdjustments) + + t.Run("list payment initiation reversal adjustments by unknown payment initiation", func(t *testing.T) { + cursor, err := store.PaymentInitiationReversalAdjustmentsList( + ctx, + models.PaymentInitiationReversalID{}, + NewListPaymentInitiationReversalAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalAdjustmentsQuery{}). + WithPageSize(15), + ), + ) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiation reversal adjustments by payment initiation", func(t *testing.T) { + q := NewListPaymentInitiationReversalAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationReversalAdjustmentsQuery{}). + WithPageSize(1), + ) + + cursor, err := store.PaymentInitiationReversalAdjustmentsList(ctx, defaultPaymentInitiationReversalAdjustments[0].PaymentInitiationReversalID, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationReversalAdjustments(t, defaultPaymentInitiationReversalAdjustments[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationReversalAdjustmentsList(ctx, defaultPaymentInitiationReversalAdjustments[0].PaymentInitiationReversalID, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + comparePaymentInitiationReversalAdjustments(t, defaultPaymentInitiationReversalAdjustments[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationReversalAdjustmentsList(ctx, defaultPaymentInitiationReversalAdjustments[0].PaymentInitiationReversalID, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationReversalAdjustments(t, defaultPaymentInitiationReversalAdjustments[1], cursor.Data[0]) + }) +} + +func comparePaymentInitiationReversals(t *testing.T, expected, actual models.PaymentInitiationReversal) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.ConnectorID, actual.ConnectorID) + require.Equal(t, expected.PaymentInitiationID, actual.PaymentInitiationID) + require.Equal(t, expected.Reference, actual.Reference) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Description, actual.Description) + require.Equal(t, expected.Amount, actual.Amount) + require.Equal(t, expected.Asset, actual.Asset) + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } +} + +func comparePaymentInitiationReversalAdjustments(t *testing.T, expected, actual models.PaymentInitiationReversalAdjustment) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.PaymentInitiationReversalID, actual.PaymentInitiationReversalID) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Status, actual.Status) + + switch { + case expected.Error != nil && actual.Error != nil: + require.Equal(t, expected.Error.Error(), actual.Error.Error()) + case expected.Error == nil && actual.Error == nil: + default: + t.Fatalf("expected.Error != actual.Error") + } + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } +} diff --git a/internal/storage/payment_initiations.go b/internal/storage/payment_initiations.go new file mode 100644 index 00000000..57682dbe --- /dev/null +++ b/internal/storage/payment_initiations.go @@ -0,0 +1,560 @@ +package storage + +import ( + "context" + "fmt" + "math/big" + stdtime "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type paymentInitiation struct { + bun.BaseModel `bun:"payment_initiations"` + + // Mandatory fields + ID models.PaymentInitiationID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + Reference string `bun:"reference,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + ScheduledAt time.Time `bun:"scheduled_at,type:timestamp without time zone,notnull"` + Description string `bun:"description,type:text,notnull"` + Type models.PaymentInitiationType `bun:"type,type:text,notnull"` + Amount *big.Int `bun:"amount,type:numeric,notnull"` + Asset string `bun:"asset,type:text,notnull"` + + // Optional fields + SourceAccountID *models.AccountID `bun:"source_account_id,type:character varying"` + DestinationAccountID *models.AccountID `bun:"destination_account_id,type:character varying,notnull"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +type paymentInitiationRelatedPayment struct { + bun.BaseModel `bun:"payment_initiation_related_payments"` + + // Mandatory fields + PaymentInitiationID models.PaymentInitiationID `bun:"payment_initiation_id,pk,type:character varying,notnull"` + PaymentID models.PaymentID `bun:"payment_id,pk,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` +} + +type paymentInitiationAdjustment struct { + bun.BaseModel `bun:"payment_initiation_adjustments"` + + // Mandatory fields + ID models.PaymentInitiationAdjustmentID `bun:"id,pk,type:character varying,notnull"` + PaymentInitiationID models.PaymentInitiationID `bun:"payment_initiation_id,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Status models.PaymentInitiationAdjustmentStatus `bun:"status,type:text,notnull"` + + // Optional fields + Error *string `bun:"error,type:text"` + Amount *big.Int `bun:"amount,type:numeric"` + Asset *string `bun:"asset,type:text"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +func (s *store) PaymentInitiationsUpsert(ctx context.Context, pi models.PaymentInitiation, adjustments ...models.PaymentInitiationAdjustment) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("upsert payment initiations", err) + } + defer tx.Rollback() + + toInsert := fromPaymentInitiationModels(pi) + adjustmentsToInsert := make([]paymentInitiationAdjustment, 0, len(adjustments)) + for _, adj := range adjustments { + adjustmentsToInsert = append(adjustmentsToInsert, fromPaymentInitiationAdjustmentModels(adj)) + } + + _, err = tx.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert payment initiations", err) + } + + if len(adjustmentsToInsert) > 0 { + _, err = tx.NewInsert(). + Model(&adjustmentsToInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert payment initiation adjustments", err) + } + } + + return e("failed to commit transaction", tx.Commit()) +} + +func (s *store) PaymentInitiationsUpdateMetadata(ctx context.Context, piID models.PaymentInitiationID, metadata map[string]string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("update payment metadata", err) + } + defer tx.Rollback() + + var pi paymentInitiation + err = tx.NewSelect(). + Model(&pi). + Column("id", "metadata"). + Where("id = ?", piID). + Scan(ctx) + if err != nil { + return e("update payment initiation metadata", err) + } + + if pi.Metadata == nil { + pi.Metadata = make(map[string]string) + } + + for k, v := range metadata { + pi.Metadata[k] = v + } + + _, err = tx.NewUpdate(). + Model(&pi). + Column("metadata"). + Where("id = ?", piID). + Exec(ctx) + if err != nil { + return e("update payment initiation metadata", err) + } + + return e("failed to commit transaction", tx.Commit()) +} + +func (s *store) PaymentInitiationsGet(ctx context.Context, piID models.PaymentInitiationID) (*models.PaymentInitiation, error) { + var pi paymentInitiation + err := s.db.NewSelect(). + Model(&pi). + Where("id = ?", piID). + Scan(ctx) + if err != nil { + return nil, e("failed to get payment initiation", err) + } + + res := toPaymentInitiationModels(pi) + return &res, nil +} + +func (s *store) PaymentInitiationsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*paymentInitiation)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + return e("failed to delete payment initiations", err) +} + +func (s *store) PaymentInitiationsDelete(ctx context.Context, piID models.PaymentInitiationID) error { + _, err := s.db.NewDelete(). + Model((*paymentInitiation)(nil)). + Where("id = ?", piID). + Exec(ctx) + return e("failed to delete payment initiation", err) +} + +type PaymentInitiationQuery struct{} + +type ListPaymentInitiationsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationQuery]] + +func NewListPaymentInitiationsQuery(opts bunpaginate.PaginatedQueryOptions[PaymentInitiationQuery]) ListPaymentInitiationsQuery { + return ListPaymentInitiationsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) paymentsInitiationQueryContext(qb query.Builder) (string, []any, error) { + where, args, err := qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "reference", + key == "connector_id", + key == "type", + key == "asset", + key == "source_account_id", + key == "destination_account_id": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'type' column can only be used with $match") + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + + case key == "amount": + return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil + case metadataRegex.Match([]byte(key)): + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + match := metadataRegex.FindAllStringSubmatch(key, 3) + + key := "metadata" + return key + " @> ?", []any{map[string]any{ + match[0][1]: value, + }}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) + + return where, args, err +} + +func (s *store) PaymentInitiationsList(ctx context.Context, q ListPaymentInitiationsQuery) (*bunpaginate.Cursor[models.PaymentInitiation], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.paymentsInitiationQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PaymentInitiationQuery], paymentInitiation](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch payment initiations", err) + } + + pis := make([]models.PaymentInitiation, 0, len(cursor.Data)) + for _, pi := range cursor.Data { + pis = append(pis, toPaymentInitiationModels(pi)) + } + + return &bunpaginate.Cursor[models.PaymentInitiation]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pis, + }, nil +} + +func (s *store) PaymentInitiationRelatedPaymentsUpsert(ctx context.Context, piID models.PaymentInitiationID, pID models.PaymentID, createdAt stdtime.Time) error { + toInsert := paymentInitiationRelatedPayment{ + PaymentInitiationID: piID, + PaymentID: pID, + CreatedAt: time.New(createdAt), + } + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (payment_initiation_id, payment_id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert payment initiation related payments", err) + } + + return nil +} + +func (s *store) PaymentInitiationIDsListFromPaymentID(ctx context.Context, id models.PaymentID) ([]models.PaymentInitiationID, error) { + var paymentInitiationRelatedPayments []paymentInitiationRelatedPayment + err := s.db.NewSelect(). + Model(&paymentInitiationRelatedPayments). + Column("payment_initiation_id"). + Where("payment_id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to get payment initiation related payments", err) + } + + ids := make([]models.PaymentInitiationID, 0, len(paymentInitiationRelatedPayments)) + for _, pi := range paymentInitiationRelatedPayments { + ids = append(ids, pi.PaymentInitiationID) + } + + return ids, nil +} + +type PaymentInitiationRelatedPaymentsQuery struct{} + +type ListPaymentInitiationRelatedPaymentsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationRelatedPaymentsQuery]] + +func NewListPaymentInitiationRelatedPaymentsQuery(opts bunpaginate.PaginatedQueryOptions[PaymentInitiationRelatedPaymentsQuery]) ListPaymentInitiationRelatedPaymentsQuery { + return ListPaymentInitiationRelatedPaymentsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) PaymentInitiationRelatedPaymentsList(ctx context.Context, piID models.PaymentInitiationID, q ListPaymentInitiationRelatedPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PaymentInitiationRelatedPaymentsQuery], paymentInitiationRelatedPayment](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationRelatedPaymentsQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + query.Where("payment_initiation_id = ?", piID) + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch accounts", err) + } + + pis := make([]models.Payment, 0, len(cursor.Data)) + for _, pi := range cursor.Data { + p, err := s.PaymentsGet(ctx, pi.PaymentID) + if err != nil { + return nil, e("failed to get payment", err) + } + + pis = append(pis, *p) + } + + return &bunpaginate.Cursor[models.Payment]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pis, + }, nil +} + +func (s *store) PaymentInitiationAdjustmentsUpsert(ctx context.Context, adj models.PaymentInitiationAdjustment) error { + toInsert := fromPaymentInitiationAdjustmentModels(adj) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert payment initiation adjustments", err) + } + + return nil +} + +func (s *store) PaymentInitiationAdjustmentsUpsertIfPredicate( + ctx context.Context, + adj models.PaymentInitiationAdjustment, + predicate func(models.PaymentInitiationAdjustment) bool, +) (bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, e("upsert payment initiations", err) + } + defer tx.Rollback() + + var previousAdj paymentInitiationAdjustment + err = tx.NewSelect(). + Model(&previousAdj). + Where("payment_initiation_id = ?", adj.PaymentInitiationID). + Order("created_at DESC", "sort_id DESC"). + For("UPDATE"). // Prevent another transaction to select/insert a new adjustment while this one is not committed + Limit(1). + Scan(ctx) + if err != nil { + return false, e("failed to get previous payment initiation adjustment", err) + } + + if !predicate(toPaymentInitiationAdjustmentModels(previousAdj)) { + return false, nil + } + + toInsert := fromPaymentInitiationAdjustmentModels(adj) + _, err = tx.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return false, e("failed to insert payment initiation adjustments", err) + } + + return true, e("failed to commit transaction", tx.Commit()) +} + +func (s *store) PaymentInitiationAdjustmentsGet(ctx context.Context, id models.PaymentInitiationAdjustmentID) (*models.PaymentInitiationAdjustment, error) { + var adj paymentInitiationAdjustment + err := s.db.NewSelect(). + Model(&adj). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to get payment initiation adjustment", err) + } + + res := toPaymentInitiationAdjustmentModels(adj) + return &res, nil +} + +type PaymentInitiationAdjustmentsQuery struct{} + +type ListPaymentInitiationAdjustmentsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationAdjustmentsQuery]] + +func NewListPaymentInitiationAdjustmentsQuery(opts bunpaginate.PaginatedQueryOptions[PaymentInitiationAdjustmentsQuery]) ListPaymentInitiationAdjustmentsQuery { + return ListPaymentInitiationAdjustmentsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) paymentsInitiationAdjustmentsQueryContext(qb query.Builder) (string, []any, error) { + where, args, err := qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "status": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'type' column can only be used with $match") + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + case metadataRegex.Match([]byte(key)): + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + match := metadataRegex.FindAllStringSubmatch(key, 3) + key := "metadata" + return key + " @> ?", []any{map[string]any{ + match[0][1]: value, + }}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) + return where, args, err +} + +func (s *store) PaymentInitiationAdjustmentsList(ctx context.Context, piID models.PaymentInitiationID, q ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.paymentsInitiationAdjustmentsQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PaymentInitiationAdjustmentsQuery], paymentInitiationAdjustment](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentInitiationAdjustmentsQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + query.Where("payment_initiation_id = ?", piID) + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch accounts", err) + } + + pis := make([]models.PaymentInitiationAdjustment, 0, len(cursor.Data)) + for _, pi := range cursor.Data { + pis = append(pis, toPaymentInitiationAdjustmentModels(pi)) + } + + return &bunpaginate.Cursor[models.PaymentInitiationAdjustment]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pis, + }, nil +} + +func fromPaymentInitiationModels(from models.PaymentInitiation) paymentInitiation { + return paymentInitiation{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Reference: from.Reference, + CreatedAt: time.New(from.CreatedAt), + ScheduledAt: time.New(from.ScheduledAt), + Description: from.Description, + Type: from.Type, + Amount: from.Amount, + Asset: from.Asset, + DestinationAccountID: from.DestinationAccountID, + SourceAccountID: from.SourceAccountID, + Metadata: from.Metadata, + } +} + +func toPaymentInitiationModels(from paymentInitiation) models.PaymentInitiation { + return models.PaymentInitiation{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Reference: from.Reference, + CreatedAt: from.CreatedAt.Time, + ScheduledAt: from.ScheduledAt.Time, + Description: from.Description, + Type: from.Type, + SourceAccountID: from.SourceAccountID, + DestinationAccountID: from.DestinationAccountID, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + } +} + +func fromPaymentInitiationAdjustmentModels(from models.PaymentInitiationAdjustment) paymentInitiationAdjustment { + return paymentInitiationAdjustment{ + ID: from.ID, + PaymentInitiationID: from.PaymentInitiationID, + CreatedAt: time.New(from.CreatedAt), + Status: from.Status, + Amount: from.Amount, + Asset: from.Asset, + Error: func() *string { + if from.Error == nil { + return nil + } + return pointer.For(from.Error.Error()) + }(), + Metadata: from.Metadata, + } +} + +func toPaymentInitiationAdjustmentModels(from paymentInitiationAdjustment) models.PaymentInitiationAdjustment { + return models.PaymentInitiationAdjustment{ + ID: from.ID, + PaymentInitiationID: from.PaymentInitiationID, + CreatedAt: from.CreatedAt.Time, + Status: from.Status, + Amount: from.Amount, + Asset: from.Asset, + Error: func() error { + if from.Error == nil { + return nil + } + + return errors.New(*from.Error) + }(), + Metadata: from.Metadata, + } +} diff --git a/internal/storage/payment_initiations_test.go b/internal/storage/payment_initiations_test.go new file mode 100644 index 00000000..a3d222c2 --- /dev/null +++ b/internal/storage/payment_initiations_test.go @@ -0,0 +1,1076 @@ +package storage + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +var ( + piID1 = models.PaymentInitiationID{ + Reference: "test1", + ConnectorID: defaultConnector.ID, + } + + piID2 = models.PaymentInitiationID{ + Reference: "test2", + ConnectorID: defaultConnector.ID, + } + + piID3 = models.PaymentInitiationID{ + Reference: "test3", + ConnectorID: defaultConnector.ID, + } +) + +func defaultPaymentInitiations() []models.PaymentInitiation { + defaultAccounts := defaultAccounts() + return []models.PaymentInitiation{ + { + ID: piID1, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + ScheduledAt: now.Add(-60 * time.Minute).UTC().Time, + Description: "test1", + Type: models.PAYMENT_INITIATION_TYPE_PAYOUT, + DestinationAccountID: &defaultAccounts[0].ID, + Amount: big.NewInt(100), + Asset: "EUR/2", + }, + { + ID: piID2, + ConnectorID: defaultConnector.ID, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + ScheduledAt: now.Add(-20 * time.Minute).UTC().Time, + Description: "test2", + Type: models.PAYMENT_INITIATION_TYPE_TRANSFER, + SourceAccountID: &defaultAccounts[0].ID, + DestinationAccountID: &defaultAccounts[1].ID, + Amount: big.NewInt(150), + Asset: "USD/2", + Metadata: map[string]string{ + "foo": "bar", + }, + }, + { + ID: piID3, + ConnectorID: defaultConnector.ID, + Reference: "test3", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + ScheduledAt: now.Add(-40 * time.Minute).UTC().Time, + Description: "test3", + Type: models.PAYMENT_INITIATION_TYPE_PAYOUT, + DestinationAccountID: &defaultAccounts[1].ID, + Amount: big.NewInt(200), + Asset: "EUR/2", + Metadata: map[string]string{ + "foo2": "bar2", + }, + }, + } +} + +func upsertPaymentInitiations(t *testing.T, ctx context.Context, storage Storage, paymentInitiations []models.PaymentInitiation) { + for _, pi := range paymentInitiations { + err := storage.PaymentInitiationsUpsert(ctx, pi) + require.NoError(t, err) + } +} + +func TestPaymentInitiationsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + + t.Run("upsert with unknown connector", func(t *testing.T) { + connector := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + p := defaultPaymentInitiations()[0] + p.ID.ConnectorID = connector + p.ConnectorID = connector + + err := store.PaymentInitiationsUpsert(ctx, p) + require.Error(t, err) + }) + + t.Run("upsert with same id", func(t *testing.T) { + defaultAccounts := defaultAccounts() + pi := models.PaymentInitiation{ + ID: piID1, + ConnectorID: defaultConnector.ID, + Reference: "test_changed", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + ScheduledAt: now.Add(-20 * time.Minute).UTC().Time, + Description: "test_changed", + Type: models.PAYMENT_INITIATION_TYPE_PAYOUT, + DestinationAccountID: &defaultAccounts[0].ID, + Amount: big.NewInt(100), + Asset: "DKK/2", + } + + upsertPaymentInitiations(t, ctx, store, []models.PaymentInitiation{pi}) + + actual, err := store.PaymentInitiationsGet(ctx, piID1) + require.NoError(t, err) + comparePaymentInitiations(t, defaultPaymentInitiations()[0], *actual) + }) +} + +func TestPaymentInitiationsUpdateMetadata(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + + t.Run("update metadata of unknown payment initiation", func(t *testing.T) { + require.Error(t, store.PaymentInitiationsUpdateMetadata(ctx, models.PaymentInitiationID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + }, map[string]string{})) + }) + + t.Run("update existing metadata", func(t *testing.T) { + metadata := map[string]string{ + "foo": "changed", + } + + require.NoError(t, store.PaymentInitiationsUpdateMetadata(ctx, piID2, metadata)) + + actual, err := store.PaymentInitiationsGet(ctx, piID2) + require.NoError(t, err) + require.Equal(t, len(metadata), len(actual.Metadata)) + for k, v := range metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } + }) + + t.Run("add new metadata", func(t *testing.T) { + metadata := map[string]string{ + "key2": "value2", + "key3": "value3", + } + + require.NoError(t, store.PaymentInitiationsUpdateMetadata(ctx, piID1, metadata)) + + actual, err := store.PaymentInitiationsGet(ctx, piID1) + require.NoError(t, err) + require.Equal(t, len(metadata), len(actual.Metadata)) + for k, v := range metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } + }) +} + +func TestPaymentInitiationsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + + t.Run("get unknown payment initiation", func(t *testing.T) { + _, err := store.PaymentInitiationsGet(ctx, models.PaymentInitiationID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + }) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("get existing payment initiation", func(t *testing.T) { + for _, pi := range defaultPaymentInitiations() { + actual, err := store.PaymentInitiationsGet(ctx, pi.ID) + require.NoError(t, err) + comparePaymentInitiations(t, pi, *actual) + } + }) +} + +func TestPaymentInitiationsDelete(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + + t.Run("delete unknown payment initiation", func(t *testing.T) { + require.NoError(t, store.PaymentInitiationsDelete(ctx, models.PaymentInitiationID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + })) + }) + + t.Run("delete existing payment initiation", func(t *testing.T) { + for _, pi := range defaultPaymentInitiations() { + require.NoError(t, store.PaymentInitiationsDelete(ctx, pi.ID)) + + _, err := store.PaymentInitiationsGet(ctx, pi.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} + +func TestPaymentInitiationsDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + + t.Run("delete from unknown connector", func(t *testing.T) { + require.NoError(t, store.PaymentInitiationsDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, pi := range defaultPaymentInitiations() { + actual, err := store.PaymentInitiationsGet(ctx, pi.ID) + require.NoError(t, err) + comparePaymentInitiations(t, pi, *actual) + } + }) + + t.Run("delete from existing connector", func(t *testing.T) { + require.NoError(t, store.PaymentInitiationsDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, pi := range defaultPaymentInitiations() { + _, err := store.PaymentInitiationsGet(ctx, pi.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} + +func TestPaymentInitiationsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + + t.Run("list payment intitiations by reference", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "test1")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[0], cursor.Data[0]) + }) + + t.Run("list payment initiations by unknown reference", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "unknown")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by connector_id", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector.ID.String())), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[1]) + comparePaymentInitiations(t, defaultPaymentInitiations()[0], cursor.Data[2]) + }) + + t.Run("list payment initiations by unknown connector_id", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", "unknown")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by type", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", "PAYOUT")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[0]) + comparePaymentInitiations(t, defaultPaymentInitiations()[0], cursor.Data[1]) + }) + + t.Run("list payment initiations by type 2", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", "TRANSFER")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + }) + + t.Run("list payment initiations by unknown type", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", "UNKNOWN")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by asset", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "EUR/2")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[0]) + comparePaymentInitiations(t, defaultPaymentInitiations()[0], cursor.Data[1]) + }) + + t.Run("list payment initiations by asset 2", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "USD/2")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + }) + + t.Run("list payment initiations by unknown asset", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "unknown")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by source account id", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("source_account_id", defaultAccounts()[0].ID.String())), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + }) + + t.Run("list payment initiations by unknown source account id", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("source_account_id", "unknown")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by destination account id", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("destination_account_id", defaultAccounts()[1].ID.String())), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[1]) + }) + + t.Run("list payment initiations by unknown destination account id", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("destination_account_id", "unknown")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by amount", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("amount", 200)), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[0]) + }) + + t.Run("list payment initiations by unknown amount", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("amount", 0)), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by metadata", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "bar")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + }) + + t.Run("list payment initiations by unknown metadata", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[foo]", "unknown")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations by unknown metadata 2", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[unknown]", "bar")), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiations test cursor", func(t *testing.T) { + q := NewListPaymentInitiationsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationQuery{}). + WithPageSize(1), + ) + + cursor, err := store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + comparePaymentInitiations(t, defaultPaymentInitiations()[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiations(t, defaultPaymentInitiations()[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiations(t, defaultPaymentInitiations()[1], cursor.Data[0]) + }) +} + +func upsertPaymentInitiationRelatedPayments(t *testing.T, ctx context.Context, storage Storage) { + payments := defaultPayments() + require.NoError(t, storage.PaymentInitiationRelatedPaymentsUpsert(ctx, piID1, payments[0].ID, now.Add(-10*time.Minute).UTC().Time)) + require.NoError(t, storage.PaymentInitiationRelatedPaymentsUpsert(ctx, piID1, payments[1].ID, now.Add(-5*time.Minute).UTC().Time)) + require.NoError(t, storage.PaymentInitiationRelatedPaymentsUpsert(ctx, piID1, payments[2].ID, now.Add(-7*time.Minute).UTC().Time)) + require.NoError(t, storage.PaymentInitiationRelatedPaymentsUpsert(ctx, piID2, payments[0].ID, now.Add(-7*time.Minute).UTC().Time)) +} + +func TestPaymentInitiationsRelatedPaymentUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationRelatedPayments(t, ctx, store) + + t.Run("same id insert", func(t *testing.T) { + payments := defaultPayments() + require.NoError(t, store.PaymentInitiationRelatedPaymentsUpsert(ctx, piID1, payments[0].ID, now.Add(-10*time.Minute).UTC().Time)) + + cursor, err := store.PaymentInitiationRelatedPaymentsList( + ctx, + piID1, + NewListPaymentInitiationRelatedPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationRelatedPaymentsQuery{}). + WithPageSize(15), + ), + ) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + comparePayments(t, payments[1], cursor.Data[0]) + comparePayments(t, payments[2], cursor.Data[1]) + comparePayments(t, payments[0], cursor.Data[2]) + }) + + t.Run("unknown payment initiation", func(t *testing.T) { + payments := defaultPayments() + require.Error(t, store.PaymentInitiationRelatedPaymentsUpsert( + ctx, + models.PaymentInitiationID{}, + payments[0].ID, now.Add(-10*time.Minute).UTC().Time), + ) + }) +} + +func TestPaymentInitiationIDsFromPaymentID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationRelatedPayments(t, ctx, store) + + t.Run("unknown payment id", func(t *testing.T) { + ids, err := store.PaymentInitiationIDsListFromPaymentID(ctx, models.PaymentID{}) + require.NoError(t, err) + require.Len(t, ids, 0) + }) + + t.Run("known payment id", func(t *testing.T) { + ids, err := store.PaymentInitiationIDsListFromPaymentID(ctx, defaultPayments()[0].ID) + require.NoError(t, err) + require.Len(t, ids, 2) + require.Contains(t, ids, piID1) + require.Contains(t, ids, piID2) + }) +} + +func TestPaymentInitiationRelatedPaymentsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationRelatedPayments(t, ctx, store) + + t.Run("list related payments by unknown payment initiation", func(t *testing.T) { + cursor, err := store.PaymentInitiationRelatedPaymentsList( + ctx, + models.PaymentInitiationID{}, + NewListPaymentInitiationRelatedPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationRelatedPaymentsQuery{}). + WithPageSize(15), + ), + ) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list related payments by payment initiation", func(t *testing.T) { + q := NewListPaymentInitiationRelatedPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationRelatedPaymentsQuery{}). + WithPageSize(1), + ) + payments := defaultPayments() + + cursor, err := store.PaymentInitiationRelatedPaymentsList(ctx, piID1, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, payments[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationRelatedPaymentsList(ctx, piID1, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, payments[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationRelatedPaymentsList(ctx, piID1, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + comparePayments(t, payments[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationRelatedPaymentsList(ctx, piID1, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, payments[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationRelatedPaymentsList(ctx, piID1, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, payments[1], cursor.Data[0]) + }) +} + +var ( + piAdjID1 = models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: defaultPaymentInitiations()[0].ID, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION, + } + piAdjID2 = models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: defaultPaymentInitiations()[0].ID, + CreatedAt: now.Add(-5 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED, + } + piAdjID3 = models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: defaultPaymentInitiations()[1].ID, + CreatedAt: now.Add(-7 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + } +) + +func defaultPaymentInitiationAdjustments() []models.PaymentInitiationAdjustment { + return []models.PaymentInitiationAdjustment{ + { + ID: piAdjID1, + PaymentInitiationID: defaultPaymentInitiations()[0].ID, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION, + Amount: big.NewInt(100), + Asset: pointer.For("EUR/2"), + Metadata: map[string]string{ + "foo": "bar", + }, + }, + { + ID: piAdjID2, + PaymentInitiationID: defaultPaymentInitiations()[0].ID, + CreatedAt: now.Add(-5 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED, + Error: errors.New("test"), + Amount: big.NewInt(200), + Asset: pointer.For("USD/2"), + Metadata: map[string]string{ + "foo2": "bar2", + }, + }, + { + ID: piAdjID3, + PaymentInitiationID: defaultPaymentInitiations()[1].ID, + CreatedAt: now.Add(-7 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + Amount: big.NewInt(300), + Asset: pointer.For("DKK/2"), + Metadata: map[string]string{ + "foo3": "bar3", + }, + }, + } +} + +func upsertPaymentInitiationAdjustments(t *testing.T, ctx context.Context, storage Storage, adjustments []models.PaymentInitiationAdjustment) { + for _, adj := range adjustments { + require.NoError(t, storage.PaymentInitiationAdjustmentsUpsert(ctx, adj)) + } +} + +func TestPaymentInitiationAdjustmentsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationAdjustments(t, ctx, store, defaultPaymentInitiationAdjustments()) + + t.Run("upsert with unknown payment initiation", func(t *testing.T) { + p := models.PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{}, + PaymentInitiationID: models.PaymentInitiationID{}, + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION, + Metadata: map[string]string{ + "foo": "bar", + }, + } + + require.Error(t, store.PaymentInitiationAdjustmentsUpsert(ctx, p)) + }) + + t.Run("upsert with same id", func(t *testing.T) { + p := models.PaymentInitiationAdjustment{ + ID: piAdjID1, + PaymentInitiationID: defaultPaymentInitiationAdjustments()[0].PaymentInitiationID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED, + Metadata: map[string]string{ + "foo": "changed", + }, + } + + require.NoError(t, store.PaymentInitiationAdjustmentsUpsert(ctx, p)) + + for _, pa := range defaultPaymentInitiationAdjustments() { + actual, err := store.PaymentInitiationAdjustmentsGet(ctx, pa.ID) + require.NoError(t, err) + comparePaymentInitiationAdjustments(t, pa, *actual) + } + }) +} + +func TestPaymentInitiationAdjustmentsUpsertIfStatusEqual(t *testing.T) { + t.Parallel() + ctx := logging.TestingContext() + store := newStore(t) + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationAdjustments(t, ctx, store, defaultPaymentInitiationAdjustments()) + t.Run("upsert with status not equal", func(t *testing.T) { + p := models.PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: defaultPaymentInitiations()[1].ID, + CreatedAt: now.UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + }, + PaymentInitiationID: defaultPaymentInitiations()[1].ID, + CreatedAt: now.UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + Metadata: map[string]string{ + "foo": "bar", + }, + } + inserted, err := store.PaymentInitiationAdjustmentsUpsertIfPredicate( + ctx, + p, + func(previous models.PaymentInitiationAdjustment) bool { + return previous.Status == models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_WAITING_FOR_VALIDATION + }, + ) + require.NoError(t, err) + require.False(t, inserted) + }) + t.Run("upsert with status equal", func(t *testing.T) { + p := models.PaymentInitiationAdjustment{ + ID: models.PaymentInitiationAdjustmentID{ + PaymentInitiationID: defaultPaymentInitiations()[0].ID, + CreatedAt: now.UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + }, + PaymentInitiationID: defaultPaymentInitiations()[0].ID, + CreatedAt: now.UTC().Time, + Status: models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSING, + Metadata: map[string]string{ + "foo": "bar", + }, + } + inserted, err := store.PaymentInitiationAdjustmentsUpsertIfPredicate( + ctx, + p, + func(previous models.PaymentInitiationAdjustment) bool { + return previous.Status == models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_FAILED + }, + ) + require.NoError(t, err) + require.True(t, inserted) + }) +} + +func TestPaymentInitiationAdjustmentsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationAdjustments(t, ctx, store, defaultPaymentInitiationAdjustments()) + + t.Run("get unknown payment initiation adjustment", func(t *testing.T) { + _, err := store.PaymentInitiationAdjustmentsGet(ctx, models.PaymentInitiationAdjustmentID{}) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("get existing payment initiation adjustment", func(t *testing.T) { + for _, pa := range defaultPaymentInitiationAdjustments() { + actual, err := store.PaymentInitiationAdjustmentsGet(ctx, pa.ID) + require.NoError(t, err) + comparePaymentInitiationAdjustments(t, pa, *actual) + } + }) +} + +func TestPaymentInitiationAdjustmentsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + upsertPaymentInitiations(t, ctx, store, defaultPaymentInitiations()) + upsertPaymentInitiationAdjustments(t, ctx, store, defaultPaymentInitiationAdjustments()) + + t.Run("list payment initiation adjustments by unknown payment initiation", func(t *testing.T) { + cursor, err := store.PaymentInitiationAdjustmentsList( + ctx, + models.PaymentInitiationID{}, + NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationAdjustmentsQuery{}). + WithPageSize(15), + ), + ) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payment initiation adjustments by payment initiation", func(t *testing.T) { + q := NewListPaymentInitiationAdjustmentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentInitiationAdjustmentsQuery{}). + WithPageSize(1), + ) + + cursor, err := store.PaymentInitiationAdjustmentsList(ctx, defaultPaymentInitiationAdjustments()[0].PaymentInitiationID, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationAdjustments(t, defaultPaymentInitiationAdjustments()[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationAdjustmentsList(ctx, defaultPaymentInitiationAdjustments()[0].PaymentInitiationID, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + comparePaymentInitiationAdjustments(t, defaultPaymentInitiationAdjustments()[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentInitiationAdjustmentsList(ctx, defaultPaymentInitiationAdjustments()[0].PaymentInitiationID, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePaymentInitiationAdjustments(t, defaultPaymentInitiationAdjustments()[1], cursor.Data[0]) + }) +} + +func comparePaymentInitiations(t *testing.T, expected, actual models.PaymentInitiation) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.ConnectorID, actual.ConnectorID) + require.Equal(t, expected.Reference, actual.Reference) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.ScheduledAt, actual.ScheduledAt) + require.Equal(t, expected.Description, actual.Description) + require.Equal(t, expected.Type, actual.Type) + + switch { + case expected.SourceAccountID != nil && actual.SourceAccountID != nil: + require.Equal(t, *expected.SourceAccountID, *actual.SourceAccountID) + case expected.SourceAccountID == nil && actual.SourceAccountID == nil: + default: + t.Fatalf("expected.SourceAccountID != actual.SourceAccountID") + } + + require.Equal(t, expected.DestinationAccountID, actual.DestinationAccountID) + require.Equal(t, expected.Amount, actual.Amount) + require.Equal(t, expected.Asset, actual.Asset) + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } +} + +func comparePaymentInitiationAdjustments(t *testing.T, expected, actual models.PaymentInitiationAdjustment) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.PaymentInitiationID, actual.PaymentInitiationID) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Status, actual.Status) + + switch { + case expected.Error != nil && actual.Error != nil: + require.Equal(t, expected.Error.Error(), actual.Error.Error()) + case expected.Error == nil && actual.Error == nil: + default: + t.Fatalf("expected.Error != actual.Error") + } + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } +} diff --git a/internal/storage/payments.go b/internal/storage/payments.go new file mode 100644 index 00000000..8a5a988a --- /dev/null +++ b/internal/storage/payments.go @@ -0,0 +1,401 @@ +package storage + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math/big" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type payment struct { + bun.BaseModel `bun:"table:payments"` + + // Mandatory fields + ID models.PaymentID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + Reference string `bun:"reference,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Type models.PaymentType `bun:"type,type:text,notnull"` + InitialAmount *big.Int `bun:"initial_amount,type:numeric,notnull"` + Amount *big.Int `bun:"amount,type:numeric,notnull"` + Asset string `bun:"asset,type:text,notnull"` + Scheme models.PaymentScheme `bun:"scheme,type:text,notnull"` + + // Scan only fields + Status models.PaymentStatus `bun:"status,type:text,notnull,scanonly"` + + // Optional fields + // c.f.: https://bun.uptrace.dev/guide/models.html#nulls + SourceAccountID *models.AccountID `bun:"source_account_id,type:character varying,nullzero"` + DestinationAccountID *models.AccountID `bun:"destination_account_id,type:character varying,nullzero"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +type paymentAdjustment struct { + bun.BaseModel `bun:"table:payment_adjustments"` + + // Mandatory fields + ID models.PaymentAdjustmentID `bun:"id,pk,type:character varying,notnull"` + PaymentID models.PaymentID `bun:"payment_id,type:character varying,notnull"` + Reference string `bun:"reference,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + Status models.PaymentStatus `bun:"status,type:text,notnull"` + Raw json.RawMessage `bun:"raw,type:json,notnull"` + + // Optional fields + // c.f.: https://bun.uptrace.dev/guide/models.html#nulls + Amount *big.Int `bun:"amount,type:numeric,nullzero"` + Asset *string `bun:"asset,type:text,nullzero"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Metadata map[string]string `bun:"metadata,type:jsonb,nullzero,notnull,default:'{}'"` +} + +func (s *store) PaymentsUpsert(ctx context.Context, payments []models.Payment) error { + paymentsToInsert := make([]payment, 0, len(payments)) + adjustmentsToInsert := make([]paymentAdjustment, 0) + paymentsRefunded := make([]payment, 0) + paymentsInitialAmountToAdjust := make([]payment, 0) + paymentsCaptured := make([]payment, 0) + for _, p := range payments { + paymentsToInsert = append(paymentsToInsert, fromPaymentModels(p)) + + for _, a := range p.Adjustments { + adjustmentsToInsert = append(adjustmentsToInsert, fromPaymentAdjustmentModels(a)) + switch a.Status { + case models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT: + res := fromPaymentModels(p) + res.InitialAmount = a.Amount + paymentsInitialAmountToAdjust = append(paymentsInitialAmountToAdjust, res) + case models.PAYMENT_STATUS_REFUNDED: + res := fromPaymentModels(p) + res.Amount = a.Amount + paymentsRefunded = append(paymentsRefunded, res) + case models.PAYMENT_STATUS_CAPTURE, models.PAYMENT_STATUS_REFUND_REVERSED: + res := fromPaymentModels(p) + res.Amount = a.Amount + paymentsCaptured = append(paymentsCaptured, res) + } + } + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return errors.Wrap(err, "failed to create transaction") + } + defer tx.Rollback() + + if len(paymentsToInsert) > 0 { + _, err = tx.NewInsert(). + Model(&paymentsToInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert payments", err) + } + } + + if len(paymentsInitialAmountToAdjust) > 0 { + _, err = tx.NewInsert(). + Model(&paymentsInitialAmountToAdjust). + On("CONFLICT (id) DO UPDATE"). + Set("initial_amount = EXCLUDED.initial_amount"). + Exec(ctx) + if err != nil { + return e("failed to update payment", err) + } + } + + if len(paymentsCaptured) > 0 { + _, err = tx.NewInsert(). + Model(&paymentsCaptured). + On("CONFLICT (id) DO UPDATE"). + Set("amount = payment.amount + EXCLUDED.amount"). + Exec(ctx) + if err != nil { + return e("failed to update payment", err) + } + } + + if len(paymentsRefunded) > 0 { + _, err = tx.NewInsert(). + Model(&paymentsRefunded). + On("CONFLICT (id) DO UPDATE"). + Set("amount = payment.amount - EXCLUDED.amount"). + Exec(ctx) + if err != nil { + return e("failed to update payment", err) + } + } + + if len(adjustmentsToInsert) > 0 { + _, err = tx.NewInsert(). + Model(&adjustmentsToInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("failed to insert adjustments", err) + } + } + + return e("failed to commit transactions", tx.Commit()) +} + +func (s *store) PaymentsUpdateMetadata(ctx context.Context, id models.PaymentID, metadata map[string]string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("update payment metadata", err) + } + defer tx.Rollback() + + var payment payment + err = tx.NewSelect(). + Model(&payment). + Column("id", "metadata"). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return e("update payment metadata", err) + } + + if payment.Metadata == nil { + payment.Metadata = make(map[string]string) + } + + for k, v := range metadata { + payment.Metadata[k] = v + } + + _, err = tx.NewUpdate(). + Model(&payment). + Column("metadata"). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return e("update payment metadata", err) + } + + return e("failed to commit transaction", tx.Commit()) +} + +func (s *store) PaymentsGet(ctx context.Context, id models.PaymentID) (*models.Payment, error) { + var payment payment + + err := s.db.NewSelect(). + Model(&payment). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to get payment", err) + } + + var ajs []paymentAdjustment + err = s.db.NewSelect(). + Model(&ajs). + Where("payment_id = ?", id). + Order("created_at DESC", "sort_id DESC"). + Scan(ctx) + if err != nil { + return nil, e("failed to get payment adjustments", err) + } + + adjustments := make([]models.PaymentAdjustment, 0, len(ajs)) + for _, a := range ajs { + adjustments = append(adjustments, toPaymentAdjustmentModels(a)) + } + + status := models.PAYMENT_STATUS_PENDING + if len(adjustments) > 0 { + status = adjustments[len(adjustments)-1].Status + } + res := toPaymentModels(payment, status) + res.Adjustments = adjustments + return &res, nil +} + +func (s *store) PaymentsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*payment)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete payments", err) +} + +type PaymentQuery struct{} + +type ListPaymentsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentQuery]] + +func NewListPaymentsQuery(opts bunpaginate.PaginatedQueryOptions[PaymentQuery]) ListPaymentsQuery { + return ListPaymentsQuery{ + PageSize: opts.PageSize, + Order: bunpaginate.OrderAsc, + Options: opts, + } +} + +func (s *store) paymentsQueryContext(qb query.Builder) (string, []any, error) { + where, args, err := qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "reference", + key == "connector_id", + key == "type", + key == "asset", + key == "scheme", + key == "status", + key == "source_account_id", + key == "destination_account_id": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'type' column can only be used with $match") + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + + case key == "initial_amount", + key == "amount": + return fmt.Sprintf("%s %s ?", key, query.DefaultComparisonOperatorsMapping[operator]), []any{value}, nil + case metadataRegex.Match([]byte(key)): + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + match := metadataRegex.FindAllStringSubmatch(key, 3) + + key := "metadata" + return key + " @> ?", []any{map[string]any{ + match[0][1]: value, + }}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) + + return where, args, err +} + +func (s *store) PaymentsList(ctx context.Context, q ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.paymentsQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + // TODO(polo): should fetch the adjustments and get the last status and amount? + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PaymentQuery], payment](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PaymentQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + query.Column("payment.*", "apd.status"). + Join(`join lateral ( + select status + from payment_adjustments apd + where payment_id = payment.id + order by created_at desc + limit 1 + ) apd on true`) + + // TODO(polo): sorter ? + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch payments", err) + } + + payments := make([]models.Payment, 0, len(cursor.Data)) + for _, p := range cursor.Data { + payments = append(payments, toPaymentModels(p, p.Status)) + } + + return &bunpaginate.Cursor[models.Payment]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: payments, + }, nil +} + +func fromPaymentModels(from models.Payment) payment { + return payment{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Reference: from.Reference, + CreatedAt: time.New(from.CreatedAt), + Type: from.Type, + InitialAmount: from.InitialAmount, + Amount: from.Amount, + Asset: from.Asset, + Scheme: from.Scheme, + SourceAccountID: from.SourceAccountID, + DestinationAccountID: from.DestinationAccountID, + Metadata: from.Metadata, + } +} + +func toPaymentModels(payment payment, status models.PaymentStatus) models.Payment { + return models.Payment{ + ID: payment.ID, + ConnectorID: payment.ConnectorID, + InitialAmount: payment.InitialAmount, + Reference: payment.Reference, + CreatedAt: payment.CreatedAt.Time, + Type: payment.Type, + Amount: payment.Amount, + Asset: payment.Asset, + Scheme: payment.Scheme, + Status: status, + SourceAccountID: payment.SourceAccountID, + DestinationAccountID: payment.DestinationAccountID, + Metadata: payment.Metadata, + } +} + +func fromPaymentAdjustmentModels(from models.PaymentAdjustment) paymentAdjustment { + return paymentAdjustment{ + ID: from.ID, + PaymentID: from.PaymentID, + Reference: from.Reference, + CreatedAt: time.New(from.CreatedAt), + Status: from.Status, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + Raw: from.Raw, + } +} + +func toPaymentAdjustmentModels(from paymentAdjustment) models.PaymentAdjustment { + return models.PaymentAdjustment{ + ID: from.ID, + PaymentID: from.PaymentID, + Reference: from.Reference, + CreatedAt: from.CreatedAt.Time, + Status: from.Status, + Amount: from.Amount, + Asset: from.Asset, + Metadata: from.Metadata, + Raw: from.Raw, + } +} diff --git a/internal/storage/payments_test.go b/internal/storage/payments_test.go new file mode 100644 index 00000000..84acd582 --- /dev/null +++ b/internal/storage/payments_test.go @@ -0,0 +1,1077 @@ +package storage + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + pID1 = models.PaymentID{ + PaymentReference: models.PaymentReference{ + Reference: "test1", + Type: models.PAYMENT_TYPE_TRANSFER, + }, + ConnectorID: defaultConnector.ID, + } + + pid2 = models.PaymentID{ + PaymentReference: models.PaymentReference{ + Reference: "test2", + Type: models.PAYMENT_TYPE_PAYIN, + }, + ConnectorID: defaultConnector.ID, + } + + pid3 = models.PaymentID{ + PaymentReference: models.PaymentReference{ + Reference: "test3", + Type: models.PAYMENT_TYPE_PAYOUT, + }, + ConnectorID: defaultConnector.ID, + } +) + +func defaultPayments() []models.Payment { + defaultAccounts := defaultAccounts() + return []models.Payment{ + { + ID: pID1, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_TRANSFER, + InitialAmount: big.NewInt(100), + Amount: big.NewInt(100), + Asset: "USD/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + SourceAccountID: &defaultAccounts[0].ID, + DestinationAccountID: &defaultAccounts[1].ID, + Metadata: map[string]string{ + "key1": "value1", + }, + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(100), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + }, + { + ID: pid2, + ConnectorID: defaultConnector.ID, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_PAYIN, + InitialAmount: big.NewInt(200), + Amount: big.NewInt(200), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + DestinationAccountID: &defaultAccounts[0].ID, + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pid2, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_FAILED, + }, + PaymentID: pid2, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_FAILED, + Amount: big.NewInt(200), + Asset: pointer.For("EUR/2"), + Raw: []byte(`{}`), + }, + }, + }, + { + ID: pid3, + ConnectorID: defaultConnector.ID, + Reference: "test3", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_PAYOUT, + InitialAmount: big.NewInt(300), + Amount: big.NewInt(300), + Asset: "DKK/2", + Scheme: models.PAYMENT_SCHEME_A2A, + SourceAccountID: &defaultAccounts[1].ID, + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pid3, + Reference: "another_reference", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_PENDING, + }, + PaymentID: pid3, + Reference: "another_reference", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_PENDING, + Amount: big.NewInt(300), + Asset: pointer.For("DKK/2"), + Raw: []byte(`{}`), + }, + }, + }, + } +} + +func upsertPayments(t *testing.T, ctx context.Context, storage Storage, payments []models.Payment) { + require.NoError(t, storage.PaymentsUpsert(ctx, payments)) +} + +func TestPaymentsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + + t.Run("upsert with unknown connector", func(t *testing.T) { + connector := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + payments := defaultPayments() + p := payments[0] + p.ID = models.PaymentID{ + PaymentReference: models.PaymentReference{ + Reference: "test4", + Type: models.PAYMENT_TYPE_PAYOUT, + }, + ConnectorID: connector, + } + p.ConnectorID = connector + + err := store.PaymentsUpsert(ctx, []models.Payment{p}) + require.Error(t, err) + }) + + t.Run("upsert with same id", func(t *testing.T) { + payments := defaultPayments() + p := payments[2] + p.Amount = big.NewInt(200) + p.Scheme = models.PAYMENT_SCHEME_A2A + upsertPayments(t, ctx, store, []models.Payment{p}) + + // should not have changed + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + + comparePayments(t, payments[2], *actual) + }) + + t.Run("upsert with different adjustments", func(t *testing.T) { + p := models.Payment{ + ID: pid3, + ConnectorID: defaultConnector.ID, + Reference: "test3", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_PAYOUT, + InitialAmount: big.NewInt(300), + Amount: big.NewInt(300), + Asset: "DKK/2", + Scheme: models.PAYMENT_SCHEME_A2A, + SourceAccountID: &defaultAccounts()[1].ID, + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pid3, + Reference: "another_reference2", + CreatedAt: now.Add(-45 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + }, + PaymentID: pid3, + Reference: "another_reference2", + CreatedAt: now.Add(-45 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(300), + Asset: pointer.For("DKK/2"), + Metadata: map[string]string{}, + Raw: []byte(`{}`), + }, + { + ID: models.PaymentAdjustmentID{ + PaymentID: pid3, + Reference: "another_reference", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_PENDING, + }, + PaymentID: pid3, + Reference: "another_reference", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_PENDING, + Amount: big.NewInt(300), + Asset: pointer.For("DKK/2"), + Raw: []byte(`{}`), + }, + }, + } + + upsertPayments(t, ctx, store, []models.Payment{p}) + + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + comparePayments(t, p, *actual) + }) + + t.Run("upsert with refund", func(t *testing.T) { + p := models.Payment{ + ID: pID1, + ConnectorID: defaultConnector.ID, + InitialAmount: big.NewInt(0), + Amount: big.NewInt(0), + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUNDED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUNDED, + Amount: big.NewInt(50), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + } + + upsertPayments(t, ctx, store, []models.Payment{p}) + + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + + expectedPayments := models.Payment{ + ID: pID1, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_TRANSFER, + InitialAmount: big.NewInt(100), + Amount: big.NewInt(50), + Asset: "USD/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + SourceAccountID: &defaultAccounts()[0].ID, + DestinationAccountID: &defaultAccounts()[1].ID, + Metadata: map[string]string{ + "key1": "value1", + }, + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUNDED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUNDED, + Amount: big.NewInt(50), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(100), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + } + + comparePayments(t, expectedPayments, *actual) + }) + + t.Run("upsert with reversed refund", func(t *testing.T) { + p := models.Payment{ + ID: pID1, + ConnectorID: defaultConnector.ID, + InitialAmount: big.NewInt(0), + Amount: big.NewInt(0), + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUND_REVERSED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUND_REVERSED, + Amount: big.NewInt(50), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + } + + upsertPayments(t, ctx, store, []models.Payment{p}) + + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + + expectedPayments := models.Payment{ + ID: pID1, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_TRANSFER, + InitialAmount: big.NewInt(100), + Amount: big.NewInt(100), + Asset: "USD/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_REFUNDED, + SourceAccountID: &defaultAccounts()[0].ID, + DestinationAccountID: &defaultAccounts()[1].ID, + Metadata: map[string]string{ + "key1": "value1", + }, + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUND_REVERSED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-10 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUND_REVERSED, + Amount: big.NewInt(50), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUNDED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-20 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_REFUNDED, + Amount: big.NewInt(50), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_SUCCEEDED, + Amount: big.NewInt(100), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + } + + comparePayments(t, expectedPayments, *actual) + }) + + t.Run("upsert with amount adjustment", func(t *testing.T) { + p := models.Payment{ + ID: pID1, + ConnectorID: defaultConnector.ID, + InitialAmount: big.NewInt(0), + Amount: big.NewInt(0), + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-5 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-5 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_AMOUNT_ADJUSTEMENT, + Amount: big.NewInt(150), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + } + + upsertPayments(t, ctx, store, []models.Payment{p}) + + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + require.Equal(t, big.NewInt(150), actual.InitialAmount) + }) + + t.Run("upsert with capture", func(t *testing.T) { + p := models.Payment{ + ID: pID1, + ConnectorID: defaultConnector.ID, + InitialAmount: big.NewInt(0), + Amount: big.NewInt(0), + Adjustments: []models.PaymentAdjustment{ + { + ID: models.PaymentAdjustmentID{ + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-3 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_CAPTURE, + }, + PaymentID: pID1, + Reference: "test1", + CreatedAt: now.Add(-3 * time.Minute).UTC().Time, + Status: models.PAYMENT_STATUS_CAPTURE, + Amount: big.NewInt(50), + Asset: pointer.For("USD/2"), + Raw: []byte(`{}`), + }, + }, + } + + upsertPayments(t, ctx, store, []models.Payment{p}) + + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + require.Equal(t, big.NewInt(150), actual.Amount) + }) +} + +func TestPaymentsUpdateMetadata(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + + t.Run("update metadata of unknown payment", func(t *testing.T) { + require.Error(t, store.PaymentsUpdateMetadata(ctx, models.PaymentID{ + PaymentReference: models.PaymentReference{Reference: "unknown", Type: models.PAYMENT_TYPE_TRANSFER}, + ConnectorID: defaultConnector.ID, + }, map[string]string{})) + }) + + t.Run("update existing metadata", func(t *testing.T) { + metadata := map[string]string{ + "key1": "changed", + } + payments := defaultPayments() + require.NoError(t, store.PaymentsUpdateMetadata(ctx, payments[0].ID, metadata)) + + actual, err := store.PaymentsGet(ctx, payments[0].ID) + require.NoError(t, err) + require.Equal(t, len(metadata), len(actual.Metadata)) + for k, v := range metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } + }) + + t.Run("add new metadata", func(t *testing.T) { + metadata := map[string]string{ + "key2": "value2", + "key3": "value3", + } + + payments := defaultPayments() + require.NoError(t, store.PaymentsUpdateMetadata(ctx, payments[1].ID, metadata)) + + actual, err := store.PaymentsGet(ctx, payments[1].ID) + require.NoError(t, err) + require.Equal(t, len(metadata), len(actual.Metadata)) + for k, v := range metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } + }) +} + +func TestPaymentsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + + t.Run("get unknown payment", func(t *testing.T) { + _, err := store.PaymentsGet(ctx, models.PaymentID{ + PaymentReference: models.PaymentReference{Reference: "unknown", Type: models.PAYMENT_TYPE_TRANSFER}, + ConnectorID: defaultConnector.ID, + }) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("get existing payments", func(t *testing.T) { + for _, p := range defaultPayments() { + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + comparePayments(t, p, *actual) + } + }) +} + +func TestPaymentsDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + + t.Run("delete from unknown connector", func(t *testing.T) { + require.NoError(t, store.PaymentsDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, p := range defaultPayments() { + actual, err := store.PaymentsGet(ctx, p.ID) + require.NoError(t, err) + comparePayments(t, p, *actual) + } + }) + + t.Run("delete from existing connector", func(t *testing.T) { + require.NoError(t, store.PaymentsDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, p := range defaultPayments() { + _, err := store.PaymentsGet(ctx, p.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} + +func TestPaymentsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPayments(t, ctx, store, defaultPayments()) + + dps := []models.Payment{ + { + ID: pID1, + ConnectorID: defaultConnector.ID, + Reference: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_TRANSFER, + InitialAmount: big.NewInt(100), + Amount: big.NewInt(100), + Asset: "USD/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_SUCCEEDED, + SourceAccountID: &defaultAccounts()[0].ID, + DestinationAccountID: &defaultAccounts()[1].ID, + Metadata: map[string]string{ + "key1": "value1", + }, + }, + { + ID: pid2, + ConnectorID: defaultConnector.ID, + Reference: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_PAYIN, + InitialAmount: big.NewInt(200), + Amount: big.NewInt(200), + Asset: "EUR/2", + Scheme: models.PAYMENT_SCHEME_OTHER, + Status: models.PAYMENT_STATUS_FAILED, + DestinationAccountID: &defaultAccounts()[0].ID, + }, + { + ID: pid3, + ConnectorID: defaultConnector.ID, + Reference: "test3", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + Type: models.PAYMENT_TYPE_PAYOUT, + InitialAmount: big.NewInt(300), + Amount: big.NewInt(300), + Asset: "DKK/2", + Scheme: models.PAYMENT_SCHEME_A2A, + Status: models.PAYMENT_STATUS_PENDING, + SourceAccountID: &defaultAccounts()[1].ID, + }, + } + + t.Run("list payments by reference", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "test1")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[0], cursor.Data[0]) + }) + + t.Run("list payments by unknown reference", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("reference", "unknown")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by connector_id", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector.ID.String())), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 3) + require.False(t, cursor.HasMore) + comparePayments(t, dps[1], cursor.Data[0]) + comparePayments(t, dps[2], cursor.Data[1]) + comparePayments(t, dps[0], cursor.Data[2]) + }) + + t.Run("list payments by unknown connector_id", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", "unknown")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by type", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", "PAYOUT")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[2], cursor.Data[0]) + }) + + t.Run("list payments by unknown type", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("type", "UNKNOWN")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by asset", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "EUR/2")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[1], cursor.Data[0]) + }) + + t.Run("list payments by unknown asset", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("asset", "UNKNOWN")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by scheme", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("scheme", "OTHER")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 2) + require.False(t, cursor.HasMore) + comparePayments(t, dps[1], cursor.Data[0]) + comparePayments(t, dps[0], cursor.Data[1]) + }) + + t.Run("list payments by unknown scheme", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("scheme", "UNKNOWN")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by status", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("status", "PENDING")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[2], cursor.Data[0]) + }) + + t.Run("list payments by unknown status", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("status", "UNKNOWN")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by source account id", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("source_account_id", defaultAccounts()[0].ID.String())), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[0], cursor.Data[0]) + }) + + t.Run("list payments by unknown source account id", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("source_account_id", "unknown")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by destination account id", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("destination_account_id", defaultAccounts()[0].ID.String())), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[1], cursor.Data[0]) + }) + + t.Run("list payments by unknown destination account id", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("destination_account_id", "unknown")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by amount", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("amount", 200)), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[1], cursor.Data[0]) + }) + + t.Run("list payments by unknown amount", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("amount", 0)), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by initial_amount", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("initial_amount", 300)), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[2], cursor.Data[0]) + }) + + t.Run("list payments by unknown initial_amount", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("initial_amount", 0)), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by metadata", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[key1]", "value1")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + comparePayments(t, dps[0], cursor.Data[0]) + }) + + t.Run("list payments by unknown metadata", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[key1]", "unknown")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments by unknown metadata 2", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("metadata[unknown]", "unknown")), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 0) + require.False(t, cursor.HasMore) + }) + + t.Run("list payments test cursor", func(t *testing.T) { + q := NewListPaymentsQuery( + bunpaginate.NewPaginatedQueryOptions(PaymentQuery{}). + WithPageSize(1), + ) + + cursor, err := store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, dps[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, dps[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + comparePayments(t, dps[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, dps[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PaymentsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + comparePayments(t, dps[1], cursor.Data[0]) + }) +} + +func comparePayments(t *testing.T, expected, actual models.Payment) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.ConnectorID, actual.ConnectorID) + require.Equal(t, expected.Reference, actual.Reference) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Type, actual.Type) + require.Equal(t, expected.InitialAmount, actual.InitialAmount) + require.Equal(t, expected.Amount, actual.Amount) + require.Equal(t, expected.Asset, actual.Asset) + require.Equal(t, expected.Scheme, actual.Scheme) + + switch { + case expected.SourceAccountID == nil: + require.Nil(t, actual.SourceAccountID) + default: + require.NotNil(t, actual.SourceAccountID) + require.Equal(t, *expected.SourceAccountID, *actual.SourceAccountID) + } + + switch { + case expected.DestinationAccountID == nil: + require.Nil(t, actual.DestinationAccountID) + default: + require.NotNil(t, actual.DestinationAccountID) + require.Equal(t, *expected.DestinationAccountID, *actual.DestinationAccountID) + } + + require.Equal(t, len(expected.Metadata), len(actual.Metadata)) + for k, v := range expected.Metadata { + _, ok := actual.Metadata[k] + require.True(t, ok) + require.Equal(t, v, actual.Metadata[k]) + } + + require.Equal(t, len(expected.Adjustments), len(actual.Adjustments)) + for i := range expected.Adjustments { + comparePaymentAdjustments(t, expected.Adjustments[i], actual.Adjustments[i]) + } +} + +func comparePaymentAdjustments(t *testing.T, expected, actual models.PaymentAdjustment) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.PaymentID, actual.PaymentID) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.Status, actual.Status) + require.Equal(t, expected.Amount, actual.Amount) + require.Equal(t, expected.Asset, actual.Asset) +} diff --git a/internal/storage/pools.go b/internal/storage/pools.go new file mode 100644 index 00000000..f782f6ae --- /dev/null +++ b/internal/storage/pools.go @@ -0,0 +1,284 @@ +package storage + +import ( + "context" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type pool struct { + bun.BaseModel `bun:"table:pools"` + + // Mandatory fields + ID uuid.UUID `bun:"id,pk,type:uuid,notnull"` + Name string `bun:"name,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + + PoolAccounts []*poolAccounts `bun:"rel:has-many,join:id=pool_id"` +} + +type poolAccounts struct { + bun.BaseModel `bun:"table:pool_accounts"` + + PoolID uuid.UUID `bun:"pool_id,pk,type:uuid,notnull"` + AccountID models.AccountID `bun:"account_id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` +} + +func (s *store) PoolsUpsert(ctx context.Context, pool models.Pool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("begin transaction: %w", err) + } + defer tx.Rollback() + + poolToInsert, accountsToInsert := fromPoolModel(pool) + + for i := range accountsToInsert { + exists, err := tx.NewSelect(). + Model((*account)(nil)). + Where("id = ?", accountsToInsert[i].AccountID). + Limit(1). + Exists(ctx) + if err != nil { + return e("check account exists: %w", err) + } + + if !exists { + return e("account does not exist: %w", ErrNotFound) + } + } + + _, err = tx.NewInsert(). + Model(&poolToInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("insert pool: %w", err) + } + + _, err = tx.NewInsert(). + Model(&accountsToInsert). + On("CONFLICT (pool_id, account_id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("insert pool accounts: %w", err) + } + + return e("commit transaction: %w", tx.Commit()) +} + +func (s *store) PoolsGet(ctx context.Context, id uuid.UUID) (*models.Pool, error) { + var pool pool + err := s.db.NewSelect(). + Model(&pool). + Relation("PoolAccounts"). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("get pool: %w", err) + } + + return pointer.For(toPoolModel(pool)), nil +} + +func (s *store) PoolsDelete(ctx context.Context, id uuid.UUID) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("begin transaction: %w", err) + } + defer tx.Rollback() + + _, err = tx.NewDelete(). + Model((*pool)(nil)). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return e("delete pool: %w", err) + } + + _, err = tx.NewDelete(). + Model((*poolAccounts)(nil)). + Where("pool_id = ?", id). + Exec(ctx) + if err != nil { + return e("delete pool accounts: %w", err) + } + + return e("commit transaction: %w", tx.Commit()) +} + +func (s *store) PoolsAddAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return e("begin transaction: %w", err) + } + defer tx.Rollback() + + exists, err := tx.NewSelect(). + Model((*account)(nil)). + Where("id = ?", accountID). + Limit(1). + Exists(ctx) + if err != nil { + return e("check account exists: %w", err) + } + + if !exists { + return e("account does not exist: %w", ErrNotFound) + } + + _, err = tx.NewInsert(). + Model(&poolAccounts{ + PoolID: id, + AccountID: accountID, + ConnectorID: accountID.ConnectorID, + }). + On("CONFLICT (pool_id, account_id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("insert pool account: %w", err) + } + + return e("commit transaction: %w", tx.Commit()) +} + +func (s *store) PoolsRemoveAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + _, err := s.db.NewDelete(). + Model((*poolAccounts)(nil)). + Where("pool_id = ? AND account_id = ?", id, accountID). + Exec(ctx) + if err != nil { + return e("delete pool account: %w", err) + } + return nil +} + +func (s *store) PoolsRemoveAccountsFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*poolAccounts)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + if err != nil { + return e("delete pool accounts: %w", err) + } + return nil +} + +type PoolQuery struct{} + +type ListPoolsQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PoolQuery]] + +func NewListPoolsQuery(opts bunpaginate.PaginatedQueryOptions[PoolQuery]) ListPoolsQuery { + return ListPoolsQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) poolsQueryContext(qb query.Builder) (string, []any, error) { + return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "name": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'metadata' column can only be used with $match") + } + + return fmt.Sprintf("%s = ?", key), []any{value}, nil + // TODO(polo): add filters for accounts ID + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) +} + +func (s *store) PoolsList(ctx context.Context, q ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.poolsQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[PoolQuery], pool](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[PoolQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + query = query. + Relation("PoolAccounts") + + if where != "" { + query = query.Where(where, args...) + } + + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch pools", err) + } + + pools := make([]models.Pool, 0, len(cursor.Data)) + for _, p := range cursor.Data { + pools = append(pools, toPoolModel(p)) + } + + return &bunpaginate.Cursor[models.Pool]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: pools, + }, nil +} + +func fromPoolModel(from models.Pool) (pool, []poolAccounts) { + p := pool{ + ID: from.ID, + Name: from.Name, + CreatedAt: time.New(from.CreatedAt), + } + + var accounts []poolAccounts + for _, pa := range from.PoolAccounts { + accounts = append(accounts, poolAccounts{ + PoolID: pa.PoolID, + AccountID: pa.AccountID, + ConnectorID: pa.AccountID.ConnectorID, + }) + } + + return p, accounts +} + +func toPoolModel(from pool) models.Pool { + var accounts []models.PoolAccounts + for _, pa := range from.PoolAccounts { + accounts = append(accounts, models.PoolAccounts{ + PoolID: pa.PoolID, + AccountID: pa.AccountID, + }) + } + + return models.Pool{ + ID: from.ID, + Name: from.Name, + CreatedAt: from.CreatedAt.Time, + PoolAccounts: accounts, + } +} diff --git a/internal/storage/pools_test.go b/internal/storage/pools_test.go new file mode 100644 index 00000000..1dc21537 --- /dev/null +++ b/internal/storage/pools_test.go @@ -0,0 +1,409 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + poolID1 = uuid.New() + poolID2 = uuid.New() + poolID3 = uuid.New() +) + +func defaultPools() []models.Pool { + defaultAccounts := defaultAccounts() + return []models.Pool{ + { + ID: poolID1, + Name: "test1", + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + PoolAccounts: []models.PoolAccounts{ + { + PoolID: poolID1, + AccountID: defaultAccounts[0].ID, + }, + { + PoolID: poolID1, + AccountID: defaultAccounts[1].ID, + }, + }, + }, + { + ID: poolID2, + Name: "test2", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + PoolAccounts: []models.PoolAccounts{ + { + PoolID: poolID2, + AccountID: defaultAccounts[2].ID, + }, + }, + }, + { + ID: poolID3, + Name: "test3", + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + PoolAccounts: []models.PoolAccounts{ + { + PoolID: poolID3, + AccountID: defaultAccounts[2].ID, + }, + }, + }, + } +} + +func upsertPool(t *testing.T, ctx context.Context, storage Storage, pool models.Pool) { + require.NoError(t, storage.PoolsUpsert(ctx, pool)) +} + +func TestPoolsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + upsertPool(t, ctx, store, defaultPools()[1]) + + t.Run("upsert with same name", func(t *testing.T) { + poolID3 := uuid.New() + p := models.Pool{ + ID: poolID3, + Name: "test1", + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + PoolAccounts: []models.PoolAccounts{ + { + PoolID: poolID3, + AccountID: defaultAccounts()[2].ID, + }, + }, + } + + err := store.PoolsUpsert(ctx, p) + require.Error(t, err) + }) + + t.Run("upsert with same id", func(t *testing.T) { + upsertPool(t, ctx, store, defaultPools()[1]) + + actual, err := store.PoolsGet(ctx, defaultPools()[1].ID) + require.NoError(t, err) + require.Equal(t, defaultPools()[1], *actual) + }) + + t.Run("upsert with same id but more related accounts", func(t *testing.T) { + p := defaultPools()[0] + p.PoolAccounts = append(p.PoolAccounts, models.PoolAccounts{ + PoolID: p.ID, + AccountID: defaultAccounts()[2].ID, + }) + + upsertPool(t, ctx, store, p) + + actual, err := store.PoolsGet(ctx, defaultPools()[0].ID) + require.NoError(t, err) + require.Equal(t, p, *actual) + }) + + t.Run("upsert with same id, but wrong related account pool id", func(t *testing.T) { + p := defaultPools()[0] + p.PoolAccounts = append(p.PoolAccounts, models.PoolAccounts{ + PoolID: uuid.New(), + AccountID: defaultAccounts()[2].ID, + }) + + err := store.PoolsUpsert(ctx, p) + require.Error(t, err) + }) + + t.Run("upsert but account does not exist", func(t *testing.T) { + p := defaultPools()[0] + p.PoolAccounts = append(p.PoolAccounts, models.PoolAccounts{ + PoolID: p.ID, + AccountID: models.AccountID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + }, + }) + + err := store.PoolsUpsert(ctx, p) + require.Error(t, err) + }) +} + +func TestPoolsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + upsertPool(t, ctx, store, defaultPools()[1]) + upsertPool(t, ctx, store, defaultPools()[2]) + + t.Run("get existing pool", func(t *testing.T) { + for _, p := range defaultPools() { + actual, err := store.PoolsGet(ctx, p.ID) + require.NoError(t, err) + require.Equal(t, p, *actual) + } + }) + + t.Run("get non-existing pool", func(t *testing.T) { + p, err := store.PoolsGet(ctx, uuid.New()) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, p) + }) +} + +func TestPoolsDelete(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + upsertPool(t, ctx, store, defaultPools()[1]) + upsertPool(t, ctx, store, defaultPools()[2]) + + t.Run("delete unknown pool", func(t *testing.T) { + require.NoError(t, store.PoolsDelete(ctx, uuid.New())) + for _, p := range defaultPools() { + actual, err := store.PoolsGet(ctx, p.ID) + require.NoError(t, err) + require.Equal(t, p, *actual) + } + }) + + t.Run("delete existing pool", func(t *testing.T) { + require.NoError(t, store.PoolsDelete(ctx, defaultPools()[0].ID)) + + _, err := store.PoolsGet(ctx, defaultPools()[0].ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + + actual, err := store.PoolsGet(ctx, defaultPools()[1].ID) + require.NoError(t, err) + require.Equal(t, defaultPools()[1], *actual) + }) +} + +func TestPoolsAddAccount(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + upsertPool(t, ctx, store, defaultPools()[1]) + + t.Run("add account to unknown pool", func(t *testing.T) { + err := store.PoolsAddAccount(ctx, uuid.New(), defaultAccounts()[0].ID) + require.Error(t, err) + }) + + t.Run("add account to pool", func(t *testing.T) { + require.NoError(t, store.PoolsAddAccount(ctx, defaultPools()[0].ID, defaultAccounts()[2].ID)) + + p := defaultPools()[0] + p.PoolAccounts = append(p.PoolAccounts, models.PoolAccounts{ + PoolID: p.ID, + AccountID: defaultAccounts()[2].ID, + }) + + actual, err := store.PoolsGet(ctx, defaultPools()[0].ID) + require.NoError(t, err) + require.Equal(t, p, *actual) + }) + + t.Run("add account to pool but account does not exist", func(t *testing.T) { + err := store.PoolsAddAccount(ctx, defaultPools()[0].ID, models.AccountID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + }) + require.Error(t, err) + }) +} + +func TestPoolsRemoveAccount(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + upsertPool(t, ctx, store, defaultPools()[1]) + + t.Run("remove unknown account from pool", func(t *testing.T) { + require.NoError(t, store.PoolsRemoveAccount(ctx, defaultPools()[0].ID, models.AccountID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + })) + }) + + t.Run("remove account from unknown pool", func(t *testing.T) { + require.NoError(t, store.PoolsRemoveAccount(ctx, uuid.New(), defaultAccounts()[0].ID)) + }) + + t.Run("remove account from pool", func(t *testing.T) { + require.NoError(t, store.PoolsRemoveAccount(ctx, defaultPools()[0].ID, defaultAccounts()[1].ID)) + + p := defaultPools()[0] + p.PoolAccounts = p.PoolAccounts[:1] + + actual, err := store.PoolsGet(ctx, defaultPools()[0].ID) + require.NoError(t, err) + require.Equal(t, p, *actual) + }) +} + +func TestPoolsRemoveAccountFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + + t.Run("remove accounts from unknown connector", func(t *testing.T) { + require.NoError(t, store.PoolsRemoveAccountsFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + actual, err := store.PoolsGet(ctx, defaultPools()[0].ID) + require.NoError(t, err) + require.Equal(t, defaultPools()[0], *actual) + }) + + t.Run("remove accounts from connector", func(t *testing.T) { + require.NoError(t, store.PoolsRemoveAccountsFromConnectorID(ctx, defaultConnector.ID)) + + actual, err := store.PoolsGet(ctx, defaultPools()[0].ID) + require.NoError(t, err) + require.Len(t, actual.PoolAccounts, 0) + }) +} + +func TestPoolsList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertAccounts(t, ctx, store, defaultAccounts()) + upsertPool(t, ctx, store, defaultPools()[0]) + upsertPool(t, ctx, store, defaultPools()[1]) + upsertPool(t, ctx, store, defaultPools()[2]) + + t.Run("list pools by name", func(t *testing.T) { + q := NewListPoolsQuery( + bunpaginate.NewPaginatedQueryOptions(PoolQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "test1")), + ) + + cursor, err := store.PoolsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, []models.Pool{defaultPools()[0]}, cursor.Data) + }) + + t.Run("list pools by unknown name", func(t *testing.T) { + q := NewListPoolsQuery( + bunpaginate.NewPaginatedQueryOptions(PoolQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("name", "unknown")), + ) + + cursor, err := store.PoolsList(ctx, q) + require.NoError(t, err) + require.Empty(t, cursor.Data) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list pools test cursor", func(t *testing.T) { + q := NewListPoolsQuery( + bunpaginate.NewPaginatedQueryOptions(PoolQuery{}). + WithPageSize(1), + ) + defaultPools := defaultPools() + + cursor, err := store.PoolsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Pool{defaultPools[1]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PoolsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Pool{defaultPools[2]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.PoolsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, []models.Pool{defaultPools[0]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PoolsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Pool{defaultPools[2]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.PoolsList(ctx, q) + require.NoError(t, err) + require.Len(t, cursor.Data, 1) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Pool{defaultPools[1]}, cursor.Data) + }) +} diff --git a/internal/storage/schedules.go b/internal/storage/schedules.go new file mode 100644 index 00000000..d2bc86e7 --- /dev/null +++ b/internal/storage/schedules.go @@ -0,0 +1,150 @@ +package storage + +import ( + "context" + "fmt" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type schedule struct { + bun.BaseModel `bun:"table:schedules"` + + // Mandatory fields + ID string `bun:"id,pk,type:text,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,pk,type:character varying,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` +} + +func (s *store) SchedulesUpsert(ctx context.Context, schedule models.Schedule) error { + toInsert := fromScheduleModel(schedule) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id, connector_id) DO NOTHING"). + Exec(ctx) + + return e("failed to insert schedule", err) +} + +func (s *store) SchedulesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*schedule)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete schedule", err) +} + +func (s *store) SchedulesDelete(ctx context.Context, id string) error { + _, err := s.db.NewDelete(). + Model((*schedule)(nil)). + Where("id = ?", id). + Exec(ctx) + + return e("failed to delete schedule", err) +} + +func (s *store) SchedulesGet(ctx context.Context, id string, connectorID models.ConnectorID) (*models.Schedule, error) { + var schedule schedule + err := s.db.NewSelect(). + Model(&schedule). + Where("id = ? AND connector_id = ?", id, connectorID). + Scan(ctx) + + if err != nil { + return nil, e("failed to fetch schedule", err) + } + + return pointer.For(toScheduleModel(schedule)), nil +} + +type ScheduleQuery struct{} + +type ListSchedulesQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[ScheduleQuery]] + +func NewListSchedulesQuery(opts bunpaginate.PaginatedQueryOptions[ScheduleQuery]) ListSchedulesQuery { + return ListSchedulesQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) schedulesQueryContext(qb query.Builder) (string, []any, error) { + return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "connector_id": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'connector_id' column can only be used with $match") + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) +} + +func (s *store) SchedulesList(ctx context.Context, q ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.schedulesQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[ScheduleQuery], schedule](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[ScheduleQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch schedules", err) + } + + schedules := make([]models.Schedule, 0, len(cursor.Data)) + for _, s := range cursor.Data { + schedules = append(schedules, toScheduleModel(s)) + } + + return &bunpaginate.Cursor[models.Schedule]{ + Data: schedules, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + }, nil +} + +func fromScheduleModel(s models.Schedule) schedule { + return schedule{ + ID: s.ID, + ConnectorID: s.ConnectorID, + CreatedAt: time.New(s.CreatedAt), + } +} + +func toScheduleModel(s schedule) models.Schedule { + return models.Schedule{ + ID: s.ID, + ConnectorID: s.ConnectorID, + CreatedAt: s.CreatedAt.Time, + } +} diff --git a/internal/storage/schedules_test.go b/internal/storage/schedules_test.go new file mode 100644 index 00000000..d5bac264 --- /dev/null +++ b/internal/storage/schedules_test.go @@ -0,0 +1,268 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultSchedules = []models.Schedule{ + { + ID: "test1", + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + }, + { + ID: "test2", + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + }, + { + ID: "test3", + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + }, + } +) + +func upsertSchedule(t *testing.T, ctx context.Context, storage Storage, schedule models.Schedule) { + require.NoError(t, storage.SchedulesUpsert(ctx, schedule)) +} + +func TestSchedulesUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertSchedule(t, ctx, store, defaultSchedules[0]) + upsertSchedule(t, ctx, store, defaultSchedules[1]) + upsertSchedule(t, ctx, store, defaultSchedules[2]) + + t.Run("upsert with same id", func(t *testing.T) { + sch := models.Schedule{ + ID: "test1", + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-90 * time.Minute).UTC().Time, + } + + require.NoError(t, store.SchedulesUpsert(ctx, sch)) + + actual, err := store.SchedulesGet(ctx, sch.ID, sch.ConnectorID) + require.NoError(t, err) + require.Equal(t, defaultSchedules[0], *actual) + }) + + t.Run("upsert with unknown connector id", func(t *testing.T) { + sch := models.Schedule{ + ID: "test4", + ConnectorID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, + CreatedAt: now.Add(-90 * time.Minute).UTC().Time, + } + + require.Error(t, store.SchedulesUpsert(ctx, sch)) + }) +} + +func TestSchedulesDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertSchedule(t, ctx, store, defaultSchedules[0]) + upsertSchedule(t, ctx, store, defaultSchedules[1]) + upsertSchedule(t, ctx, store, defaultSchedules[2]) + + t.Run("delete schedules from unknown connector id", func(t *testing.T) { + require.NoError(t, store.SchedulesDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, sch := range defaultSchedules { + actual, err := store.SchedulesGet(ctx, sch.ID, sch.ConnectorID) + require.NoError(t, err) + require.Equal(t, sch, *actual) + } + }) + + t.Run("delete schedules", func(t *testing.T) { + require.NoError(t, store.SchedulesDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, sch := range defaultSchedules { + _, err := store.SchedulesGet(ctx, sch.ID, sch.ConnectorID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} + +func TestSchedulesDelete(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertSchedule(t, ctx, store, defaultSchedules[0]) + upsertSchedule(t, ctx, store, defaultSchedules[1]) + upsertSchedule(t, ctx, store, defaultSchedules[2]) + + t.Run("delete unknown schedule", func(t *testing.T) { + require.NoError(t, store.SchedulesDelete(ctx, "unknown")) + + for _, sch := range defaultSchedules { + actual, err := store.SchedulesGet(ctx, sch.ID, sch.ConnectorID) + require.NoError(t, err) + require.Equal(t, sch, *actual) + } + }) + + t.Run("delete schedule", func(t *testing.T) { + require.NoError(t, store.SchedulesDelete(ctx, defaultSchedules[0].ID)) + + _, err := store.SchedulesGet(ctx, defaultSchedules[0].ID, defaultSchedules[0].ConnectorID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) +} + +func TestSchedulesGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertSchedule(t, ctx, store, defaultSchedules[0]) + upsertSchedule(t, ctx, store, defaultSchedules[1]) + upsertSchedule(t, ctx, store, defaultSchedules[2]) + + t.Run("get schedule", func(t *testing.T) { + actual, err := store.SchedulesGet(ctx, defaultSchedules[0].ID, defaultSchedules[0].ConnectorID) + require.NoError(t, err) + require.Equal(t, defaultSchedules[0], *actual) + }) + + t.Run("get unknown schedule", func(t *testing.T) { + _, err := store.SchedulesGet(ctx, "unknown", defaultConnector.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) +} + +func TestSchedulesList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertSchedule(t, ctx, store, defaultSchedules[0]) + upsertSchedule(t, ctx, store, defaultSchedules[1]) + upsertSchedule(t, ctx, store, defaultSchedules[2]) + + t.Run("list schedules by connector id", func(t *testing.T) { + q := NewListSchedulesQuery( + bunpaginate.NewPaginatedQueryOptions(ScheduleQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector.ID)), + ) + + cursor, err := store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 3, len(cursor.Data)) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, []models.Schedule{defaultSchedules[1], defaultSchedules[2], defaultSchedules[0]}, cursor.Data) + }) + + t.Run("list schedules by unknown connector id", func(t *testing.T) { + q := NewListSchedulesQuery( + bunpaginate.NewPaginatedQueryOptions(ScheduleQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }), + ), + ) + + cursor, err := store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Empty(t, cursor.Data) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list schedules test cursor", func(t *testing.T) { + q := NewListSchedulesQuery( + bunpaginate.NewPaginatedQueryOptions(ScheduleQuery{}). + WithPageSize(1), + ) + + cursor, err := store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Schedule{defaultSchedules[1]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Schedule{defaultSchedules[2]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, []models.Schedule{defaultSchedules[0]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Schedule{defaultSchedules[2]}, cursor.Data) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.SchedulesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, []models.Schedule{defaultSchedules[1]}, cursor.Data) + }) +} diff --git a/internal/storage/states.go b/internal/storage/states.go new file mode 100644 index 00000000..5271c095 --- /dev/null +++ b/internal/storage/states.go @@ -0,0 +1,68 @@ +package storage + +import ( + "context" + "encoding/json" + + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type state struct { + bun.BaseModel `bun:"table:states"` + + ID models.StateID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + State json.RawMessage `bun:"state,type:json,notnull"` +} + +func (s *store) StatesUpsert(ctx context.Context, state models.State) error { + toInsert := fromStateModels(state) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO UPDATE"). + Set("state = EXCLUDED.state"). + Exec(ctx) + return e("failed to upsert state", err) +} + +func (s *store) StatesGet(ctx context.Context, id models.StateID) (models.State, error) { + var state state + + err := s.db.NewSelect(). + Model(&state). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return models.State{}, e("failed to get state", err) + } + + res := toStateModels(state) + return res, nil +} + +func (s *store) StatesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*state)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete state", err) +} + +func fromStateModels(from models.State) state { + return state{ + ID: from.ID, + ConnectorID: from.ConnectorID, + State: from.State, + } +} + +func toStateModels(from state) models.State { + return models.State{ + ID: from.ID, + ConnectorID: from.ConnectorID, + State: from.State, + } +} diff --git a/internal/storage/states_test.go b/internal/storage/states_test.go new file mode 100644 index 00000000..86caf391 --- /dev/null +++ b/internal/storage/states_test.go @@ -0,0 +1,151 @@ +package storage + +import ( + "context" + "encoding/json" + "testing" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultStates = []models.State{ + { + ID: models.StateID{ + Reference: "test1", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + State: []byte(`{}`), + }, + { + ID: models.StateID{ + Reference: "test2", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + State: []byte(`{"foo":"bar"}`), + }, + { + ID: models.StateID{ + Reference: "test3", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + State: []byte(`{"foo3":"bar3"}`), + }, + } +) + +func upsertState(t *testing.T, ctx context.Context, storage Storage, state models.State) { + require.NoError(t, storage.StatesUpsert(ctx, state)) +} + +func TestStatesUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, state := range defaultStates { + upsertState(t, ctx, store, state) + } + + t.Run("upsert with unknown connector id", func(t *testing.T) { + c := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + s := models.State{ + ID: models.StateID{ + Reference: "test4", + ConnectorID: c, + }, + ConnectorID: c, + State: []byte(`{}`), + } + + require.Error(t, store.StatesUpsert(ctx, s)) + }) + + t.Run("upsert with same id", func(t *testing.T) { + s := models.State{ + ID: defaultStates[0].ID, + ConnectorID: defaultConnector.ID, + State: json.RawMessage(`{"foo":"bar"}`), + } + + upsertState(t, ctx, store, s) + + // Should update the state + state, err := store.StatesGet(ctx, s.ID) + require.NoError(t, err) + require.Equal(t, s, state) + }) +} + +func TestStatesGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, state := range defaultStates { + upsertState(t, ctx, store, state) + } + + t.Run("get state", func(t *testing.T) { + for _, state := range defaultStates { + s, err := store.StatesGet(ctx, state.ID) + require.NoError(t, err) + require.Equal(t, state, s) + } + }) + + t.Run("get state with unknown id", func(t *testing.T) { + _, err := store.StatesGet(ctx, models.StateID{ + Reference: "unknown", + ConnectorID: defaultConnector.ID, + }) + require.Error(t, err) + }) +} + +func TestDeleteStatesFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, state := range defaultStates { + upsertState(t, ctx, store, state) + } + + t.Run("delete states with unknown connector id", func(t *testing.T) { + require.NoError(t, store.StatesDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, state := range defaultStates { + s, err := store.StatesGet(ctx, state.ID) + require.NoError(t, err) + require.Equal(t, state, s) + } + }) + + t.Run("delete states", func(t *testing.T) { + require.NoError(t, store.StatesDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, state := range defaultStates { + _, err := store.StatesGet(ctx, state.ID) + require.Error(t, err) + } + }) +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 00000000..3aaf5f5d --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,175 @@ +package storage + +import ( + "context" + "sync" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +//go:generate mockgen -source storage.go -destination storage_generated.go -package storage . Storage +type Storage interface { + // Close closes the storage. + Close() error + + // Accounts + AccountsUpsert(ctx context.Context, accounts []models.Account) error + AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) + AccountsList(ctx context.Context, q ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) + AccountsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Balances + BalancesUpsert(ctx context.Context, balances []models.Balance) error + BalancesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + BalancesList(ctx context.Context, q ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) + BalancesGetAt(ctx context.Context, accountID models.AccountID, at time.Time) ([]*models.Balance, error) + + // Bank Accounts + BankAccountsUpsert(ctx context.Context, bankAccount models.BankAccount) error + BankAccountsUpdateMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error + BankAccountsGet(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) + BankAccountsList(ctx context.Context, q ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error) + BankAccountsAddRelatedAccount(ctx context.Context, relatedAccount models.BankAccountRelatedAccount) error + BankAccountsDeleteRelatedAccountFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Connectors + ListenConnectorsChanges(ctx context.Context, handler HandlerConnectorsChanges) error + ConnectorsInstall(ctx context.Context, c models.Connector) error + ConnectorsUninstall(ctx context.Context, id models.ConnectorID) error + ConnectorsGet(ctx context.Context, id models.ConnectorID) (*models.Connector, error) + ConnectorsList(ctx context.Context, q ListConnectorsQuery) (*bunpaginate.Cursor[models.Connector], error) + ConnectorsScheduleForDeletion(ctx context.Context, id models.ConnectorID) error + + // Connector Tasks Tree + ConnectorTasksTreeUpsert(ctx context.Context, connectorID models.ConnectorID, tasks models.ConnectorTasksTree) error + ConnectorTasksTreeGet(ctx context.Context, connectorID models.ConnectorID) (*models.ConnectorTasksTree, error) + ConnectorTasksTreeDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Events Sent + EventsSentUpsert(ctx context.Context, event models.EventSent) error + EventsSentGet(ctx context.Context, id models.EventID) (*models.EventSent, error) + EventsSentExists(ctx context.Context, id models.EventID) (bool, error) + EventsSentDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Payments + PaymentsUpsert(ctx context.Context, payments []models.Payment) error + PaymentsUpdateMetadata(ctx context.Context, id models.PaymentID, metadata map[string]string) error + PaymentsGet(ctx context.Context, id models.PaymentID) (*models.Payment, error) + PaymentsList(ctx context.Context, q ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) + PaymentsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Payment Initiations + PaymentInitiationsUpsert(ctx context.Context, pi models.PaymentInitiation, adjustments ...models.PaymentInitiationAdjustment) error + PaymentInitiationsUpdateMetadata(ctx context.Context, piID models.PaymentInitiationID, metadata map[string]string) error + PaymentInitiationsGet(ctx context.Context, piID models.PaymentInitiationID) (*models.PaymentInitiation, error) + PaymentInitiationsDelete(ctx context.Context, piID models.PaymentInitiationID) error + PaymentInitiationsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + PaymentInitiationsList(ctx context.Context, q ListPaymentInitiationsQuery) (*bunpaginate.Cursor[models.PaymentInitiation], error) + PaymentInitiationIDsListFromPaymentID(ctx context.Context, id models.PaymentID) ([]models.PaymentInitiationID, error) + + // Payment Initiation Adjustments + PaymentInitiationAdjustmentsUpsert(ctx context.Context, adj models.PaymentInitiationAdjustment) error + PaymentInitiationAdjustmentsUpsertIfPredicate(ctx context.Context, adj models.PaymentInitiationAdjustment, predicate func(models.PaymentInitiationAdjustment) bool) (bool, error) + PaymentInitiationAdjustmentsGet(ctx context.Context, id models.PaymentInitiationAdjustmentID) (*models.PaymentInitiationAdjustment, error) + PaymentInitiationAdjustmentsList(ctx context.Context, piID models.PaymentInitiationID, q ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) + + // Payment Initiation Related Payments + PaymentInitiationRelatedPaymentsUpsert(ctx context.Context, piID models.PaymentInitiationID, pID models.PaymentID, createdAt time.Time) error + PaymentInitiationRelatedPaymentsList(ctx context.Context, piID models.PaymentInitiationID, q ListPaymentInitiationRelatedPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) + + // Payment Initiation Reversals + PaymentInitiationReversalsUpsert(ctx context.Context, pir models.PaymentInitiationReversal, reversalAdjustments []models.PaymentInitiationReversalAdjustment) error + PaymentInitiationReversalsGet(ctx context.Context, id models.PaymentInitiationReversalID) (*models.PaymentInitiationReversal, error) + PaymentInitiationReversalsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + PaymentInitiationReversalsList(ctx context.Context, q ListPaymentInitiationReversalsQuery) (*bunpaginate.Cursor[models.PaymentInitiationReversal], error) + + // Payment Initiation Reversal Adjustments + PaymentInitiationReversalAdjustmentsUpsert(ctx context.Context, adj models.PaymentInitiationReversalAdjustment) error + PaymentInitiationReversalAdjustmentsGet(ctx context.Context, id models.PaymentInitiationReversalAdjustmentID) (*models.PaymentInitiationReversalAdjustment, error) + PaymentInitiationReversalAdjustmentsList(ctx context.Context, piID models.PaymentInitiationReversalID, q ListPaymentInitiationReversalAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationReversalAdjustment], error) + + // Pools + PoolsUpsert(ctx context.Context, pool models.Pool) error + PoolsGet(ctx context.Context, id uuid.UUID) (*models.Pool, error) + PoolsDelete(ctx context.Context, id uuid.UUID) error + PoolsAddAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error + PoolsRemoveAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error + PoolsRemoveAccountsFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + PoolsList(ctx context.Context, q ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) + + // Schedules + SchedulesUpsert(ctx context.Context, schedule models.Schedule) error + SchedulesList(ctx context.Context, q ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) + SchedulesGet(ctx context.Context, id string, connectorID models.ConnectorID) (*models.Schedule, error) + SchedulesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + SchedulesDelete(ctx context.Context, id string) error + + // State + StatesUpsert(ctx context.Context, state models.State) error + StatesGet(ctx context.Context, id models.StateID) (models.State, error) + StatesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Tasks + TasksUpsert(ctx context.Context, task models.Task) error + TasksGet(ctx context.Context, id models.TaskID) (*models.Task, error) + TasksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Webhooks Configs + WebhooksConfigsUpsert(ctx context.Context, webhooksConfigs []models.WebhookConfig) error + WebhooksConfigsGet(ctx context.Context, name string, connectorID models.ConnectorID) (*models.WebhookConfig, error) + WebhooksConfigsGetFromConnectorID(ctx context.Context, connectorID models.ConnectorID) ([]models.WebhookConfig, error) + WebhooksConfigsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Webhooks + WebhooksInsert(ctx context.Context, webhook models.Webhook) error + WebhooksGet(ctx context.Context, id string) (models.Webhook, error) + WebhooksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error + + // Workflow Instances + InstancesUpsert(ctx context.Context, instance models.Instance) error + InstancesUpdate(ctx context.Context, instance models.Instance) error + InstancesGet(ctx context.Context, id string, scheduleID string, connectorID models.ConnectorID) (*models.Instance, error) + InstancesList(ctx context.Context, q ListInstancesQuery) (*bunpaginate.Cursor[models.Instance], error) + InstancesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error +} + +const encryptionOptions = "compress-algo=1, cipher-algo=aes256" + +type store struct { + logger logging.Logger + db *bun.DB + configEncryptionKey string + + conns []bun.Conn + rwMutex sync.RWMutex +} + +func newStorage(logger logging.Logger, db *bun.DB, configEncryptionKey string) Storage { + return &store{ + logger: logger, + db: db, + configEncryptionKey: configEncryptionKey, + } +} + +func (s *store) Close() error { + s.rwMutex.Lock() + defer s.rwMutex.Unlock() + + if err := s.db.Close(); err != nil { + return err + } + + for _, conn := range s.conns { + if err := conn.Close(); err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/storage_generated.go b/internal/storage/storage_generated.go new file mode 100644 index 00000000..49807b40 --- /dev/null +++ b/internal/storage/storage_generated.go @@ -0,0 +1,1246 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: storage.go +// +// Generated by this command: +// +// mockgen -source storage.go -destination storage_generated.go -package storage . Storage +// + +// Package storage is a generated GoMock package. +package storage + +import ( + context "context" + reflect "reflect" + time "time" + + bunpaginate "github.com/formancehq/go-libs/v2/bun/bunpaginate" + models "github.com/formancehq/payments/internal/models" + uuid "github.com/google/uuid" + gomock "go.uber.org/mock/gomock" +) + +// MockStorage is a mock of Storage interface. +type MockStorage struct { + ctrl *gomock.Controller + recorder *MockStorageMockRecorder +} + +// MockStorageMockRecorder is the mock recorder for MockStorage. +type MockStorageMockRecorder struct { + mock *MockStorage +} + +// NewMockStorage creates a new mock instance. +func NewMockStorage(ctrl *gomock.Controller) *MockStorage { + mock := &MockStorage{ctrl: ctrl} + mock.recorder = &MockStorageMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStorage) EXPECT() *MockStorageMockRecorder { + return m.recorder +} + +// AccountsDeleteFromConnectorID mocks base method. +func (m *MockStorage) AccountsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// AccountsDeleteFromConnectorID indicates an expected call of AccountsDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) AccountsDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).AccountsDeleteFromConnectorID), ctx, connectorID) +} + +// AccountsGet mocks base method. +func (m *MockStorage) AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsGet", ctx, id) + ret0, _ := ret[0].(*models.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountsGet indicates an expected call of AccountsGet. +func (mr *MockStorageMockRecorder) AccountsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsGet", reflect.TypeOf((*MockStorage)(nil).AccountsGet), ctx, id) +} + +// AccountsList mocks base method. +func (m *MockStorage) AccountsList(ctx context.Context, q ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Account]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountsList indicates an expected call of AccountsList. +func (mr *MockStorageMockRecorder) AccountsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsList", reflect.TypeOf((*MockStorage)(nil).AccountsList), ctx, q) +} + +// AccountsUpsert mocks base method. +func (m *MockStorage) AccountsUpsert(ctx context.Context, accounts []models.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountsUpsert", ctx, accounts) + ret0, _ := ret[0].(error) + return ret0 +} + +// AccountsUpsert indicates an expected call of AccountsUpsert. +func (mr *MockStorageMockRecorder) AccountsUpsert(ctx, accounts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountsUpsert", reflect.TypeOf((*MockStorage)(nil).AccountsUpsert), ctx, accounts) +} + +// BalancesDeleteFromConnectorID mocks base method. +func (m *MockStorage) BalancesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BalancesDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// BalancesDeleteFromConnectorID indicates an expected call of BalancesDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) BalancesDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BalancesDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).BalancesDeleteFromConnectorID), ctx, connectorID) +} + +// BalancesGetAt mocks base method. +func (m *MockStorage) BalancesGetAt(ctx context.Context, accountID models.AccountID, at time.Time) ([]*models.Balance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BalancesGetAt", ctx, accountID, at) + ret0, _ := ret[0].([]*models.Balance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BalancesGetAt indicates an expected call of BalancesGetAt. +func (mr *MockStorageMockRecorder) BalancesGetAt(ctx, accountID, at any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BalancesGetAt", reflect.TypeOf((*MockStorage)(nil).BalancesGetAt), ctx, accountID, at) +} + +// BalancesList mocks base method. +func (m *MockStorage) BalancesList(ctx context.Context, q ListBalancesQuery) (*bunpaginate.Cursor[models.Balance], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BalancesList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Balance]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BalancesList indicates an expected call of BalancesList. +func (mr *MockStorageMockRecorder) BalancesList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BalancesList", reflect.TypeOf((*MockStorage)(nil).BalancesList), ctx, q) +} + +// BalancesUpsert mocks base method. +func (m *MockStorage) BalancesUpsert(ctx context.Context, balances []models.Balance) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BalancesUpsert", ctx, balances) + ret0, _ := ret[0].(error) + return ret0 +} + +// BalancesUpsert indicates an expected call of BalancesUpsert. +func (mr *MockStorageMockRecorder) BalancesUpsert(ctx, balances any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BalancesUpsert", reflect.TypeOf((*MockStorage)(nil).BalancesUpsert), ctx, balances) +} + +// BankAccountsAddRelatedAccount mocks base method. +func (m *MockStorage) BankAccountsAddRelatedAccount(ctx context.Context, relatedAccount models.BankAccountRelatedAccount) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsAddRelatedAccount", ctx, relatedAccount) + ret0, _ := ret[0].(error) + return ret0 +} + +// BankAccountsAddRelatedAccount indicates an expected call of BankAccountsAddRelatedAccount. +func (mr *MockStorageMockRecorder) BankAccountsAddRelatedAccount(ctx, relatedAccount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsAddRelatedAccount", reflect.TypeOf((*MockStorage)(nil).BankAccountsAddRelatedAccount), ctx, relatedAccount) +} + +// BankAccountsDeleteRelatedAccountFromConnectorID mocks base method. +func (m *MockStorage) BankAccountsDeleteRelatedAccountFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsDeleteRelatedAccountFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// BankAccountsDeleteRelatedAccountFromConnectorID indicates an expected call of BankAccountsDeleteRelatedAccountFromConnectorID. +func (mr *MockStorageMockRecorder) BankAccountsDeleteRelatedAccountFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsDeleteRelatedAccountFromConnectorID", reflect.TypeOf((*MockStorage)(nil).BankAccountsDeleteRelatedAccountFromConnectorID), ctx, connectorID) +} + +// BankAccountsGet mocks base method. +func (m *MockStorage) BankAccountsGet(ctx context.Context, id uuid.UUID, expand bool) (*models.BankAccount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsGet", ctx, id, expand) + ret0, _ := ret[0].(*models.BankAccount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BankAccountsGet indicates an expected call of BankAccountsGet. +func (mr *MockStorageMockRecorder) BankAccountsGet(ctx, id, expand any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsGet", reflect.TypeOf((*MockStorage)(nil).BankAccountsGet), ctx, id, expand) +} + +// BankAccountsList mocks base method. +func (m *MockStorage) BankAccountsList(ctx context.Context, q ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.BankAccount]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BankAccountsList indicates an expected call of BankAccountsList. +func (mr *MockStorageMockRecorder) BankAccountsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsList", reflect.TypeOf((*MockStorage)(nil).BankAccountsList), ctx, q) +} + +// BankAccountsUpdateMetadata mocks base method. +func (m *MockStorage) BankAccountsUpdateMetadata(ctx context.Context, id uuid.UUID, metadata map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsUpdateMetadata", ctx, id, metadata) + ret0, _ := ret[0].(error) + return ret0 +} + +// BankAccountsUpdateMetadata indicates an expected call of BankAccountsUpdateMetadata. +func (mr *MockStorageMockRecorder) BankAccountsUpdateMetadata(ctx, id, metadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsUpdateMetadata", reflect.TypeOf((*MockStorage)(nil).BankAccountsUpdateMetadata), ctx, id, metadata) +} + +// BankAccountsUpsert mocks base method. +func (m *MockStorage) BankAccountsUpsert(ctx context.Context, bankAccount models.BankAccount) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BankAccountsUpsert", ctx, bankAccount) + ret0, _ := ret[0].(error) + return ret0 +} + +// BankAccountsUpsert indicates an expected call of BankAccountsUpsert. +func (mr *MockStorageMockRecorder) BankAccountsUpsert(ctx, bankAccount any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BankAccountsUpsert", reflect.TypeOf((*MockStorage)(nil).BankAccountsUpsert), ctx, bankAccount) +} + +// Close mocks base method. +func (m *MockStorage) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockStorageMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStorage)(nil).Close)) +} + +// ConnectorTasksTreeDeleteFromConnectorID mocks base method. +func (m *MockStorage) ConnectorTasksTreeDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorTasksTreeDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorTasksTreeDeleteFromConnectorID indicates an expected call of ConnectorTasksTreeDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) ConnectorTasksTreeDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorTasksTreeDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).ConnectorTasksTreeDeleteFromConnectorID), ctx, connectorID) +} + +// ConnectorTasksTreeGet mocks base method. +func (m *MockStorage) ConnectorTasksTreeGet(ctx context.Context, connectorID models.ConnectorID) (*models.ConnectorTasksTree, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorTasksTreeGet", ctx, connectorID) + ret0, _ := ret[0].(*models.ConnectorTasksTree) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConnectorTasksTreeGet indicates an expected call of ConnectorTasksTreeGet. +func (mr *MockStorageMockRecorder) ConnectorTasksTreeGet(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorTasksTreeGet", reflect.TypeOf((*MockStorage)(nil).ConnectorTasksTreeGet), ctx, connectorID) +} + +// ConnectorTasksTreeUpsert mocks base method. +func (m *MockStorage) ConnectorTasksTreeUpsert(ctx context.Context, connectorID models.ConnectorID, tasks models.ConnectorTasksTree) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorTasksTreeUpsert", ctx, connectorID, tasks) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorTasksTreeUpsert indicates an expected call of ConnectorTasksTreeUpsert. +func (mr *MockStorageMockRecorder) ConnectorTasksTreeUpsert(ctx, connectorID, tasks any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorTasksTreeUpsert", reflect.TypeOf((*MockStorage)(nil).ConnectorTasksTreeUpsert), ctx, connectorID, tasks) +} + +// ConnectorsGet mocks base method. +func (m *MockStorage) ConnectorsGet(ctx context.Context, id models.ConnectorID) (*models.Connector, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsGet", ctx, id) + ret0, _ := ret[0].(*models.Connector) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConnectorsGet indicates an expected call of ConnectorsGet. +func (mr *MockStorageMockRecorder) ConnectorsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsGet", reflect.TypeOf((*MockStorage)(nil).ConnectorsGet), ctx, id) +} + +// ConnectorsInstall mocks base method. +func (m *MockStorage) ConnectorsInstall(ctx context.Context, c models.Connector) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsInstall", ctx, c) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorsInstall indicates an expected call of ConnectorsInstall. +func (mr *MockStorageMockRecorder) ConnectorsInstall(ctx, c any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsInstall", reflect.TypeOf((*MockStorage)(nil).ConnectorsInstall), ctx, c) +} + +// ConnectorsList mocks base method. +func (m *MockStorage) ConnectorsList(ctx context.Context, q ListConnectorsQuery) (*bunpaginate.Cursor[models.Connector], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Connector]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConnectorsList indicates an expected call of ConnectorsList. +func (mr *MockStorageMockRecorder) ConnectorsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsList", reflect.TypeOf((*MockStorage)(nil).ConnectorsList), ctx, q) +} + +// ConnectorsScheduleForDeletion mocks base method. +func (m *MockStorage) ConnectorsScheduleForDeletion(ctx context.Context, id models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsScheduleForDeletion", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorsScheduleForDeletion indicates an expected call of ConnectorsScheduleForDeletion. +func (mr *MockStorageMockRecorder) ConnectorsScheduleForDeletion(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsScheduleForDeletion", reflect.TypeOf((*MockStorage)(nil).ConnectorsScheduleForDeletion), ctx, id) +} + +// ConnectorsUninstall mocks base method. +func (m *MockStorage) ConnectorsUninstall(ctx context.Context, id models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConnectorsUninstall", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// ConnectorsUninstall indicates an expected call of ConnectorsUninstall. +func (mr *MockStorageMockRecorder) ConnectorsUninstall(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectorsUninstall", reflect.TypeOf((*MockStorage)(nil).ConnectorsUninstall), ctx, id) +} + +// EventsSentDeleteFromConnectorID mocks base method. +func (m *MockStorage) EventsSentDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EventsSentDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// EventsSentDeleteFromConnectorID indicates an expected call of EventsSentDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) EventsSentDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EventsSentDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).EventsSentDeleteFromConnectorID), ctx, connectorID) +} + +// EventsSentExists mocks base method. +func (m *MockStorage) EventsSentExists(ctx context.Context, id models.EventID) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EventsSentExists", ctx, id) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EventsSentExists indicates an expected call of EventsSentExists. +func (mr *MockStorageMockRecorder) EventsSentExists(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EventsSentExists", reflect.TypeOf((*MockStorage)(nil).EventsSentExists), ctx, id) +} + +// EventsSentGet mocks base method. +func (m *MockStorage) EventsSentGet(ctx context.Context, id models.EventID) (*models.EventSent, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EventsSentGet", ctx, id) + ret0, _ := ret[0].(*models.EventSent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EventsSentGet indicates an expected call of EventsSentGet. +func (mr *MockStorageMockRecorder) EventsSentGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EventsSentGet", reflect.TypeOf((*MockStorage)(nil).EventsSentGet), ctx, id) +} + +// EventsSentUpsert mocks base method. +func (m *MockStorage) EventsSentUpsert(ctx context.Context, event models.EventSent) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EventsSentUpsert", ctx, event) + ret0, _ := ret[0].(error) + return ret0 +} + +// EventsSentUpsert indicates an expected call of EventsSentUpsert. +func (mr *MockStorageMockRecorder) EventsSentUpsert(ctx, event any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EventsSentUpsert", reflect.TypeOf((*MockStorage)(nil).EventsSentUpsert), ctx, event) +} + +// InstancesDeleteFromConnectorID mocks base method. +func (m *MockStorage) InstancesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstancesDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// InstancesDeleteFromConnectorID indicates an expected call of InstancesDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) InstancesDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstancesDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).InstancesDeleteFromConnectorID), ctx, connectorID) +} + +// InstancesGet mocks base method. +func (m *MockStorage) InstancesGet(ctx context.Context, id, scheduleID string, connectorID models.ConnectorID) (*models.Instance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstancesGet", ctx, id, scheduleID, connectorID) + ret0, _ := ret[0].(*models.Instance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InstancesGet indicates an expected call of InstancesGet. +func (mr *MockStorageMockRecorder) InstancesGet(ctx, id, scheduleID, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstancesGet", reflect.TypeOf((*MockStorage)(nil).InstancesGet), ctx, id, scheduleID, connectorID) +} + +// InstancesList mocks base method. +func (m *MockStorage) InstancesList(ctx context.Context, q ListInstancesQuery) (*bunpaginate.Cursor[models.Instance], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstancesList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Instance]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InstancesList indicates an expected call of InstancesList. +func (mr *MockStorageMockRecorder) InstancesList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstancesList", reflect.TypeOf((*MockStorage)(nil).InstancesList), ctx, q) +} + +// InstancesUpdate mocks base method. +func (m *MockStorage) InstancesUpdate(ctx context.Context, instance models.Instance) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstancesUpdate", ctx, instance) + ret0, _ := ret[0].(error) + return ret0 +} + +// InstancesUpdate indicates an expected call of InstancesUpdate. +func (mr *MockStorageMockRecorder) InstancesUpdate(ctx, instance any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstancesUpdate", reflect.TypeOf((*MockStorage)(nil).InstancesUpdate), ctx, instance) +} + +// InstancesUpsert mocks base method. +func (m *MockStorage) InstancesUpsert(ctx context.Context, instance models.Instance) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstancesUpsert", ctx, instance) + ret0, _ := ret[0].(error) + return ret0 +} + +// InstancesUpsert indicates an expected call of InstancesUpsert. +func (mr *MockStorageMockRecorder) InstancesUpsert(ctx, instance any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstancesUpsert", reflect.TypeOf((*MockStorage)(nil).InstancesUpsert), ctx, instance) +} + +// ListenConnectorsChanges mocks base method. +func (m *MockStorage) ListenConnectorsChanges(ctx context.Context, handler HandlerConnectorsChanges) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListenConnectorsChanges", ctx, handler) + ret0, _ := ret[0].(error) + return ret0 +} + +// ListenConnectorsChanges indicates an expected call of ListenConnectorsChanges. +func (mr *MockStorageMockRecorder) ListenConnectorsChanges(ctx, handler any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListenConnectorsChanges", reflect.TypeOf((*MockStorage)(nil).ListenConnectorsChanges), ctx, handler) +} + +// PaymentInitiationAdjustmentsGet mocks base method. +func (m *MockStorage) PaymentInitiationAdjustmentsGet(ctx context.Context, id models.PaymentInitiationAdjustmentID) (*models.PaymentInitiationAdjustment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsGet", ctx, id) + ret0, _ := ret[0].(*models.PaymentInitiationAdjustment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationAdjustmentsGet indicates an expected call of PaymentInitiationAdjustmentsGet. +func (mr *MockStorageMockRecorder) PaymentInitiationAdjustmentsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsGet", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationAdjustmentsGet), ctx, id) +} + +// PaymentInitiationAdjustmentsList mocks base method. +func (m *MockStorage) PaymentInitiationAdjustmentsList(ctx context.Context, piID models.PaymentInitiationID, q ListPaymentInitiationAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationAdjustment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsList", ctx, piID, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.PaymentInitiationAdjustment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationAdjustmentsList indicates an expected call of PaymentInitiationAdjustmentsList. +func (mr *MockStorageMockRecorder) PaymentInitiationAdjustmentsList(ctx, piID, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsList", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationAdjustmentsList), ctx, piID, q) +} + +// PaymentInitiationAdjustmentsUpsert mocks base method. +func (m *MockStorage) PaymentInitiationAdjustmentsUpsert(ctx context.Context, adj models.PaymentInitiationAdjustment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsUpsert", ctx, adj) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationAdjustmentsUpsert indicates an expected call of PaymentInitiationAdjustmentsUpsert. +func (mr *MockStorageMockRecorder) PaymentInitiationAdjustmentsUpsert(ctx, adj any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsUpsert", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationAdjustmentsUpsert), ctx, adj) +} + +// PaymentInitiationAdjustmentsUpsertIfPredicate mocks base method. +func (m *MockStorage) PaymentInitiationAdjustmentsUpsertIfPredicate(ctx context.Context, adj models.PaymentInitiationAdjustment, predicate func(models.PaymentInitiationAdjustment) bool) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationAdjustmentsUpsertIfPredicate", ctx, adj, predicate) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationAdjustmentsUpsertIfPredicate indicates an expected call of PaymentInitiationAdjustmentsUpsertIfPredicate. +func (mr *MockStorageMockRecorder) PaymentInitiationAdjustmentsUpsertIfPredicate(ctx, adj, predicate any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationAdjustmentsUpsertIfPredicate", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationAdjustmentsUpsertIfPredicate), ctx, adj, predicate) +} + +// PaymentInitiationIDsListFromPaymentID mocks base method. +func (m *MockStorage) PaymentInitiationIDsListFromPaymentID(ctx context.Context, id models.PaymentID) ([]models.PaymentInitiationID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationIDsListFromPaymentID", ctx, id) + ret0, _ := ret[0].([]models.PaymentInitiationID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationIDsListFromPaymentID indicates an expected call of PaymentInitiationIDsListFromPaymentID. +func (mr *MockStorageMockRecorder) PaymentInitiationIDsListFromPaymentID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationIDsListFromPaymentID", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationIDsListFromPaymentID), ctx, id) +} + +// PaymentInitiationRelatedPaymentsList mocks base method. +func (m *MockStorage) PaymentInitiationRelatedPaymentsList(ctx context.Context, piID models.PaymentInitiationID, q ListPaymentInitiationRelatedPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationRelatedPaymentsList", ctx, piID, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Payment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationRelatedPaymentsList indicates an expected call of PaymentInitiationRelatedPaymentsList. +func (mr *MockStorageMockRecorder) PaymentInitiationRelatedPaymentsList(ctx, piID, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationRelatedPaymentsList", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationRelatedPaymentsList), ctx, piID, q) +} + +// PaymentInitiationRelatedPaymentsUpsert mocks base method. +func (m *MockStorage) PaymentInitiationRelatedPaymentsUpsert(ctx context.Context, piID models.PaymentInitiationID, pID models.PaymentID, createdAt time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationRelatedPaymentsUpsert", ctx, piID, pID, createdAt) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationRelatedPaymentsUpsert indicates an expected call of PaymentInitiationRelatedPaymentsUpsert. +func (mr *MockStorageMockRecorder) PaymentInitiationRelatedPaymentsUpsert(ctx, piID, pID, createdAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationRelatedPaymentsUpsert", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationRelatedPaymentsUpsert), ctx, piID, pID, createdAt) +} + +// PaymentInitiationReversalAdjustmentsGet mocks base method. +func (m *MockStorage) PaymentInitiationReversalAdjustmentsGet(ctx context.Context, id models.PaymentInitiationReversalAdjustmentID) (*models.PaymentInitiationReversalAdjustment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalAdjustmentsGet", ctx, id) + ret0, _ := ret[0].(*models.PaymentInitiationReversalAdjustment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationReversalAdjustmentsGet indicates an expected call of PaymentInitiationReversalAdjustmentsGet. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalAdjustmentsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalAdjustmentsGet", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalAdjustmentsGet), ctx, id) +} + +// PaymentInitiationReversalAdjustmentsList mocks base method. +func (m *MockStorage) PaymentInitiationReversalAdjustmentsList(ctx context.Context, piID models.PaymentInitiationReversalID, q ListPaymentInitiationReversalAdjustmentsQuery) (*bunpaginate.Cursor[models.PaymentInitiationReversalAdjustment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalAdjustmentsList", ctx, piID, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.PaymentInitiationReversalAdjustment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationReversalAdjustmentsList indicates an expected call of PaymentInitiationReversalAdjustmentsList. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalAdjustmentsList(ctx, piID, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalAdjustmentsList", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalAdjustmentsList), ctx, piID, q) +} + +// PaymentInitiationReversalAdjustmentsUpsert mocks base method. +func (m *MockStorage) PaymentInitiationReversalAdjustmentsUpsert(ctx context.Context, adj models.PaymentInitiationReversalAdjustment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalAdjustmentsUpsert", ctx, adj) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationReversalAdjustmentsUpsert indicates an expected call of PaymentInitiationReversalAdjustmentsUpsert. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalAdjustmentsUpsert(ctx, adj any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalAdjustmentsUpsert", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalAdjustmentsUpsert), ctx, adj) +} + +// PaymentInitiationReversalsDeleteFromConnectorID mocks base method. +func (m *MockStorage) PaymentInitiationReversalsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalsDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationReversalsDeleteFromConnectorID indicates an expected call of PaymentInitiationReversalsDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalsDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalsDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalsDeleteFromConnectorID), ctx, connectorID) +} + +// PaymentInitiationReversalsGet mocks base method. +func (m *MockStorage) PaymentInitiationReversalsGet(ctx context.Context, id models.PaymentInitiationReversalID) (*models.PaymentInitiationReversal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalsGet", ctx, id) + ret0, _ := ret[0].(*models.PaymentInitiationReversal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationReversalsGet indicates an expected call of PaymentInitiationReversalsGet. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalsGet", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalsGet), ctx, id) +} + +// PaymentInitiationReversalsList mocks base method. +func (m *MockStorage) PaymentInitiationReversalsList(ctx context.Context, q ListPaymentInitiationReversalsQuery) (*bunpaginate.Cursor[models.PaymentInitiationReversal], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.PaymentInitiationReversal]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationReversalsList indicates an expected call of PaymentInitiationReversalsList. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalsList", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalsList), ctx, q) +} + +// PaymentInitiationReversalsUpsert mocks base method. +func (m *MockStorage) PaymentInitiationReversalsUpsert(ctx context.Context, pir models.PaymentInitiationReversal, reversalAdjustments []models.PaymentInitiationReversalAdjustment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationReversalsUpsert", ctx, pir, reversalAdjustments) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationReversalsUpsert indicates an expected call of PaymentInitiationReversalsUpsert. +func (mr *MockStorageMockRecorder) PaymentInitiationReversalsUpsert(ctx, pir, reversalAdjustments any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationReversalsUpsert", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationReversalsUpsert), ctx, pir, reversalAdjustments) +} + +// PaymentInitiationsDelete mocks base method. +func (m *MockStorage) PaymentInitiationsDelete(ctx context.Context, piID models.PaymentInitiationID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsDelete", ctx, piID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationsDelete indicates an expected call of PaymentInitiationsDelete. +func (mr *MockStorageMockRecorder) PaymentInitiationsDelete(ctx, piID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsDelete", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationsDelete), ctx, piID) +} + +// PaymentInitiationsDeleteFromConnectorID mocks base method. +func (m *MockStorage) PaymentInitiationsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationsDeleteFromConnectorID indicates an expected call of PaymentInitiationsDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) PaymentInitiationsDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationsDeleteFromConnectorID), ctx, connectorID) +} + +// PaymentInitiationsGet mocks base method. +func (m *MockStorage) PaymentInitiationsGet(ctx context.Context, piID models.PaymentInitiationID) (*models.PaymentInitiation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsGet", ctx, piID) + ret0, _ := ret[0].(*models.PaymentInitiation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsGet indicates an expected call of PaymentInitiationsGet. +func (mr *MockStorageMockRecorder) PaymentInitiationsGet(ctx, piID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsGet", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationsGet), ctx, piID) +} + +// PaymentInitiationsList mocks base method. +func (m *MockStorage) PaymentInitiationsList(ctx context.Context, q ListPaymentInitiationsQuery) (*bunpaginate.Cursor[models.PaymentInitiation], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.PaymentInitiation]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentInitiationsList indicates an expected call of PaymentInitiationsList. +func (mr *MockStorageMockRecorder) PaymentInitiationsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsList", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationsList), ctx, q) +} + +// PaymentInitiationsUpdateMetadata mocks base method. +func (m *MockStorage) PaymentInitiationsUpdateMetadata(ctx context.Context, piID models.PaymentInitiationID, metadata map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentInitiationsUpdateMetadata", ctx, piID, metadata) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationsUpdateMetadata indicates an expected call of PaymentInitiationsUpdateMetadata. +func (mr *MockStorageMockRecorder) PaymentInitiationsUpdateMetadata(ctx, piID, metadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsUpdateMetadata", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationsUpdateMetadata), ctx, piID, metadata) +} + +// PaymentInitiationsUpsert mocks base method. +func (m *MockStorage) PaymentInitiationsUpsert(ctx context.Context, pi models.PaymentInitiation, adjustments ...models.PaymentInitiationAdjustment) error { + m.ctrl.T.Helper() + varargs := []any{ctx, pi} + for _, a := range adjustments { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "PaymentInitiationsUpsert", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentInitiationsUpsert indicates an expected call of PaymentInitiationsUpsert. +func (mr *MockStorageMockRecorder) PaymentInitiationsUpsert(ctx, pi any, adjustments ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, pi}, adjustments...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentInitiationsUpsert", reflect.TypeOf((*MockStorage)(nil).PaymentInitiationsUpsert), varargs...) +} + +// PaymentsDeleteFromConnectorID mocks base method. +func (m *MockStorage) PaymentsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentsDeleteFromConnectorID indicates an expected call of PaymentsDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) PaymentsDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).PaymentsDeleteFromConnectorID), ctx, connectorID) +} + +// PaymentsGet mocks base method. +func (m *MockStorage) PaymentsGet(ctx context.Context, id models.PaymentID) (*models.Payment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsGet", ctx, id) + ret0, _ := ret[0].(*models.Payment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentsGet indicates an expected call of PaymentsGet. +func (mr *MockStorageMockRecorder) PaymentsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsGet", reflect.TypeOf((*MockStorage)(nil).PaymentsGet), ctx, id) +} + +// PaymentsList mocks base method. +func (m *MockStorage) PaymentsList(ctx context.Context, q ListPaymentsQuery) (*bunpaginate.Cursor[models.Payment], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Payment]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaymentsList indicates an expected call of PaymentsList. +func (mr *MockStorageMockRecorder) PaymentsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsList", reflect.TypeOf((*MockStorage)(nil).PaymentsList), ctx, q) +} + +// PaymentsUpdateMetadata mocks base method. +func (m *MockStorage) PaymentsUpdateMetadata(ctx context.Context, id models.PaymentID, metadata map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsUpdateMetadata", ctx, id, metadata) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentsUpdateMetadata indicates an expected call of PaymentsUpdateMetadata. +func (mr *MockStorageMockRecorder) PaymentsUpdateMetadata(ctx, id, metadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsUpdateMetadata", reflect.TypeOf((*MockStorage)(nil).PaymentsUpdateMetadata), ctx, id, metadata) +} + +// PaymentsUpsert mocks base method. +func (m *MockStorage) PaymentsUpsert(ctx context.Context, payments []models.Payment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaymentsUpsert", ctx, payments) + ret0, _ := ret[0].(error) + return ret0 +} + +// PaymentsUpsert indicates an expected call of PaymentsUpsert. +func (mr *MockStorageMockRecorder) PaymentsUpsert(ctx, payments any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaymentsUpsert", reflect.TypeOf((*MockStorage)(nil).PaymentsUpsert), ctx, payments) +} + +// PoolsAddAccount mocks base method. +func (m *MockStorage) PoolsAddAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsAddAccount", ctx, id, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsAddAccount indicates an expected call of PoolsAddAccount. +func (mr *MockStorageMockRecorder) PoolsAddAccount(ctx, id, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsAddAccount", reflect.TypeOf((*MockStorage)(nil).PoolsAddAccount), ctx, id, accountID) +} + +// PoolsDelete mocks base method. +func (m *MockStorage) PoolsDelete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsDelete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsDelete indicates an expected call of PoolsDelete. +func (mr *MockStorageMockRecorder) PoolsDelete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsDelete", reflect.TypeOf((*MockStorage)(nil).PoolsDelete), ctx, id) +} + +// PoolsGet mocks base method. +func (m *MockStorage) PoolsGet(ctx context.Context, id uuid.UUID) (*models.Pool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsGet", ctx, id) + ret0, _ := ret[0].(*models.Pool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PoolsGet indicates an expected call of PoolsGet. +func (mr *MockStorageMockRecorder) PoolsGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsGet", reflect.TypeOf((*MockStorage)(nil).PoolsGet), ctx, id) +} + +// PoolsList mocks base method. +func (m *MockStorage) PoolsList(ctx context.Context, q ListPoolsQuery) (*bunpaginate.Cursor[models.Pool], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Pool]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PoolsList indicates an expected call of PoolsList. +func (mr *MockStorageMockRecorder) PoolsList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsList", reflect.TypeOf((*MockStorage)(nil).PoolsList), ctx, q) +} + +// PoolsRemoveAccount mocks base method. +func (m *MockStorage) PoolsRemoveAccount(ctx context.Context, id uuid.UUID, accountID models.AccountID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsRemoveAccount", ctx, id, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsRemoveAccount indicates an expected call of PoolsRemoveAccount. +func (mr *MockStorageMockRecorder) PoolsRemoveAccount(ctx, id, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsRemoveAccount", reflect.TypeOf((*MockStorage)(nil).PoolsRemoveAccount), ctx, id, accountID) +} + +// PoolsRemoveAccountsFromConnectorID mocks base method. +func (m *MockStorage) PoolsRemoveAccountsFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsRemoveAccountsFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsRemoveAccountsFromConnectorID indicates an expected call of PoolsRemoveAccountsFromConnectorID. +func (mr *MockStorageMockRecorder) PoolsRemoveAccountsFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsRemoveAccountsFromConnectorID", reflect.TypeOf((*MockStorage)(nil).PoolsRemoveAccountsFromConnectorID), ctx, connectorID) +} + +// PoolsUpsert mocks base method. +func (m *MockStorage) PoolsUpsert(ctx context.Context, pool models.Pool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PoolsUpsert", ctx, pool) + ret0, _ := ret[0].(error) + return ret0 +} + +// PoolsUpsert indicates an expected call of PoolsUpsert. +func (mr *MockStorageMockRecorder) PoolsUpsert(ctx, pool any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PoolsUpsert", reflect.TypeOf((*MockStorage)(nil).PoolsUpsert), ctx, pool) +} + +// SchedulesDelete mocks base method. +func (m *MockStorage) SchedulesDelete(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesDelete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// SchedulesDelete indicates an expected call of SchedulesDelete. +func (mr *MockStorageMockRecorder) SchedulesDelete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesDelete", reflect.TypeOf((*MockStorage)(nil).SchedulesDelete), ctx, id) +} + +// SchedulesDeleteFromConnectorID mocks base method. +func (m *MockStorage) SchedulesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SchedulesDeleteFromConnectorID indicates an expected call of SchedulesDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) SchedulesDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).SchedulesDeleteFromConnectorID), ctx, connectorID) +} + +// SchedulesGet mocks base method. +func (m *MockStorage) SchedulesGet(ctx context.Context, id string, connectorID models.ConnectorID) (*models.Schedule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesGet", ctx, id, connectorID) + ret0, _ := ret[0].(*models.Schedule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SchedulesGet indicates an expected call of SchedulesGet. +func (mr *MockStorageMockRecorder) SchedulesGet(ctx, id, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesGet", reflect.TypeOf((*MockStorage)(nil).SchedulesGet), ctx, id, connectorID) +} + +// SchedulesList mocks base method. +func (m *MockStorage) SchedulesList(ctx context.Context, q ListSchedulesQuery) (*bunpaginate.Cursor[models.Schedule], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesList", ctx, q) + ret0, _ := ret[0].(*bunpaginate.Cursor[models.Schedule]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SchedulesList indicates an expected call of SchedulesList. +func (mr *MockStorageMockRecorder) SchedulesList(ctx, q any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesList", reflect.TypeOf((*MockStorage)(nil).SchedulesList), ctx, q) +} + +// SchedulesUpsert mocks base method. +func (m *MockStorage) SchedulesUpsert(ctx context.Context, schedule models.Schedule) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchedulesUpsert", ctx, schedule) + ret0, _ := ret[0].(error) + return ret0 +} + +// SchedulesUpsert indicates an expected call of SchedulesUpsert. +func (mr *MockStorageMockRecorder) SchedulesUpsert(ctx, schedule any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulesUpsert", reflect.TypeOf((*MockStorage)(nil).SchedulesUpsert), ctx, schedule) +} + +// StatesDeleteFromConnectorID mocks base method. +func (m *MockStorage) StatesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StatesDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// StatesDeleteFromConnectorID indicates an expected call of StatesDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) StatesDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StatesDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).StatesDeleteFromConnectorID), ctx, connectorID) +} + +// StatesGet mocks base method. +func (m *MockStorage) StatesGet(ctx context.Context, id models.StateID) (models.State, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StatesGet", ctx, id) + ret0, _ := ret[0].(models.State) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StatesGet indicates an expected call of StatesGet. +func (mr *MockStorageMockRecorder) StatesGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StatesGet", reflect.TypeOf((*MockStorage)(nil).StatesGet), ctx, id) +} + +// StatesUpsert mocks base method. +func (m *MockStorage) StatesUpsert(ctx context.Context, state models.State) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StatesUpsert", ctx, state) + ret0, _ := ret[0].(error) + return ret0 +} + +// StatesUpsert indicates an expected call of StatesUpsert. +func (mr *MockStorageMockRecorder) StatesUpsert(ctx, state any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StatesUpsert", reflect.TypeOf((*MockStorage)(nil).StatesUpsert), ctx, state) +} + +// TasksDeleteFromConnectorID mocks base method. +func (m *MockStorage) TasksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TasksDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// TasksDeleteFromConnectorID indicates an expected call of TasksDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) TasksDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TasksDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).TasksDeleteFromConnectorID), ctx, connectorID) +} + +// TasksGet mocks base method. +func (m *MockStorage) TasksGet(ctx context.Context, id models.TaskID) (*models.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TasksGet", ctx, id) + ret0, _ := ret[0].(*models.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TasksGet indicates an expected call of TasksGet. +func (mr *MockStorageMockRecorder) TasksGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TasksGet", reflect.TypeOf((*MockStorage)(nil).TasksGet), ctx, id) +} + +// TasksUpsert mocks base method. +func (m *MockStorage) TasksUpsert(ctx context.Context, task models.Task) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TasksUpsert", ctx, task) + ret0, _ := ret[0].(error) + return ret0 +} + +// TasksUpsert indicates an expected call of TasksUpsert. +func (mr *MockStorageMockRecorder) TasksUpsert(ctx, task any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TasksUpsert", reflect.TypeOf((*MockStorage)(nil).TasksUpsert), ctx, task) +} + +// WebhooksConfigsDeleteFromConnectorID mocks base method. +func (m *MockStorage) WebhooksConfigsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksConfigsDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// WebhooksConfigsDeleteFromConnectorID indicates an expected call of WebhooksConfigsDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) WebhooksConfigsDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksConfigsDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).WebhooksConfigsDeleteFromConnectorID), ctx, connectorID) +} + +// WebhooksConfigsGet mocks base method. +func (m *MockStorage) WebhooksConfigsGet(ctx context.Context, name string, connectorID models.ConnectorID) (*models.WebhookConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksConfigsGet", ctx, name, connectorID) + ret0, _ := ret[0].(*models.WebhookConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WebhooksConfigsGet indicates an expected call of WebhooksConfigsGet. +func (mr *MockStorageMockRecorder) WebhooksConfigsGet(ctx, name, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksConfigsGet", reflect.TypeOf((*MockStorage)(nil).WebhooksConfigsGet), ctx, name, connectorID) +} + +// WebhooksConfigsGetFromConnectorID mocks base method. +func (m *MockStorage) WebhooksConfigsGetFromConnectorID(ctx context.Context, connectorID models.ConnectorID) ([]models.WebhookConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksConfigsGetFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].([]models.WebhookConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WebhooksConfigsGetFromConnectorID indicates an expected call of WebhooksConfigsGetFromConnectorID. +func (mr *MockStorageMockRecorder) WebhooksConfigsGetFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksConfigsGetFromConnectorID", reflect.TypeOf((*MockStorage)(nil).WebhooksConfigsGetFromConnectorID), ctx, connectorID) +} + +// WebhooksConfigsUpsert mocks base method. +func (m *MockStorage) WebhooksConfigsUpsert(ctx context.Context, webhooksConfigs []models.WebhookConfig) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksConfigsUpsert", ctx, webhooksConfigs) + ret0, _ := ret[0].(error) + return ret0 +} + +// WebhooksConfigsUpsert indicates an expected call of WebhooksConfigsUpsert. +func (mr *MockStorageMockRecorder) WebhooksConfigsUpsert(ctx, webhooksConfigs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksConfigsUpsert", reflect.TypeOf((*MockStorage)(nil).WebhooksConfigsUpsert), ctx, webhooksConfigs) +} + +// WebhooksDeleteFromConnectorID mocks base method. +func (m *MockStorage) WebhooksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksDeleteFromConnectorID", ctx, connectorID) + ret0, _ := ret[0].(error) + return ret0 +} + +// WebhooksDeleteFromConnectorID indicates an expected call of WebhooksDeleteFromConnectorID. +func (mr *MockStorageMockRecorder) WebhooksDeleteFromConnectorID(ctx, connectorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksDeleteFromConnectorID", reflect.TypeOf((*MockStorage)(nil).WebhooksDeleteFromConnectorID), ctx, connectorID) +} + +// WebhooksGet mocks base method. +func (m *MockStorage) WebhooksGet(ctx context.Context, id string) (models.Webhook, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksGet", ctx, id) + ret0, _ := ret[0].(models.Webhook) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WebhooksGet indicates an expected call of WebhooksGet. +func (mr *MockStorageMockRecorder) WebhooksGet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksGet", reflect.TypeOf((*MockStorage)(nil).WebhooksGet), ctx, id) +} + +// WebhooksInsert mocks base method. +func (m *MockStorage) WebhooksInsert(ctx context.Context, webhook models.Webhook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WebhooksInsert", ctx, webhook) + ret0, _ := ret[0].(error) + return ret0 +} + +// WebhooksInsert indicates an expected call of WebhooksInsert. +func (mr *MockStorageMockRecorder) WebhooksInsert(ctx, webhook any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebhooksInsert", reflect.TypeOf((*MockStorage)(nil).WebhooksInsert), ctx, webhook) +} diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go new file mode 100644 index 00000000..10c52592 --- /dev/null +++ b/internal/storage/tasks.go @@ -0,0 +1,105 @@ +package storage + +import ( + "context" + + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type task struct { + bun.BaseModel `bun:"table:tasks"` + + // Mandatory fields + ID models.TaskID `bun:"id,pk,type:character varying,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + Status models.TaskStatus `bun:"status,type:text,notnull"` + CreatedAt time.Time `bun:"created_at,type:timestamp without time zone,notnull"` + UpdatedAt time.Time `bun:"updated_at,type:timestamp without time zone,notnull"` + + // Optional fields + CreatedObjectID *string `bun:"created_object_id,type:character varying"` + Error *string `bun:"error,type:text"` +} + +func (s *store) TasksUpsert(ctx context.Context, task models.Task) error { + t := fromTaskModel(task) + + query := s.db.NewInsert(). + Model(&t). + On("CONFLICT (id) DO UPDATE"). + Set("status = EXCLUDED.status"). + Set("updated_at = EXCLUDED.updated_at") + + if task.CreatedObjectID != nil { + query.Set("created_object_id = EXCLUDED.created_object_id") + } + + if task.Error != nil { + query.Set("error = EXCLUDED.error") + } + + _, err := query. + Exec(ctx) + + return e("failed to insert task", err) +} + +func (s *store) TasksGet(ctx context.Context, id models.TaskID) (*models.Task, error) { + var t task + + err := s.db.NewSelect(). + Model(&t). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, e("failed to fetch task", err) + } + + return pointer.For(toTaskModel(t)), nil +} + +func (s *store) TasksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*task)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + return e("failed to delete tasks", err) +} + +func fromTaskModel(from models.Task) task { + return task{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Status: from.Status, + CreatedAt: time.New(from.CreatedAt), + UpdatedAt: time.New(from.UpdatedAt), + CreatedObjectID: from.CreatedObjectID, + Error: func() *string { + if from.Error == nil { + return nil + } + return pointer.For(from.Error.Error()) + }(), + } +} + +func toTaskModel(from task) models.Task { + return models.Task{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Status: from.Status, + CreatedAt: from.CreatedAt.Time, + UpdatedAt: from.UpdatedAt.Time, + CreatedObjectID: from.CreatedObjectID, + Error: func() error { + if from.Error == nil { + return nil + } + return errors.New(*from.Error) + }(), + } +} diff --git a/internal/storage/tasks_test.go b/internal/storage/tasks_test.go new file mode 100644 index 00000000..099c2019 --- /dev/null +++ b/internal/storage/tasks_test.go @@ -0,0 +1,216 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +var ( + defaultTasks = []models.Task{ + { + ID: models.TaskID{ + Reference: "1", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + Status: "FAILED", + CreatedAt: now.Add(-time.Hour).UTC().Time, + UpdatedAt: now.Add(-time.Hour).UTC().Time, + Error: errors.New("test error"), + }, + { + ID: models.TaskID{ + Reference: "2", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + Status: "PROCESSING", + CreatedAt: now.Add(-2 * time.Hour).UTC().Time, + UpdatedAt: now.Add(-2 * time.Hour).UTC().Time, + }, + { + ID: models.TaskID{ + Reference: "3", + ConnectorID: defaultConnector.ID, + }, + ConnectorID: defaultConnector.ID, + Status: "SUCCEEDED", + CreatedAt: now.Add(-3 * time.Hour).UTC().Time, + UpdatedAt: now.Add(-3 * time.Hour).UTC().Time, + CreatedObjectID: pointer.For("test object id"), + }, + { + ID: models.TaskID{ + Reference: "4", + ConnectorID: defaultConnector2.ID, + }, + ConnectorID: defaultConnector2.ID, + Status: "PROCESSING", + CreatedAt: now.Add(-4 * time.Hour).UTC().Time, + UpdatedAt: now.Add(-4 * time.Hour).UTC().Time, + }, + } +) + +func upsertTasks(t *testing.T, ctx context.Context, storage Storage, tasks []models.Task) { + t.Helper() + + for _, task := range tasks { + err := storage.TasksUpsert(ctx, task) + require.NoError(t, err) + } +} + +func TestTasksUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertTasks(t, ctx, store, defaultTasks) + + t.Run("same id upsert", func(t *testing.T) { + t2 := models.Task{ + ID: defaultTasks[1].ID, + ConnectorID: defaultConnector.ID, + Status: "FAILED", + CreatedAt: now.Add(-1 * time.Hour).UTC().Time, + UpdatedAt: now.Add(-1 * time.Hour).UTC().Time, + CreatedObjectID: pointer.For("test object id 2"), + } + + err := store.TasksUpsert(ctx, t2) + require.NoError(t, err) + + t3, err := store.TasksGet(ctx, t2.ID) + require.NoError(t, err) + + expectedTask := models.Task{ + ID: defaultTasks[1].ID, + ConnectorID: defaultConnector.ID, + Status: "FAILED", + CreatedAt: now.Add(-2 * time.Hour).UTC().Time, + UpdatedAt: now.Add(-1 * time.Hour).UTC().Time, + CreatedObjectID: pointer.For("test object id 2"), + } + + compareTasks(t, expectedTask, *t3) + }) + + t.Run("unknown connector id", func(t *testing.T) { + t2 := models.Task{ + ID: models.TaskID{ + Reference: "5", + ConnectorID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, + }, + ConnectorID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, + Status: "FAILED", + CreatedAt: now.Add(-1 * time.Hour).UTC().Time, + UpdatedAt: now.Add(-1 * time.Hour).UTC().Time, + Error: errors.New("test error"), + } + + err := store.TasksUpsert(ctx, t2) + require.Error(t, err) + }) +} + +func TestTasksGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertTasks(t, ctx, store, defaultTasks) + + t.Run("get task", func(t *testing.T) { + for _, task := range defaultTasks { + t2, err := store.TasksGet(ctx, task.ID) + require.NoError(t, err) + compareTasks(t, task, *t2) + } + }) + + t.Run("unknown task", func(t *testing.T) { + _, err := store.TasksGet(ctx, models.TaskID{}) + require.ErrorIs(t, err, ErrNotFound) + require.Error(t, err) + }) +} + +func TestTasksDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertConnector(t, ctx, store, defaultConnector2) + upsertTasks(t, ctx, store, defaultTasks) + + t.Run("unknown connector id", func(t *testing.T) { + err := store.TasksDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }) + require.NoError(t, err) + + for _, task := range defaultTasks { + t2, err := store.TasksGet(ctx, task.ID) + require.NoError(t, err) + compareTasks(t, task, *t2) + } + }) + + t.Run("delete tasks", func(t *testing.T) { + err := store.TasksDeleteFromConnectorID(ctx, defaultConnector.ID) + require.NoError(t, err) + + for _, task := range defaultTasks { + if task.ConnectorID == defaultConnector.ID { + _, err := store.TasksGet(ctx, task.ID) + require.ErrorIs(t, err, ErrNotFound) + require.Error(t, err) + } else { + t2, err := store.TasksGet(ctx, task.ID) + require.NoError(t, err) + compareTasks(t, task, *t2) + } + } + }) +} + +func compareTasks(t *testing.T, expected, actual models.Task) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.ConnectorID, actual.ConnectorID) + require.Equal(t, expected.Status, actual.Status) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.UpdatedAt, actual.UpdatedAt) + require.Equal(t, expected.CreatedObjectID, actual.CreatedObjectID) + + switch { + case expected.Error == nil && actual.Error == nil: + case expected.Error != nil && actual.Error != nil: + require.Equal(t, expected.Error.Error(), actual.Error.Error()) + default: + t.Errorf("expected error %v, got %v", expected.Error, actual.Error) + } +} diff --git a/internal/storage/utils.go b/internal/storage/utils.go new file mode 100644 index 00000000..29c09dc0 --- /dev/null +++ b/internal/storage/utils.go @@ -0,0 +1,7 @@ +package storage + +import "regexp" + +var ( + metadataRegex = regexp.MustCompile(`metadata\[(.+)\]`) +) diff --git a/internal/storage/utils_test.go b/internal/storage/utils_test.go new file mode 100644 index 00000000..e8d034a8 --- /dev/null +++ b/internal/storage/utils_test.go @@ -0,0 +1,27 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMetadataRegexp(t *testing.T) { + t.Parallel() + + t.Run("valid tests", func(t *testing.T) { + t.Parallel() + require.True(t, metadataRegex.MatchString("metadata[foo]")) + require.True(t, metadataRegex.MatchString("metadata[foo_bar]")) + require.True(t, metadataRegex.MatchString("metadata[foo/bar]")) + require.True(t, metadataRegex.MatchString("metadata[foo.bar]")) + }) + + t.Run("invalid tests", func(t *testing.T) { + t.Parallel() + + require.False(t, metadataRegex.MatchString("metadata[foo")) + require.False(t, metadataRegex.MatchString("metadata/foo")) + require.False(t, metadataRegex.MatchString("metadata.foo")) + }) +} diff --git a/internal/storage/webhooks.go b/internal/storage/webhooks.go new file mode 100644 index 00000000..c4d974d8 --- /dev/null +++ b/internal/storage/webhooks.go @@ -0,0 +1,80 @@ +package storage + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type webhook struct { + bun.BaseModel `bun:"table:webhooks"` + + // Mandatory fields + ID string `bun:"id,pk,type:uuid,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,type:character varying,notnull"` + + // Optional fields + Headers map[string][]string `bun:"headers,type:json"` + QueryValues map[string][]string `bun:"query_values,type:json"` + Body []byte `bun:"body,type:bytea,nullzero"` +} + +func (s *store) WebhooksInsert(ctx context.Context, webhook models.Webhook) error { + toInsert := fromWebhookModels(webhook) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("insert webhook", err) + } + + return nil +} + +func (s *store) WebhooksGet(ctx context.Context, id string) (models.Webhook, error) { + var w webhook + err := s.db.NewSelect(). + Model(&w). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return models.Webhook{}, e("get webhook", err) + } + + return toWebhookModels(w), nil +} + +func (s *store) WebhooksDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*webhook)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + if err != nil { + return e("delete webhook", err) + } + + return nil +} + +func fromWebhookModels(from models.Webhook) webhook { + return webhook{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Headers: from.Headers, + QueryValues: from.QueryValues, + Body: from.Body, + } +} + +func toWebhookModels(from webhook) models.Webhook { + return models.Webhook{ + ID: from.ID, + ConnectorID: from.ConnectorID, + Headers: from.Headers, + QueryValues: from.QueryValues, + Body: from.Body, + } +} diff --git a/internal/storage/webhooks_configs.go b/internal/storage/webhooks_configs.go new file mode 100644 index 00000000..e32095ef --- /dev/null +++ b/internal/storage/webhooks_configs.go @@ -0,0 +1,99 @@ +package storage + +import ( + "context" + + "github.com/formancehq/payments/internal/models" + "github.com/uptrace/bun" +) + +type webhookConfig struct { + bun.BaseModel `bun:"table:webhooks_configs"` + + // Mandatory fields + Name string `bun:"name,pk,type:text,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,pk,type:character varying,notnull"` + URLPath string `bun:"url_path,type:text,notnull"` +} + +func (s *store) WebhooksConfigsUpsert(ctx context.Context, webhooksConfigs []models.WebhookConfig) error { + toInsert := fromWebhooksConfigsModels(webhooksConfigs) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (name, connector_id) DO NOTHING"). + Exec(ctx) + if err != nil { + return e("upsert webhook config", err) + } + + return nil +} + +func (s *store) WebhooksConfigsGet(ctx context.Context, name string, connectorID models.ConnectorID) (*models.WebhookConfig, error) { + var webhookConfig webhookConfig + err := s.db.NewSelect(). + Model(&webhookConfig). + Where("name = ? AND connector_id = ?", name, connectorID). + Scan(ctx) + if err != nil { + return nil, e("get webhook config", err) + } + + return toWebhookConfigModel(webhookConfig), nil +} + +func (s *store) WebhooksConfigsGetFromConnectorID(ctx context.Context, connectorID models.ConnectorID) ([]models.WebhookConfig, error) { + var webhookConfigs []webhookConfig + err := s.db.NewSelect(). + Model(&webhookConfigs). + Where("connector_id = ?", connectorID). + Scan(ctx) + if err != nil { + return nil, e("get webhook configs", err) + } + + res := make([]models.WebhookConfig, 0, len(webhookConfigs)) + for _, webhookConfig := range webhookConfigs { + res = append(res, *toWebhookConfigModel(webhookConfig)) + } + + return res, nil +} + +func (s *store) WebhooksConfigsDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*webhookConfig)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + if err != nil { + return e("delete webhook config", err) + } + + return nil +} + +func fromWebhookConfigModels(from models.WebhookConfig) webhookConfig { + return webhookConfig{ + Name: from.Name, + ConnectorID: from.ConnectorID, + URLPath: from.URLPath, + } +} + +func fromWebhooksConfigsModels(from []models.WebhookConfig) []webhookConfig { + to := make([]webhookConfig, 0, len(from)) + for _, webhookConfig := range from { + to = append(to, fromWebhookConfigModels(webhookConfig)) + } + + return to +} + +func toWebhookConfigModel(from webhookConfig) *models.WebhookConfig { + return &models.WebhookConfig{ + Name: from.Name, + ConnectorID: from.ConnectorID, + URLPath: from.URLPath, + } +} diff --git a/internal/storage/webhooks_configs_test.go b/internal/storage/webhooks_configs_test.go new file mode 100644 index 00000000..859d2e33 --- /dev/null +++ b/internal/storage/webhooks_configs_test.go @@ -0,0 +1,129 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultWebhooksConfigs = []models.WebhookConfig{ + { + Name: "test1", + ConnectorID: defaultConnector.ID, + URLPath: "/test1", + }, + { + Name: "test2", + ConnectorID: defaultConnector.ID, + URLPath: "/test2", + }, + { + Name: "test3", + ConnectorID: defaultConnector.ID, + URLPath: "/test3", + }, + } +) + +func upsertWebhookConfigs(t *testing.T, ctx context.Context, storage Storage, webhookConfigs []models.WebhookConfig) { + require.NoError(t, storage.WebhooksConfigsUpsert(ctx, webhookConfigs)) +} + +func TestWebhooksConfigsUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertWebhookConfigs(t, ctx, store, defaultWebhooksConfigs) + + t.Run("same name and connector id insert", func(t *testing.T) { + w := models.WebhookConfig{ + Name: "test1", + ConnectorID: defaultConnector.ID, + URLPath: "/test3", + } + + require.NoError(t, store.WebhooksConfigsUpsert(ctx, []models.WebhookConfig{w})) + + actual, err := store.WebhooksConfigsGet(ctx, w.Name, w.ConnectorID) + require.NoError(t, err) + require.Equal(t, defaultWebhooksConfigs[0], *actual) + }) + + t.Run("unknown connector id", func(t *testing.T) { + w := models.WebhookConfig{ + Name: "test1", + ConnectorID: models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + }, + URLPath: "/test3", + } + + require.Error(t, store.WebhooksConfigsUpsert(ctx, []models.WebhookConfig{w})) + }) +} + +func TestWebhooksConfigsGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertWebhookConfigs(t, ctx, store, defaultWebhooksConfigs) + + t.Run("get webhook config", func(t *testing.T) { + for _, w := range defaultWebhooksConfigs { + actual, err := store.WebhooksConfigsGet(ctx, w.Name, w.ConnectorID) + require.NoError(t, err) + require.Equal(t, w, *actual) + } + }) + + t.Run("unknown webhook config", func(t *testing.T) { + _, err := store.WebhooksConfigsGet(ctx, "unknown", defaultConnector.ID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) +} + +func TestWebhooksConfigsDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + upsertWebhookConfigs(t, ctx, store, defaultWebhooksConfigs) + + t.Run("delete webhooks configs from unknown connector id", func(t *testing.T) { + require.NoError(t, store.WebhooksConfigsDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, w := range defaultWebhooksConfigs { + actual, err := store.WebhooksConfigsGet(ctx, w.Name, w.ConnectorID) + require.NoError(t, err) + require.Equal(t, w, *actual) + } + }) + + t.Run("delete webhooks configs from connector id", func(t *testing.T) { + require.NoError(t, store.WebhooksConfigsDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, w := range defaultWebhooksConfigs { + _, err := store.WebhooksConfigsGet(ctx, w.Name, w.ConnectorID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} diff --git a/internal/storage/webhooks_test.go b/internal/storage/webhooks_test.go new file mode 100644 index 00000000..820dc827 --- /dev/null +++ b/internal/storage/webhooks_test.go @@ -0,0 +1,138 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultWebhooks = []models.Webhook{ + { + ID: "test1", + ConnectorID: defaultConnector.ID, + QueryValues: map[string][]string{ + "foo": {"bar"}, + }, + Headers: map[string][]string{ + "foo2": {"bar2"}, + }, + Body: []byte(`{}`), + }, + { + ID: "test2", + ConnectorID: defaultConnector.ID, + QueryValues: map[string][]string{ + "foo3": {"bar3"}, + }, + Headers: map[string][]string{ + "foo4": {"bar4"}, + }, + Body: []byte(`{}`), + }, + } +) + +func upsertWebhook(t *testing.T, ctx context.Context, storage Storage, webhook models.Webhook) { + require.NoError(t, storage.WebhooksInsert(ctx, webhook)) +} + +func TestWebhooksInsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, webhook := range defaultWebhooks { + upsertWebhook(t, ctx, store, webhook) + } + + t.Run("same id upsert", func(t *testing.T) { + webhook := defaultWebhooks[0] + webhook.QueryValues = map[string][]string{ + "changed": {"changed"}, + } + + require.NoError(t, store.WebhooksInsert(ctx, webhook)) + + // should not have been changed + actual, err := store.WebhooksGet(ctx, webhook.ID) + require.NoError(t, err) + require.Equal(t, defaultWebhooks[0], actual) + }) + + t.Run("unknown connector id", func(t *testing.T) { + webhook := defaultWebhooks[0] + webhook.ID = "unknown" + webhook.ConnectorID = models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + require.Error(t, store.WebhooksInsert(ctx, webhook)) + }) +} + +func TestWebhooksGet(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, webhook := range defaultWebhooks { + upsertWebhook(t, ctx, store, webhook) + } + + t.Run("get webhook", func(t *testing.T) { + for _, webhook := range defaultWebhooks { + actual, err := store.WebhooksGet(ctx, webhook.ID) + require.NoError(t, err) + require.Equal(t, webhook, actual) + } + }) + + t.Run("get unknown webhook", func(t *testing.T) { + _, err := store.WebhooksGet(ctx, "unknown") + require.Error(t, err) + }) +} + +func TestWebhooksDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, webhook := range defaultWebhooks { + upsertWebhook(t, ctx, store, webhook) + } + + t.Run("delete unknown connector id", func(t *testing.T) { + require.NoError(t, store.WebhooksDeleteFromConnectorID(ctx, models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })) + + for _, webhook := range defaultWebhooks { + actual, err := store.WebhooksGet(ctx, webhook.ID) + require.NoError(t, err) + require.Equal(t, webhook, actual) + } + }) + + t.Run("delete webhooks", func(t *testing.T) { + require.NoError(t, store.WebhooksDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, webhook := range defaultWebhooks { + _, err := store.WebhooksGet(ctx, webhook.ID) + require.Error(t, err) + } + }) +} diff --git a/internal/storage/workflow_instances.go b/internal/storage/workflow_instances.go new file mode 100644 index 00000000..cb7c8cfa --- /dev/null +++ b/internal/storage/workflow_instances.go @@ -0,0 +1,192 @@ +package storage + +import ( + "context" + "fmt" + "time" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + internalTime "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type instance struct { + bun.BaseModel `bun:"table:workflows_instances"` + + // Mandatory fields + ID string `bun:"id,pk,type:text,notnull"` + ScheduleID string `bun:"schedule_id,pk,type:text,notnull"` + ConnectorID models.ConnectorID `bun:"connector_id,pk,type:character varying,notnull"` + CreatedAt internalTime.Time `bun:"created_at,type:timestamp without time zone,notnull"` + UpdatedAt internalTime.Time `bun:"updated_at,type:timestamp without time zone,notnull"` + + // Optional fields with default + // c.f. https://bun.uptrace.dev/guide/models.html#default + Terminated bool `bun:"terminated,type:boolean,notnull,nullzero,default:false"` + + // Optional fields + // c.f.: https://bun.uptrace.dev/guide/models.html#nulls + TerminatedAt *internalTime.Time `bun:"terminated_at,type:timestamp without time zone,nullzero"` + Error *string `bun:"error,type:text,nullzero"` +} + +func (s *store) InstancesUpsert(ctx context.Context, instance models.Instance) error { + toInsert := fromInstanceModel(instance) + + _, err := s.db.NewInsert(). + Model(&toInsert). + On("CONFLICT (id, schedule_id, connector_id) DO NOTHING"). + Exec(ctx) + + return e("failed to insert new instance", err) +} + +func (s *store) InstancesUpdate(ctx context.Context, instance models.Instance) error { + toUpdate := fromInstanceModel(instance) + + _, err := s.db.NewUpdate(). + Model(&toUpdate). + Set("updated_at = ?", instance.UpdatedAt). + Set("terminated = ?", instance.Terminated). + Set("terminated_at = ?", instance.TerminatedAt). + Set("error = ?", instance.Error). + WherePK(). + Exec(ctx) + + return e("failed to update instance", err) +} + +func (s *store) InstancesDeleteFromConnectorID(ctx context.Context, connectorID models.ConnectorID) error { + _, err := s.db.NewDelete(). + Model((*instance)(nil)). + Where("connector_id = ?", connectorID). + Exec(ctx) + + return e("failed to delete instances", err) +} + +type InstanceQuery struct{} + +type ListInstancesQuery bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[InstanceQuery]] + +func NewListInstancesQuery(opts bunpaginate.PaginatedQueryOptions[InstanceQuery]) ListInstancesQuery { + return ListInstancesQuery{ + Order: bunpaginate.OrderAsc, + PageSize: opts.PageSize, + Options: opts, + } +} + +func (s *store) instancesQueryContext(qb query.Builder) (string, []any, error) { + return qb.Build(query.ContextFn(func(key, operator string, value any) (string, []any, error) { + switch { + case key == "schedule_id", + key == "connector_id": + if operator != "$match" { + return "", nil, errors.Wrap(ErrValidation, "'connector_id' column can only be used with $match") + } + return fmt.Sprintf("%s = ?", key), []any{value}, nil + default: + return "", nil, errors.Wrap(ErrValidation, fmt.Sprintf("unknown key '%s' when building query", key)) + } + })) +} + +func (s *store) InstancesGet(ctx context.Context, id string, scheduleID string, connectorID models.ConnectorID) (*models.Instance, error) { + var i instance + err := s.db.NewSelect(). + Model(&i). + Where("id = ?", id). + Where("schedule_id = ?", scheduleID). + Where("connector_id = ?", connectorID). + Scan(ctx) + if err != nil { + return nil, e("failed to fetch instance", err) + } + + return pointer.For(toInstanceModel(i)), nil +} + +func (s *store) InstancesList(ctx context.Context, q ListInstancesQuery) (*bunpaginate.Cursor[models.Instance], error) { + var ( + where string + args []any + err error + ) + if q.Options.QueryBuilder != nil { + where, args, err = s.instancesQueryContext(q.Options.QueryBuilder) + if err != nil { + return nil, err + } + } + + cursor, err := paginateWithOffset[bunpaginate.PaginatedQueryOptions[InstanceQuery], instance](s, ctx, + (*bunpaginate.OffsetPaginatedQuery[bunpaginate.PaginatedQueryOptions[InstanceQuery]])(&q), + func(query *bun.SelectQuery) *bun.SelectQuery { + if where != "" { + query = query.Where(where, args...) + } + + query = query.Order("created_at DESC", "sort_id DESC") + + return query + }, + ) + if err != nil { + return nil, e("failed to fetch instances", err) + } + + instances := make([]models.Instance, 0, len(cursor.Data)) + for _, i := range cursor.Data { + instances = append(instances, toInstanceModel(i)) + } + + return &bunpaginate.Cursor[models.Instance]{ + PageSize: cursor.PageSize, + HasMore: cursor.HasMore, + Previous: cursor.Previous, + Next: cursor.Next, + Data: instances, + }, nil +} + +func fromInstanceModel(from models.Instance) instance { + return instance{ + ID: from.ID, + ScheduleID: from.ScheduleID, + ConnectorID: from.ConnectorID, + CreatedAt: internalTime.New(from.CreatedAt), + UpdatedAt: internalTime.New(from.UpdatedAt), + Terminated: from.Terminated, + TerminatedAt: func() *internalTime.Time { + if from.TerminatedAt == nil { + return nil + } + return pointer.For(internalTime.New(*from.TerminatedAt)) + }(), + Error: from.Error, + } +} + +func toInstanceModel(from instance) models.Instance { + return models.Instance{ + ID: from.ID, + ScheduleID: from.ScheduleID, + ConnectorID: from.ConnectorID, + CreatedAt: from.CreatedAt.Time, + UpdatedAt: from.UpdatedAt.Time, + Terminated: from.Terminated, + TerminatedAt: func() *time.Time { + if from.TerminatedAt == nil { + return nil + } + + return pointer.For(from.TerminatedAt.Time) + }(), + Error: from.Error, + } +} diff --git a/internal/storage/workflow_instances_test.go b/internal/storage/workflow_instances_test.go new file mode 100644 index 00000000..368cfdb7 --- /dev/null +++ b/internal/storage/workflow_instances_test.go @@ -0,0 +1,318 @@ +package storage + +import ( + "context" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/pointer" + "github.com/formancehq/go-libs/v2/query" + "github.com/formancehq/go-libs/v2/time" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var ( + defaultWorkflowInstances = []models.Instance{ + { + ID: "test1", + ScheduleID: defaultSchedules[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-60 * time.Minute).UTC().Time, + UpdatedAt: now.Add(-60 * time.Minute).UTC().Time, + Terminated: false, + }, + { + ID: "test2", + ScheduleID: defaultSchedules[0].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-30 * time.Minute).UTC().Time, + UpdatedAt: now.Add(-30 * time.Minute).UTC().Time, + Terminated: false, + }, + { + ID: "test3", + ScheduleID: defaultSchedules[2].ID, + ConnectorID: defaultConnector.ID, + CreatedAt: now.Add(-55 * time.Minute).UTC().Time, + UpdatedAt: now.Add(-55 * time.Minute).UTC().Time, + Terminated: true, + TerminatedAt: pointer.For(now.UTC().Time), + Error: pointer.For("test error"), + }, + } +) + +func upsertInstance(t *testing.T, ctx context.Context, storage Storage, instance models.Instance) { + require.NoError(t, storage.InstancesUpsert(ctx, instance)) +} + +func TestInstancesUpsert(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, schedule := range defaultSchedules { + upsertSchedule(t, ctx, store, schedule) + } + for _, instance := range defaultWorkflowInstances { + upsertInstance(t, ctx, store, instance) + } + + t.Run("same id upsert", func(t *testing.T) { + instance := defaultWorkflowInstances[0] + instance.Terminated = true + instance.TerminatedAt = pointer.For(now.UTC().Time) + instance.Error = pointer.For("test error") + + upsertInstance(t, ctx, store, instance) + + actual, err := store.InstancesGet(ctx, instance.ID, instance.ScheduleID, instance.ConnectorID) + require.NoError(t, err) + require.Equal(t, defaultWorkflowInstances[0], *actual) + }) + + t.Run("unknown connector id", func(t *testing.T) { + instance := defaultWorkflowInstances[0] + instance.ConnectorID = models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + err := store.InstancesUpsert(ctx, instance) + require.Error(t, err) + }) + + t.Run("unknown schedule id", func(t *testing.T) { + instance := defaultWorkflowInstances[0] + instance.ScheduleID = uuid.New().String() + + err := store.InstancesUpsert(ctx, instance) + require.Error(t, err) + }) +} + +func TestInstancesUpdate(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, schedule := range defaultSchedules { + upsertSchedule(t, ctx, store, schedule) + } + for _, instance := range defaultWorkflowInstances { + upsertInstance(t, ctx, store, instance) + } + + t.Run("update instance error", func(t *testing.T) { + instance := defaultWorkflowInstances[0] + instance.Error = pointer.For("test error") + instance.Terminated = true + instance.TerminatedAt = pointer.For(now.UTC().Time) + + err := store.InstancesUpdate(ctx, instance) + require.NoError(t, err) + + actual, err := store.InstancesGet(ctx, instance.ID, instance.ScheduleID, instance.ConnectorID) + require.NoError(t, err) + require.Equal(t, instance, *actual) + }) + + t.Run("update instance already on error", func(t *testing.T) { + instance := defaultWorkflowInstances[2] + instance.Error = pointer.For("test error2") + instance.Terminated = true + instance.TerminatedAt = pointer.For(now.UTC().Time) + + err := store.InstancesUpdate(ctx, instance) + require.NoError(t, err) + + actual, err := store.InstancesGet(ctx, instance.ID, instance.ScheduleID, instance.ConnectorID) + require.NoError(t, err) + require.Equal(t, instance, *actual) + }) +} + +func TestInstancesDeleteFromConnectorID(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, schedule := range defaultSchedules { + upsertSchedule(t, ctx, store, schedule) + } + for _, instance := range defaultWorkflowInstances { + upsertInstance(t, ctx, store, instance) + } + + t.Run("delete instances from unknown connector", func(t *testing.T) { + unknownConnectorID := models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + } + + require.NoError(t, store.InstancesDeleteFromConnectorID(ctx, unknownConnectorID)) + + for _, instance := range defaultWorkflowInstances { + actual, err := store.InstancesGet(ctx, instance.ID, instance.ScheduleID, instance.ConnectorID) + require.NoError(t, err) + require.Equal(t, instance, *actual) + } + }) + + t.Run("delete instances from default connector", func(t *testing.T) { + require.NoError(t, store.InstancesDeleteFromConnectorID(ctx, defaultConnector.ID)) + + for _, instance := range defaultWorkflowInstances { + _, err := store.InstancesGet(ctx, instance.ID, instance.ScheduleID, instance.ConnectorID) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + } + }) +} + +func TestInstancesList(t *testing.T) { + t.Parallel() + + ctx := logging.TestingContext() + store := newStore(t) + + upsertConnector(t, ctx, store, defaultConnector) + for _, schedule := range defaultSchedules { + upsertSchedule(t, ctx, store, schedule) + } + for _, instance := range defaultWorkflowInstances { + upsertInstance(t, ctx, store, instance) + } + + t.Run("list instances by schedule_id", func(t *testing.T) { + q := NewListInstancesQuery( + bunpaginate.NewPaginatedQueryOptions(InstanceQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("schedule_id", defaultSchedules[0].ID)), + ) + + cursor, err := store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 2, len(cursor.Data)) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[1], cursor.Data[0]) + require.Equal(t, defaultWorkflowInstances[0], cursor.Data[1]) + }) + + t.Run("list instances by unknown schedule_id", func(t *testing.T) { + q := NewListInstancesQuery( + bunpaginate.NewPaginatedQueryOptions(InstanceQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("schedule_id", uuid.New().String())), + ) + + cursor, err := store.InstancesList(ctx, q) + require.NoError(t, err) + require.Empty(t, cursor.Data) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list instances by connector_id", func(t *testing.T) { + q := NewListInstancesQuery( + bunpaginate.NewPaginatedQueryOptions(InstanceQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", defaultConnector.ID)), + ) + + cursor, err := store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 3, len(cursor.Data)) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[1], cursor.Data[0]) + require.Equal(t, defaultWorkflowInstances[2], cursor.Data[1]) + require.Equal(t, defaultWorkflowInstances[0], cursor.Data[2]) + }) + + t.Run("list instances by unknown connector_id", func(t *testing.T) { + q := NewListInstancesQuery( + bunpaginate.NewPaginatedQueryOptions(InstanceQuery{}). + WithPageSize(15). + WithQueryBuilder(query.Match("connector_id", models.ConnectorID{ + Reference: uuid.New(), + Provider: "unknown", + })), + ) + + cursor, err := store.InstancesList(ctx, q) + require.NoError(t, err) + require.Empty(t, cursor.Data) + require.False(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.Empty(t, cursor.Next) + }) + + t.Run("list instances test cursor", func(t *testing.T) { + q := NewListInstancesQuery( + bunpaginate.NewPaginatedQueryOptions(InstanceQuery{}). + WithPageSize(1), + ) + + cursor, err := store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[1], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Next, &q) + require.NoError(t, err) + cursor, err = store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.False(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.Empty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[0], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.NotEmpty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[2], cursor.Data[0]) + + err = bunpaginate.UnmarshalCursor(cursor.Previous, &q) + require.NoError(t, err) + cursor, err = store.InstancesList(ctx, q) + require.NoError(t, err) + require.Equal(t, 1, len(cursor.Data)) + require.True(t, cursor.HasMore) + require.Empty(t, cursor.Previous) + require.NotEmpty(t, cursor.Next) + require.Equal(t, defaultWorkflowInstances[1], cursor.Data[0]) + }) +} diff --git a/internal/utils/pagination/paginator.go b/internal/utils/pagination/paginator.go new file mode 100644 index 00000000..03836989 --- /dev/null +++ b/internal/utils/pagination/paginator.go @@ -0,0 +1,29 @@ +package pagination + +// ShouldFetchMore determines if we need more objects to fetch and if we have +// more objects to fetch. +// This function should be used only if the following requirements are met: +// - When fetching some objects, you need to append all of them to a slice +func ShouldFetchMore[T any, C any](total []T, currentBatch []C, pageSize int) (needMore bool, hasMore bool) { + switch { + case len(total) > pageSize: + // We fetched more than we should, the total will be trimed, we don't + // need more + needMore = false + // Since the total will be trimed it means we will have to refetch the + // objects trimed, so we have more + hasMore = true + case len(total) == pageSize: + // We don't need more, we fetched exactly what we needed + needMore = false + // hasMore depennds on the currentBatch, if the currentBatch is full + // then we have more + hasMore = len(currentBatch) >= pageSize + default: + // Here, total is < pageSize, so we need more objects + needMore = true + // If the currentBatch is full, we have more + hasMore = len(currentBatch) >= pageSize + } + return +} diff --git a/internal/utils/pagination/paginator_test.go b/internal/utils/pagination/paginator_test.go new file mode 100644 index 00000000..cc23980b --- /dev/null +++ b/internal/utils/pagination/paginator_test.go @@ -0,0 +1,123 @@ +package pagination_test + +import ( + "testing" + + "github.com/formancehq/payments/internal/utils/pagination" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pagination Suite") +} + +var _ = Describe("ShouldFetchMore", func() { + type GenericContainer struct { + Val int + } + + Context("pagination", func() { + It("detects that the total is < pageSize and batch is full", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize; i++ { + if i <= 4 { + total = append(total, GenericContainer{i}) + } + + batch = append(batch, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeTrue()) + Expect(hasMore).To(BeTrue()) + }) + + It("detects that the total is < pageSize and batch is not full", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize; i++ { + if i <= 4 { + total = append(total, GenericContainer{i}) + batch = append(batch, GenericContainer{i}) + } + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeTrue()) + Expect(hasMore).To(BeFalse()) + }) + + It("detects that the total is == pageSize and batch is full", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize; i++ { + total = append(total, GenericContainer{i}) + batch = append(batch, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeFalse()) + Expect(hasMore).To(BeTrue()) + }) + + It("detects that the total is == pageSize and batch is not full", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize; i++ { + if i <= 4 { + batch = append(batch, GenericContainer{i}) + } + total = append(total, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeFalse()) + Expect(hasMore).To(BeFalse()) + }) + + It("detects that the total is > pageSize and batch is not full", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize+10; i++ { + if i <= 4 { + batch = append(batch, GenericContainer{i}) + } + total = append(total, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeFalse()) + Expect(hasMore).To(BeTrue()) + }) + + It("detects that the total is > pageSize and batch is full", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize+10; i++ { + if i < 10 { + batch = append(batch, GenericContainer{i}) + } + total = append(total, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeFalse()) + Expect(hasMore).To(BeTrue()) + }) + }) +}) diff --git a/local_env/postgres/temporal-sql.yaml b/local_env/postgres/temporal-sql.yaml new file mode 100644 index 00000000..228c752f --- /dev/null +++ b/local_env/postgres/temporal-sql.yaml @@ -0,0 +1,6 @@ +limit.maxIDLength: + - value: 255 + constraints: {} +system.forceSearchAttributesCacheRefreshOnRead: + - value: true # Dev setup only. Please don't turn this on in production. + constraints: {} \ No newline at end of file diff --git a/main.go b/main.go index 2c94d7e0..acf9f47c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,9 @@ package main -import "github.com/formancehq/payments/cmd" +import ( + "github.com/formancehq/payments/cmd" + _ "github.com/formancehq/payments/internal/connectors/plugins/public" +) func main() { cmd.Execute() diff --git a/pkg/testserver/api.go b/pkg/testserver/api.go new file mode 100644 index 00000000..7781709e --- /dev/null +++ b/pkg/testserver/api.go @@ -0,0 +1,110 @@ +package testserver + +import ( + "context" + "fmt" + "net/http" +) + +func pathPrefix(version int, path string) string { + if version < 3 { + return fmt.Sprintf("/%s", path) + } + return fmt.Sprintf("/v%d/%s", version, path) +} + +func ConnectorInstall(ctx context.Context, srv *Server, ver int, reqBody any, res any) error { + path := "connectors/install/dummypay" + if ver == 2 { + path = "connectors/dummypay" + } + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, path), reqBody, res) +} + +func ConnectorUninstall(ctx context.Context, srv *Server, ver int, id string, res any) error { + path := "connectors/" + id + if ver == 2 { + path = "connectors/dummypay/" + id + } + return srv.Client().Do(ctx, http.MethodDelete, pathPrefix(ver, path), nil, res) +} + +func ConnectorConfig(ctx context.Context, srv *Server, ver int, id string, res any) error { + path := "connectors/" + id + "/config" + if ver == 2 { + path = "connectors/dummypay/" + id + "/config" + } + return srv.Client().Get(ctx, pathPrefix(ver, path), res) +} + +func ConnectorSchedules(ctx context.Context, srv *Server, ver int, id string, res any) error { + path := "connectors/" + id + "/schedules" + if ver == 2 { + path = "connectors/dummypay/" + id + "/schedules" + } + return srv.Client().Get(ctx, pathPrefix(ver, path), res) +} + +func CreateAccount(ctx context.Context, srv *Server, ver int, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "accounts"), reqBody, res) +} + +func ListAccounts(ctx context.Context, srv *Server, ver int, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "accounts"), res) +} + +func GetAccount(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "accounts/"+id), res) +} + +func GetAccountBalances(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "accounts/"+id+"/balances"), res) +} + +func CreateBankAccount(ctx context.Context, srv *Server, ver int, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "bank-accounts"), reqBody, res) +} + +func GetBankAccount(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "bank-accounts/"+id), res) +} + +func ForwardBankAccount(ctx context.Context, srv *Server, ver int, id string, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "bank-accounts/"+id+"/forward"), reqBody, res) +} + +func UpdateBankAccountMetadata(ctx context.Context, srv *Server, ver int, id string, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPatch, pathPrefix(ver, "bank-accounts/"+id+"/metadata"), reqBody, res) +} + +func CreatePayment(ctx context.Context, srv *Server, ver int, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "payments"), reqBody, res) +} + +func GetPayment(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "payments/"+id), res) +} + +func CreatePaymentInitiation(ctx context.Context, srv *Server, ver int, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "payment-initiations"), reqBody, res) +} + +func GetPaymentInitiation(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "payment-initiations/"+id), res) +} + +func ApprovePaymentInitiation(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "payment-initiations/"+id+"/approve"), nil, res) +} + +func RejectPaymentInitiation(ctx context.Context, srv *Server, ver int, id string) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "payment-initiations/"+id+"/reject"), nil, nil) +} + +func ReversePaymentInitiation(ctx context.Context, srv *Server, ver int, id string, reqBody any, res any) error { + return srv.Client().Do(ctx, http.MethodPost, pathPrefix(ver, "payment-initiations/"+id+"/reverse"), reqBody, res) +} + +func GetTask(ctx context.Context, srv *Server, ver int, id string, res any) error { + return srv.Client().Get(ctx, pathPrefix(ver, "tasks/"+id), res) +} diff --git a/pkg/testserver/client.go b/pkg/testserver/client.go new file mode 100644 index 00000000..1e0e6af0 --- /dev/null +++ b/pkg/testserver/client.go @@ -0,0 +1,89 @@ +package testserver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + "github.com/formancehq/payments/internal/models" + "github.com/stretchr/testify/require" +) + +type Client struct { + baseUrl string + internalClient httpwrapper.Client + transport http.RoundTripper +} + +func NewClient(urlStr string, transport http.RoundTripper) (*Client, error) { + config := &httpwrapper.Config{} + internalClient := httpwrapper.NewClient(config) + return &Client{ + baseUrl: urlStr, + transport: transport, + internalClient: internalClient, + }, nil +} + +func (c *Client) wrapError(err error, method, path string, status int, errBody map[string]interface{}) error { + if err == nil { + return nil + } + return fmt.Errorf("error with status %d for %s to '%s': %w, body: %+v", status, method, path, err, errBody) +} + +func (c *Client) Get(ctx context.Context, path string, resBody any) error { + method := http.MethodGet + req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, nil) + if err != nil { + return err + } + + var errBody map[string]interface{} + status, err := c.internalClient.Do(ctx, req, resBody, &errBody) + return c.wrapError(err, method, path, status, errBody) +} + +func (c *Client) Do(ctx context.Context, method string, path string, body any, resBody any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, bytes.NewReader(b)) + if err != nil { + return err + } + + var errBody map[string]interface{} + status, err := c.internalClient.Do(ctx, req, resBody, &errBody) + return c.wrapError(err, method, path, status, errBody) +} + +func (c *Client) PollTask(ctx context.Context, t T) func(id string) func() models.Task { + return func(id string) func() models.Task { + return func() models.Task { + path := "/v3/tasks/" + id + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseUrl+path, nil) + require.NoError(t, err) + + httpClient := &http.Client{Timeout: 2 * time.Second} + res, err := httpClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + var expectedBody struct{ Data models.Task } + rawBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + err = json.Unmarshal(rawBody, &expectedBody) + require.NoError(t, err) + return expectedBody.Data + } + } +} diff --git a/pkg/testserver/helpers.go b/pkg/testserver/helpers.go new file mode 100644 index 00000000..acbf0846 --- /dev/null +++ b/pkg/testserver/helpers.go @@ -0,0 +1,99 @@ +package testserver + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path" + "time" + + dummy "github.com/formancehq/payments/internal/connectors/plugins/public/dummypay/client" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" + + . "github.com/formancehq/go-libs/v2/testing/utils" + . "github.com/onsi/ginkgo/v2" +) + +func NewTestServer(configurationProvider func() Configuration) *Deferred[*Server] { + d := NewDeferred[*Server]() + BeforeEach(func() { + d.Reset() + d.SetValue(New(GinkgoT(), configurationProvider())) + }) + return d +} + +func Subscribe(t T, testServer *Server) chan *nats.Msg { + subscription, ch, err := testServer.Subscribe() + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, subscription.Unsubscribe()) + }) + + return ch +} + +func TaskPoller(ctx context.Context, t T, testServer *Server) func(id string) func() models.Task { + return testServer.Client().PollTask(ctx, t) +} + +func GeneratePSPData(dir string) ([]dummy.Account, error) { + num := 5 + _, err := os.Stat(dir) + if err != nil { + return []dummy.Account{}, fmt.Errorf("path %q does not exist: %w", dir, err) + } + + accounts := make([]dummy.Account, 0, num) + balances := make([]dummy.Balance, 0, num) + startTime := time.Now().Truncate(time.Second) + for i := 0; i < num; i++ { + id := uuid.New().String() + accounts = append(accounts, dummy.Account{ + ID: id, + Name: fmt.Sprintf("dummy-account-%d", i), + Currency: "EUR", + OpeningDate: startTime.Add(-time.Duration(i) * time.Minute), + }) + balances = append(balances, dummy.Balance{ + AccountID: id, + AmountInMinors: int64(i*100 + 23), + Currency: "EUR", + }) + } + + accountsFilePath := path.Join(dir, "accounts.json") + err = persistData(accountsFilePath, accounts) + if err != nil { + return []dummy.Account{}, err + } + balancesFilePath := path.Join(dir, "balances.json") + err = persistData(balancesFilePath, balances) + if err != nil { + return accounts, err + } + return accounts, nil +} + +func persistData(filePath string, data any) error { + b, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data for %s: %w", filePath, err) + } + + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create %q: %w", filePath, err) + } + defer file.Close() + + if _, err := file.Write(b); err != nil { + return fmt.Errorf("failed to write to %q: %w", filePath, err) + } + return nil +} diff --git a/pkg/testserver/matchers.go b/pkg/testserver/matchers.go new file mode 100644 index 00000000..f60ba187 --- /dev/null +++ b/pkg/testserver/matchers.go @@ -0,0 +1,251 @@ +package testserver + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "reflect" + "strings" + + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/payments/internal/models" + "github.com/google/go-cmp/cmp" + "github.com/invopop/jsonschema" + "github.com/nats-io/nats.go" + "github.com/onsi/gomega/types" + "github.com/xeipuuv/gojsonschema" +) + +type PayloadMatcher interface { + Match(actual interface{}) error +} + +type NoOpPayloadMatcher struct{} + +func (n NoOpPayloadMatcher) Match(interface{}) error { + return nil +} + +var _ PayloadMatcher = (*NoOpPayloadMatcher)(nil) + +type CallbackMatcher struct { + expected any + callback func(b []byte) error +} + +func (m CallbackMatcher) Match(payload interface{}) error { + marshaledPayload, err := rawJson(m.expected, payload, true) + if err != nil { + return fmt.Errorf("failed to process payload: %s", err) + } + return m.callback(marshaledPayload) +} + +func WithCallback(expected any, callback func(b []byte) error) CallbackMatcher { + return CallbackMatcher{ + expected: expected, + callback: callback, + } +} + +var _ PayloadMatcher = (*CallbackMatcher)(nil) + +type StructPayloadMatcher struct { + expected any + strict bool +} + +func (s StructPayloadMatcher) Match(payload interface{}) error { + marshaledPayload, err := rawJson(s.expected, payload, s.strict) + if err != nil { + return fmt.Errorf("failed to process payload: %s", err) + } + + unmarshalledPayload := reflect.New(reflect.TypeOf(s.expected)).Interface() + if err := json.Unmarshal(marshaledPayload, unmarshalledPayload); err != nil { + return fmt.Errorf("unable to unmarshal payload: %s", err) + } + + // unmarshalledPayload is actually a pointer + // as it is seen as "any" by the code, we use reflection to get the targeted valud + unmarshalledPayload = reflect.ValueOf(unmarshalledPayload).Elem().Interface() + + diff := cmp.Diff(unmarshalledPayload, s.expected, cmp.Comparer(func(v1 *big.Int, v2 *big.Int) bool { + return v1.String() == v2.String() + })) + if diff != "" { + return errors.New(diff) + } + + return nil +} + +func WithPayload(v any) StructPayloadMatcher { + return StructPayloadMatcher{ + expected: v, + strict: true, + } +} + +// WithPayloadSubset is able to match partial structs +func WithPayloadSubset(v any) StructPayloadMatcher { + return StructPayloadMatcher{ + expected: v, + strict: false, + } +} + +var _ PayloadMatcher = (*StructPayloadMatcher)(nil) + +// todo(libs): move in shared libs +type EventMatcher struct { + eventName string + matchers []PayloadMatcher + err error +} + +func (e *EventMatcher) Match(actual any) (success bool, err error) { + msg, ok := actual.(*nats.Msg) + if !ok { + return false, fmt.Errorf("expected type %t", actual) + } + + ev := publish.EventMessage{} + if err := json.Unmarshal(msg.Data, &ev); err != nil { + return false, fmt.Errorf("unable to unmarshal msg: %s", err) + } + + if ev.Type != e.eventName { + return false, nil + } + + for _, matcher := range e.matchers { + if e.err = matcher.Match(ev.Payload); e.err != nil { + return false, nil + } + } + + return true, nil +} + +func (e *EventMatcher) FailureMessage(_ any) (message string) { + return fmt.Sprintf("event does not match expectations: %s", e.err) +} + +func (e *EventMatcher) NegatedFailureMessage(_ any) (message string) { + return "event should not match" +} + +var _ types.GomegaMatcher = (*EventMatcher)(nil) + +func Event(eventName string, matchers ...PayloadMatcher) types.GomegaMatcher { + return &EventMatcher{ + matchers: matchers, + eventName: eventName, + } +} + +type TaskMatcher struct { + status models.TaskStatus + matchers []PayloadMatcher + err error +} + +func (t *TaskMatcher) Match(actual any) (success bool, err error) { + task, ok := actual.(models.Task) + if !ok { + return false, fmt.Errorf("unexpected type %t", actual) + } + + if task.Status != t.status { + t.err = fmt.Errorf("found task with status %s and error: %w", task.Status, task.Error) + return false, nil + } + + for _, matcher := range t.matchers { + if t.err = matcher.Match(task); t.err != nil { + return false, nil + } + } + return true, nil +} + +func (t *TaskMatcher) FailureMessage(_ any) (message string) { + return fmt.Sprintf("event does not match expectations: %s", t.err) +} + +func (t *TaskMatcher) NegatedFailureMessage(_ any) (message string) { + return "event should not match" +} + +var _ types.GomegaMatcher = (*TaskMatcher)(nil) + +func HaveTaskStatus(status models.TaskStatus, matchers ...PayloadMatcher) types.GomegaMatcher { + return &TaskMatcher{ + matchers: matchers, + status: status, + } +} + +type TaskErrorMatcher struct { + expected error +} + +func (m TaskErrorMatcher) Match(actual interface{}) error { + task, ok := actual.(models.Task) + if !ok { + return fmt.Errorf("unexpected type %t", actual) + } + + if task.Error == nil { + return fmt.Errorf("task with status %q did not contain an error", task.Status) + } + + // not guaranteed to be able to unwrap the error, so just string match + if !strings.Contains(task.Error.Error(), m.expected.Error()) { + return fmt.Errorf("found unexpected error: %w", task.Error) + } + return nil +} + +func WithError(expected error) TaskErrorMatcher { + return TaskErrorMatcher{ + expected: expected, + } +} + +var _ PayloadMatcher = (*TaskErrorMatcher)(nil) + +func rawJson(expected any, payload interface{}, strict bool) (b []byte, err error) { + rawSchema := jsonschema.Reflect(expected) + data, err := json.Marshal(rawSchema) + if err != nil { + return b, fmt.Errorf("unable to marshal schema: %s", err) + } + + schemaJSONLoader := gojsonschema.NewStringLoader(string(data)) + schema, err := gojsonschema.NewSchema(schemaJSONLoader) + if err != nil { + return b, fmt.Errorf("unable to load json schema: %s", err) + } + + dataJsonLoader := gojsonschema.NewRawLoader(payload) + + if strict { + validate, err := schema.Validate(dataJsonLoader) + if err != nil { + return b, fmt.Errorf("failed to validate: %w", err) + } + + if !validate.Valid() { + return b, fmt.Errorf("validation errors: %s", validate.Errors()) + } + } + + marshaledPayload, err := json.Marshal(payload) + if err != nil { + return b, fmt.Errorf("unable to marshal payload: %s", err) + } + return marshaledPayload, nil +} diff --git a/pkg/testserver/server.go b/pkg/testserver/server.go new file mode 100644 index 00000000..ed55df48 --- /dev/null +++ b/pkg/testserver/server.go @@ -0,0 +1,310 @@ +package testserver + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/nats-io/nats.go" + + _ "github.com/formancehq/payments/internal/connectors/plugins/public" + + "github.com/formancehq/go-libs/v2/otlp" + "github.com/formancehq/go-libs/v2/otlp/otlpmetrics" + "github.com/formancehq/go-libs/v2/publish" + "github.com/formancehq/go-libs/v2/temporal" + "github.com/google/uuid" + "github.com/uptrace/bun" + + "github.com/formancehq/go-libs/v2/bun/bunconnect" + "github.com/formancehq/go-libs/v2/httpclient" + "github.com/formancehq/go-libs/v2/httpserver" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/service" + "github.com/formancehq/payments/cmd" + "github.com/stretchr/testify/require" +) + +type T interface { + require.TestingT + Cleanup(func()) + Helper() + Logf(format string, args ...any) +} + +type OTLPConfig struct { + BaseConfig otlp.Config + Metrics *otlpmetrics.ModuleConfig +} + +type Configuration struct { + Stack string + PostgresConfiguration bunconnect.ConnectionOptions + TemporalNamespace string + TemporalAddress string + NatsURL string + ConfigEncryptionKey string + Output io.Writer + Debug bool + OTLPConfig *OTLPConfig +} + +type Logger interface { + Logf(fmt string, args ...any) +} + +type Server struct { + configuration Configuration + logger Logger + httpClient *Client + cancel func() + ctx context.Context + errorChan chan error + id string +} + +func (s *Server) Start() error { + rootCmd := cmd.NewRootCommand() + args := []string{ + "serve", + "--" + cmd.ListenFlag, ":0", + "--" + bunconnect.PostgresURIFlag, s.configuration.PostgresConfiguration.DatabaseSourceName, + "--" + bunconnect.PostgresMaxOpenConnsFlag, fmt.Sprint(s.configuration.PostgresConfiguration.MaxOpenConns), + "--" + bunconnect.PostgresConnMaxIdleTimeFlag, fmt.Sprint(s.configuration.PostgresConfiguration.ConnMaxIdleTime), + "--" + cmd.ConfigEncryptionKeyFlag, "dummyval", + "--" + temporal.TemporalAddressFlag, s.configuration.TemporalAddress, + "--" + temporal.TemporalNamespaceFlag, s.configuration.TemporalNamespace, + "--" + temporal.TemporalInitSearchAttributesFlag, fmt.Sprintf("stack=%s", s.configuration.Stack), + "--" + cmd.StackFlag, s.configuration.Stack, + } + if s.configuration.PostgresConfiguration.MaxIdleConns != 0 { + args = append( + args, + "--"+bunconnect.PostgresMaxIdleConnsFlag, + fmt.Sprint(s.configuration.PostgresConfiguration.MaxIdleConns), + ) + } + if s.configuration.PostgresConfiguration.MaxOpenConns != 0 { + args = append( + args, + "--"+bunconnect.PostgresMaxOpenConnsFlag, + fmt.Sprint(s.configuration.PostgresConfiguration.MaxOpenConns), + ) + } + if s.configuration.PostgresConfiguration.ConnMaxIdleTime != 0 { + args = append( + args, + "--"+bunconnect.PostgresConnMaxIdleTimeFlag, + fmt.Sprint(s.configuration.PostgresConfiguration.ConnMaxIdleTime), + ) + } + if s.configuration.NatsURL != "" { + args = append( + args, + "--"+publish.PublisherNatsEnabledFlag, + "--"+publish.PublisherNatsURLFlag, s.configuration.NatsURL, + "--"+publish.PublisherTopicMappingFlag, fmt.Sprintf("*:%s", s.id), + ) + } + if s.configuration.OTLPConfig != nil { + if s.configuration.OTLPConfig.Metrics != nil { + args = append( + args, + "--"+otlpmetrics.OtelMetricsExporterFlag, s.configuration.OTLPConfig.Metrics.Exporter, + ) + if s.configuration.OTLPConfig.Metrics.KeepInMemory { + args = append( + args, + "--"+otlpmetrics.OtelMetricsKeepInMemoryFlag, + ) + } + if s.configuration.OTLPConfig.Metrics.OTLPConfig != nil { + args = append( + args, + "--"+otlpmetrics.OtelMetricsExporterOTLPEndpointFlag, s.configuration.OTLPConfig.Metrics.OTLPConfig.Endpoint, + "--"+otlpmetrics.OtelMetricsExporterOTLPModeFlag, s.configuration.OTLPConfig.Metrics.OTLPConfig.Mode, + ) + if s.configuration.OTLPConfig.Metrics.OTLPConfig.Insecure { + args = append(args, "--"+otlpmetrics.OtelMetricsExporterOTLPInsecureFlag) + } + } + if s.configuration.OTLPConfig.Metrics.RuntimeMetrics { + args = append(args, "--"+otlpmetrics.OtelMetricsRuntimeFlag) + } + if s.configuration.OTLPConfig.Metrics.MinimumReadMemStatsInterval != 0 { + args = append( + args, + "--"+otlpmetrics.OtelMetricsRuntimeMinimumReadMemStatsIntervalFlag, + s.configuration.OTLPConfig.Metrics.MinimumReadMemStatsInterval.String(), + ) + } + if s.configuration.OTLPConfig.Metrics.PushInterval != 0 { + args = append( + args, + "--"+otlpmetrics.OtelMetricsExporterPushIntervalFlag, + s.configuration.OTLPConfig.Metrics.PushInterval.String(), + ) + } + if len(s.configuration.OTLPConfig.Metrics.ResourceAttributes) > 0 { + args = append( + args, + "--"+otlp.OtelResourceAttributesFlag, + strings.Join(s.configuration.OTLPConfig.Metrics.ResourceAttributes, ","), + ) + } + } + if s.configuration.OTLPConfig.BaseConfig.ServiceName != "" { + args = append(args, "--"+otlp.OtelServiceNameFlag, s.configuration.OTLPConfig.BaseConfig.ServiceName) + } + } + + if s.configuration.Debug { + args = append(args, "--"+service.DebugFlag) + } + + s.logger.Logf("Starting application with flags: %s", strings.Join(args, " ")) + rootCmd.SetArgs(args) + rootCmd.SilenceErrors = true + output := s.configuration.Output + if output == nil { + output = io.Discard + } + rootCmd.SetOut(output) + rootCmd.SetErr(output) + + ctx := logging.TestingContext() + ctx = service.ContextWithLifecycle(ctx) + ctx = httpserver.ContextWithServerInfo(ctx) + ctx, cancel := context.WithCancel(ctx) + + go func() { + s.errorChan <- rootCmd.ExecuteContext(ctx) + }() + + select { + case <-service.Ready(ctx): + case err := <-s.errorChan: + cancel() + if err != nil { + return err + } + + return errors.New("unexpected service stop") + } + + s.ctx, s.cancel = ctx, cancel + + var transport http.RoundTripper = &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + MaxConnsPerHost: 100, + } + if s.configuration.Debug { + transport = httpclient.NewDebugHTTPTransport(transport) + } + + httpClient, err := NewClient(httpserver.URL(s.ctx), transport) + if err != nil { + return err + } + s.httpClient = httpClient + return nil +} + +func (s *Server) Stop(ctx context.Context) error { + if s.cancel == nil { + return nil + } + s.cancel() + s.cancel = nil + + // Wait app to be marked as stopped + select { + case <-service.Stopped(s.ctx): + case <-ctx.Done(): + return errors.New("service should have been stopped") + } + + // Ensure the app has been properly shutdown + select { + case err := <-s.errorChan: + return err + case <-ctx.Done(): + return errors.New("service should have been stopped without error") + } +} + +func (s *Server) Client() *Client { + return s.httpClient +} + +func (s *Server) Restart(ctx context.Context) error { + if err := s.Stop(ctx); err != nil { + return err + } + if err := s.Start(); err != nil { + return err + } + + return nil +} + +func (s *Server) Database() (*bun.DB, error) { + db, err := bunconnect.OpenSQLDB(s.ctx, s.configuration.PostgresConfiguration) + if err != nil { + return nil, err + } + + return db, nil +} + +func (s *Server) Subscribe() (*nats.Subscription, chan *nats.Msg, error) { + if s.configuration.NatsURL == "" { + return nil, nil, errors.New("NATS URL must be set") + } + + ret := make(chan *nats.Msg) + conn, err := nats.Connect(s.configuration.NatsURL) + if err != nil { + return nil, nil, err + } + + subscription, err := conn.Subscribe(s.id, func(msg *nats.Msg) { + ret <- msg + }) + if err != nil { + return nil, nil, err + } + + return subscription, ret, nil +} + +func (s *Server) URL() string { + return httpserver.URL(s.ctx) +} + +func New(t T, configuration Configuration) *Server { + t.Helper() + + srv := &Server{ + logger: t, + configuration: configuration, + id: uuid.NewString()[:8], + errorChan: make(chan error, 1), + } + t.Logf("Start testing server") + require.NoError(t, srv.Start()) + t.Cleanup(func() { + t.Logf("Stop testing server") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + require.NoError(t, srv.Stop(ctx)) + }) + + return srv +} diff --git a/test/e2e/api_accounts_test.go b/test/e2e/api_accounts_test.go new file mode 100644 index 00000000..4ebee7ae --- /dev/null +++ b/test/e2e/api_accounts_test.go @@ -0,0 +1,130 @@ +//go:build it + +package test_suite + +import ( + "encoding/json" + "time" + + "github.com/formancehq/go-libs/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/testing/utils" + v3 "github.com/formancehq/payments/internal/api/v3" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + evts "github.com/formancehq/payments/pkg/events" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + + . "github.com/formancehq/payments/pkg/testserver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Context("Payments API Accounts", func() { + var ( + db = UseTemplatedDatabase() + ctx = logging.TestingContext() + + createRequest v3.CreateAccountRequest + + app *utils.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, + } + }) + + createdAt, _ := time.Parse("2006-Jan-02", "2024-Nov-29") + createRequest = v3.CreateAccountRequest{ + Reference: "ref", + AccountName: "foo", + CreatedAt: createdAt, + DefaultAsset: "USD", + Type: string(models.ACCOUNT_TYPE_INTERNAL), + Metadata: map[string]string{"key": "val"}, + } + + When("creating a new account", func() { + var ( + connectorRes struct{ Data string } + createResponse struct{ Data models.Account } + getResponse struct{ Data models.Account } + e chan *nats.Msg + err error + ) + + DescribeTable("should be successful", + func(ver int) { + e = Subscribe(GinkgoT(), app.GetValue()) + connectorConf := newConnectorConfigurationFn()(uuid.New()) + err = ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + createRequest.ConnectorID = connectorRes.Data + err = CreateAccount(ctx, app.GetValue(), ver, createRequest, &createResponse) + Expect(err).To(BeNil()) + + err = GetAccount(ctx, app.GetValue(), ver, createResponse.Data.ID.String(), &getResponse) + Expect(err).To(BeNil()) + Expect(getResponse.Data).To(Equal(createResponse.Data)) + + Eventually(e).Should(Receive(Event(evts.EventTypeSavedAccounts))) + }, + Entry("with v2", 2), + Entry("with v3", 3), + ) + }) + + When("fetching account balances", func() { + var ( + connectorRes struct{ Data string } + res struct { + Cursor bunpaginate.Cursor[models.Balance] + } + e chan *nats.Msg + ) + + DescribeTable("should be successful", + func(ver int) { + e = Subscribe(GinkgoT(), app.GetValue()) + connectorConf := newConnectorConfigurationFn()(uuid.New()) + _, err := GeneratePSPData(connectorConf.Directory) + Expect(err).To(BeNil()) + ver = 3 + + err = ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + Eventually(e).WithTimeout(2 * time.Second).Should(Receive(Event(evts.EventTypeSavedAccounts))) + + var msg events.BalanceMessagePayload + // poll more frequently to filter out ACCOUNT_SAVED messages that we don't care about quicker + Eventually(e).WithPolling(5 * time.Millisecond).WithTimeout(2 * time.Second).Should(Receive(Event(evts.EventTypeSavedBalances, WithCallback( + msg, + func(b []byte) error { + return json.Unmarshal(b, &msg) + }, + )))) + + err = GetAccountBalances(ctx, app.GetValue(), ver, msg.AccountID, &res) + Expect(err).To(BeNil()) + Expect(res.Cursor.Data).To(HaveLen(1)) + + balance := res.Cursor.Data[0] + Expect(balance.AccountID.String()).To(Equal(msg.AccountID)) + Expect(balance.Balance).To(Equal(msg.Balance)) + Expect(balance.Asset).To(Equal(msg.Asset)) + Expect(balance.CreatedAt).To(Equal(msg.CreatedAt.UTC().Truncate(time.Second))) + }, + Entry("with v2", 2), + Entry("with v3", 3), + ) + }) +}) diff --git a/test/e2e/api_bank_accounts_test.go b/test/e2e/api_bank_accounts_test.go new file mode 100644 index 00000000..aad3b0e8 --- /dev/null +++ b/test/e2e/api_bank_accounts_test.go @@ -0,0 +1,278 @@ +//go:build it + +package test_suite + +import ( + "fmt" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/testing/utils" + v2 "github.com/formancehq/payments/internal/api/v2" + v3 "github.com/formancehq/payments/internal/api/v3" + "github.com/formancehq/payments/internal/events" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + + evts "github.com/formancehq/payments/pkg/events" + . "github.com/formancehq/payments/pkg/testserver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Context("Payments API Bank Accounts", func() { + var ( + db = UseTemplatedDatabase() + ctx = logging.TestingContext() + + accountNumber = "123456789" + iban = "DE89370400440532013000" + createRequest v3.BankAccountsCreateRequest + v2createRequest v2.BankAccountsCreateRequest + + app *utils.Deferred[*Server] + ) + + app = NewTestServer(func() Configuration { + return Configuration{ + Stack: stack, + NatsURL: natsServer.GetValue().ClientURL(), + PostgresConfiguration: db.GetValue().ConnectionOptions(), + TemporalNamespace: temporalServer.GetValue().DefaultNamespace(), + TemporalAddress: temporalServer.GetValue().Address(), + Output: GinkgoWriter, + } + }) + + createRequest = v3.BankAccountsCreateRequest{ + Name: "foo", + AccountNumber: &accountNumber, + IBAN: &iban, + } + v2createRequest = v2.BankAccountsCreateRequest{ + Name: "foo", + AccountNumber: &accountNumber, + IBAN: &iban, + } + + When("creating a new bank account with v3", func() { + var ( + ver int + createResponse struct{ Data string } + getResponse struct{ Data models.BankAccount } + err error + ) + JustBeforeEach(func() { + ver = 3 + err = CreateBankAccount(ctx, app.GetValue(), ver, createRequest, &createResponse) + }) + It("should be ok", func() { + Expect(err).To(BeNil()) + id, err := uuid.Parse(createResponse.Data) + Expect(err).To(BeNil()) + err = GetBankAccount(ctx, app.GetValue(), ver, id.String(), &getResponse) + Expect(err).To(BeNil()) + Expect(getResponse.Data.ID.String()).To(Equal(id.String())) + }) + }) + + When("creating a new bank account with v2", func() { + var ( + ver int + createResponse struct{ Data v2.BankAccountResponse } + getResponse struct{ Data models.BankAccount } + err error + ) + JustBeforeEach(func() { + ver = 2 + err = CreateBankAccount(ctx, app.GetValue(), ver, v2createRequest, &createResponse) + }) + It("should be ok", func() { + Expect(err).To(BeNil()) + id, err := uuid.Parse(createResponse.Data.ID) + Expect(err).To(BeNil()) + err = GetBankAccount(ctx, app.GetValue(), ver, id.String(), &getResponse) + Expect(err).To(BeNil()) + Expect(getResponse.Data.ID.String()).To(Equal(id.String())) + }) + }) + + When("forwarding a bank account to a connector with v3", func() { + var ( + ver int + createRes struct{ Data string } + forwardReq v3.BankAccountsForwardToConnectorRequest + connectorRes struct{ Data string } + res struct{ Data models.Task } + err error + e chan *nats.Msg + id uuid.UUID + ) + JustBeforeEach(func() { + ver = 3 + e = Subscribe(GinkgoT(), app.GetValue()) + err = CreateBankAccount(ctx, app.GetValue(), ver, createRequest, &createRes) + Expect(err).To(BeNil()) + id, err = uuid.Parse(createRes.Data) + Expect(err).To(BeNil()) + + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + }) + + It("should fail when connector ID is invalid", func() { + forwardReq = v3.BankAccountsForwardToConnectorRequest{ConnectorID: "invalid"} + err = ForwardBankAccount(ctx, app.GetValue(), ver, id.String(), &forwardReq, &res) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("400")) + }) + It("should be ok when connector is installed", func() { + forwardReq = v3.BankAccountsForwardToConnectorRequest{ConnectorID: connectorRes.Data} + err = ForwardBankAccount(ctx, app.GetValue(), ver, id.String(), &forwardReq, &res) + Expect(err).To(BeNil()) + Expect(res.Data.ID.Reference).To(ContainSubstring(id.String())) + Expect(res.Data.ID.Reference).To(ContainSubstring(connectorRes.Data)) + + connectorID, err := models.ConnectorIDFromString(connectorRes.Data) + Expect(err).To(BeNil()) + + var getResponse struct{ Data models.BankAccount } + err = GetBankAccount(ctx, app.GetValue(), ver, id.String(), &getResponse) + Expect(err).To(BeNil()) + + accountNumber := *createRequest.AccountNumber + iban := *createRequest.IBAN + + accountID := models.AccountID{ + Reference: fmt.Sprintf("dummypay-%s", id.String()), + ConnectorID: connectorID, + } + + Eventually(e).Should(Receive(Event(evts.EventTypeSavedBankAccount, WithPayload( + events.BankAccountMessagePayload{ + ID: id.String(), + Name: createRequest.Name, + AccountNumber: fmt.Sprintf("%s****%s", accountNumber[0:2], accountNumber[len(accountNumber)-3:len(accountNumber)]), + IBAN: fmt.Sprintf("%s**************%s", iban[0:4], iban[len(iban)-4:len(iban)]), + CreatedAt: getResponse.Data.CreatedAt, + RelatedAccounts: []events.BankAccountRelatedAccountsPayload{ + { + AccountID: accountID.String(), + CreatedAt: getResponse.Data.CreatedAt, + ConnectorID: connectorID.String(), + Provider: "dummypay", + }, + }, + }, + )))) + }) + }) + + When("forwarding a bank account to a connector with v2", func() { + var ( + ver int + createRes struct{ Data v2.BankAccountResponse } + forwardReq v2.BankAccountsForwardToConnectorRequest + connectorRes struct{ Data string } + res struct{ Data v2.BankAccountResponse } + e chan *nats.Msg + err error + id uuid.UUID + ) + JustBeforeEach(func() { + ver = 2 + e = Subscribe(GinkgoT(), app.GetValue()) + err = CreateBankAccount(ctx, app.GetValue(), ver, createRequest, &createRes) + Expect(err).To(BeNil()) + id, err = uuid.Parse(createRes.Data.ID) + Expect(err).To(BeNil()) + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + }) + It("should fail when connector ID is invalid", func() { + forwardReq = v2.BankAccountsForwardToConnectorRequest{ConnectorID: "invalid"} + err = ForwardBankAccount(ctx, app.GetValue(), ver, id.String(), &forwardReq, &res) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("400")) + }) + It("should be ok", func() { + forwardReq = v2.BankAccountsForwardToConnectorRequest{ConnectorID: connectorRes.Data} + err = ForwardBankAccount(ctx, app.GetValue(), ver, id.String(), &forwardReq, &res) + Expect(err).To(BeNil()) + Expect(res.Data.RelatedAccounts).To(HaveLen(1)) + Expect(res.Data.RelatedAccounts[0].ConnectorID).To(Equal(connectorRes.Data)) + + Eventually(e).Should(Receive(Event(evts.EventTypeSavedBankAccount))) + }) + }) + + When("updating bank account metadata with v3", func() { + var ( + ver int + createRes struct{ Data string } + res struct{ Data models.BankAccount } + req v3.BankAccountsUpdateMetadataRequest + err error + id uuid.UUID + ) + JustBeforeEach(func() { + ver = 3 + err = CreateBankAccount(ctx, app.GetValue(), ver, createRequest, &createRes) + Expect(err).To(BeNil()) + id, err = uuid.Parse(createRes.Data) + Expect(err).To(BeNil()) + }) + + It("should fail when metadata is invalid", func() { + req = v3.BankAccountsUpdateMetadataRequest{} + err = ForwardBankAccount(ctx, app.GetValue(), ver, id.String(), &req, &res) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("400")) + }) + It("should be ok when metadata is valid", func() { + req = v3.BankAccountsUpdateMetadataRequest{Metadata: map[string]string{"key": "val"}} + err = UpdateBankAccountMetadata(ctx, app.GetValue(), ver, id.String(), &req, nil) + Expect(err).To(BeNil()) + err = GetBankAccount(ctx, app.GetValue(), ver, id.String(), &res) + Expect(err).To(BeNil()) + Expect(res.Data.ID.String()).To(Equal(id.String())) + Expect(res.Data.Metadata).To(Equal(req.Metadata)) + }) + }) + + When("updating bank account metadata with v2", func() { + var ( + ver int + createRes struct{ Data v2.BankAccountResponse } + res struct{ Data models.BankAccount } + req v2.BankAccountsUpdateMetadataRequest + err error + id uuid.UUID + ) + JustBeforeEach(func() { + ver = 2 + err = CreateBankAccount(ctx, app.GetValue(), ver, createRequest, &createRes) + Expect(err).To(BeNil()) + id, err = uuid.Parse(createRes.Data.ID) + Expect(err).To(BeNil()) + }) + + It("should fail when metadata is invalid", func() { + req = v2.BankAccountsUpdateMetadataRequest{} + err = ForwardBankAccount(ctx, app.GetValue(), ver, id.String(), &req, &res) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("400")) + }) + It("should be ok when metadata is valid", func() { + req = v2.BankAccountsUpdateMetadataRequest{Metadata: map[string]string{"key": "val"}} + err = UpdateBankAccountMetadata(ctx, app.GetValue(), ver, id.String(), &req, nil) + Expect(err).To(BeNil()) + err = GetBankAccount(ctx, app.GetValue(), ver, id.String(), &res) + Expect(err).To(BeNil()) + Expect(res.Data.ID.String()).To(Equal(id.String())) + Expect(res.Data.Metadata).To(Equal(req.Metadata)) + }) + }) +}) diff --git a/test/e2e/api_connectors_test.go b/test/e2e/api_connectors_test.go new file mode 100644 index 00000000..fe7c0ef7 --- /dev/null +++ b/test/e2e/api_connectors_test.go @@ -0,0 +1,215 @@ +//go:build it + +package test_suite + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/formancehq/go-libs/bun/bunpaginate" + "github.com/formancehq/go-libs/v2/logging" + "github.com/google/uuid" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/client" + + "github.com/formancehq/payments/internal/models" + . "github.com/formancehq/payments/pkg/testserver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Context("Payments API Connectors", func() { + var ( + db = UseTemplatedDatabase() + ctx = logging.TestingContext() + ) + + app := NewTestServer(func() Configuration { + return Configuration{ + Stack: stack, + PostgresConfiguration: db.GetValue().ConnectionOptions(), + TemporalNamespace: temporalServer.GetValue().DefaultNamespace(), + TemporalAddress: temporalServer.GetValue().Address(), + Output: GinkgoWriter, + } + }) + + When("installing a connector", func() { + var ( + connectorRes struct{ Data string } + id uuid.UUID + workflowID string + ) + JustBeforeEach(func() { + id = uuid.New() + }) + + It("should be ok with v3", func() { + ver := 3 + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + cl := temporalServer.GetValue().DefaultClient() + req := &workflowservice.ListOpenWorkflowExecutionsRequest{Namespace: temporalServer.GetValue().DefaultNamespace()} + workflowRes, err := cl.ListOpenWorkflow(ctx, req) + for _, info := range workflowRes.Executions { + if strings.HasPrefix(info.Execution.WorkflowId, "run-tasks-") { + workflowID = info.Execution.WorkflowId + break + } + } + Expect(workflowID).To(Equal(fmt.Sprintf("run-tasks-%s-%s", stack, connectorRes.Data))) + + getRes := struct{ Data ConnectorConf }{} + err = ConnectorConfig(ctx, app.GetValue(), ver, connectorRes.Data, &getRes) + Expect(err).To(BeNil()) + Expect(getRes.Data).To(Equal(connectorConf)) + }) + + It("should be ok with v2", func() { + ver := 2 + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + getRes := struct{ Data ConnectorConf }{} + err = ConnectorConfig(ctx, app.GetValue(), ver, connectorRes.Data, &getRes) + Expect(err).To(BeNil()) + Expect(getRes.Data).To(Equal(connectorConf)) + }) + }) + + When("uninstalling a connector", func() { + var ( + connectorRes struct{ Data string } + id uuid.UUID + ) + JustBeforeEach(func() { + id = uuid.New() + }) + + It("should be ok with v3", func() { + ver := 3 + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + delRes := struct{ Data string }{} + err = ConnectorUninstall(ctx, app.GetValue(), ver, connectorRes.Data, &delRes) + Expect(err).To(BeNil()) + Expect(delRes.Data).To(Equal(connectorRes.Data)) + blockTillWorkflowComplete(ctx, connectorRes.Data, "uninstall") + }) + + It("should be ok with v2", func() { + ver := 2 + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + err = ConnectorUninstall(ctx, app.GetValue(), ver, connectorRes.Data, nil) + Expect(err).To(BeNil()) + blockTillWorkflowComplete(ctx, connectorRes.Data, "uninstall") + }) + }) + + When("searching for schedules for a connector", func() { + var ( + connectorRes struct{ Data string } + id uuid.UUID + ver int + expectedTypes = map[string]struct{}{ + "FetchAccounts": {}, + "FetchExternalAccounts": {}, + "FetchPayments": {}, + "FetchBalances": {}, + } + ) + JustBeforeEach(func() { + ver = 3 + id = uuid.New() + + connectorConf := newConnectorConfigurationFn()(id) + err := ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + workflowID := blockTillWorkflowComplete(ctx, connectorRes.Data, "run-tasks-") + Expect(workflowID).To(Equal(fmt.Sprintf("run-tasks-%s-%s", stack, connectorRes.Data))) + }) + + It("should be ok with v3", func(ctx SpecContext) { + schCl := temporalServer.GetValue().DefaultClient().ScheduleClient() + list, err := schCl.List(ctx, client.ScheduleListOptions{PageSize: 1}) + Expect(err).To(BeNil()) + Expect(list.HasNext()).To(BeTrue()) + + for list.HasNext() { + schedule, err := list.Next() + if !strings.Contains(schedule.ID, connectorRes.Data) { + continue + } + Expect(err).To(BeNil()) + _, ok := expectedTypes[schedule.WorkflowType.Name] + Expect(ok).To(BeTrue()) + } + + res := struct { + Cursor bunpaginate.Cursor[models.Schedule] + }{} + err = ConnectorSchedules(ctx, app.GetValue(), ver, connectorRes.Data, &res) + Expect(err).To(BeNil()) + Expect(len(res.Cursor.Data) > 0).To(BeTrue()) + for _, schedule := range res.Cursor.Data { + Expect(schedule.ConnectorID.String()).To(Equal(connectorRes.Data)) + Expect(schedule.ConnectorID.Provider).To(Equal("dummypay")) + } + }) + }) +}) + +func blockTillWorkflowComplete(ctx context.Context, connectorID string, searchKeyword string) string { + var ( + workflowID string + runID string + ) + + cl := temporalServer.GetValue().DefaultClient() + req := &workflowservice.ListOpenWorkflowExecutionsRequest{Namespace: temporalServer.GetValue().DefaultNamespace()} + workflowRes, err := cl.ListOpenWorkflow(ctx, req) + for _, info := range workflowRes.Executions { + if strings.Contains(info.Execution.WorkflowId, connectorID) && strings.HasPrefix(info.Execution.WorkflowId, searchKeyword) { + workflowID = info.Execution.WorkflowId + runID = info.Execution.RunId + break + } + } + + // if we couldn't find it either it's already done or it wasn't scheduled + if workflowID == "" { + return "" + } + workflowRun := cl.GetWorkflow(ctx, workflowID, runID) + err = workflowRun.Get(ctx, nil) // blocks to ensure workflow is finished + Expect(err).To(BeNil()) + return workflowID +} + +func newConnectorConfigurationFn() func(id uuid.UUID) ConnectorConf { + return func(id uuid.UUID) ConnectorConf { + dir, err := os.MkdirTemp("", "dummypay") + Expect(err).To(BeNil()) + GinkgoT().Cleanup(func() { + os.RemoveAll(dir) + }) + + return ConnectorConf{ + Name: fmt.Sprintf("connector-%s", id.String()), + PollingPeriod: "30s", + PageSize: 30, + Directory: dir, + } + } +} diff --git a/test/e2e/api_payment_initiations_test.go b/test/e2e/api_payment_initiations_test.go new file mode 100644 index 00000000..c54e75c3 --- /dev/null +++ b/test/e2e/api_payment_initiations_test.go @@ -0,0 +1,351 @@ +package test_suite + +import ( + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/testing/utils" + v3 "github.com/formancehq/payments/internal/api/v3" + "github.com/formancehq/payments/internal/connectors/engine/workflow" + "github.com/formancehq/payments/internal/models" + evts "github.com/formancehq/payments/pkg/events" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + + . "github.com/formancehq/payments/pkg/testserver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Context("Payments API Payment Initiation", func() { + var ( + db = UseTemplatedDatabase() + ctx = logging.TestingContext() + + app *utils.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, + } + }) + + createdAt, _ := time.Parse("2006-Jan-02", "2024-Nov-29") + + When("initiating a new transfer with v3", func() { + var ( + ver = 3 + e chan *nats.Msg + err error + + debtorID string + creditorID string + payReq v3.PaymentInitiationsCreateRequest + + connectorRes struct{ Data string } + initRes struct { + Data v3.PaymentInitiationsCreateResponse + } + approveRes struct{ Data models.Task } + ) + + JustBeforeEach(func() { + e = Subscribe(GinkgoT(), app.GetValue()) + connectorConf := newConnectorConfigurationFn()(uuid.New()) + err = ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + debtorID, creditorID = setupDebtorAndCreditorAccounts(ctx, app.GetValue(), e, ver, connectorRes.Data, createdAt) + payReq = v3.PaymentInitiationsCreateRequest{ + Reference: uuid.New().String(), + ScheduledAt: time.Now(), + ConnectorID: connectorRes.Data, + Description: "some description", + Type: models.PAYMENT_INITIATION_TYPE_TRANSFER.String(), + Amount: big.NewInt(3200), + Asset: "EUR", + SourceAccountID: &debtorID, + DestinationAccountID: &creditorID, + Metadata: map[string]string{"key": "val"}, + } + + err := CreatePaymentInitiation(ctx, app.GetValue(), ver, payReq, &initRes) + Expect(err).To(BeNil()) + Expect(initRes.Data.TaskID).To(Equal("")) // task nil when not sending to PSP + }) + + It("can be processed", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + err = ApprovePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &approveRes) + Expect(err).To(BeNil()) + Expect(approveRes.Data).NotTo(BeNil()) + Expect(approveRes.Data.ID.Reference).To(ContainSubstring("create-transfer")) + + var msg = struct { + ConnectorID string `json:"connectorId"` + SourceAccountID string `json:"sourceAccountId,omitempty"` + DestinationAccountID string `json:"destinationAccountId,omitempty"` + }{ + ConnectorID: connectorRes.Data, + SourceAccountID: debtorID, + DestinationAccountID: creditorID, + } + Eventually(e).WithTimeout(2 * time.Second).Should(Receive(Event(evts.EventTypeSavedPayments, WithPayloadSubset(msg)))) + taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue()) + Eventually(taskPoller(approveRes.Data.ID.String())).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED)) + + var paymentRes struct { + Data models.PaymentInitiationExpanded + } + err = GetPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &paymentRes) + Expect(err).To(BeNil()) + Expect(paymentRes.Data.Status).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED)) + }) + + It("can be rejected", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + err = RejectPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String()) + Expect(err).To(BeNil()) + + var paymentRes struct { + Data models.PaymentInitiationExpanded + } + err = GetPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &paymentRes) + Expect(err).To(BeNil()) + Expect(paymentRes.Data.Status).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED)) + }) + + It("cannot be reversed if the payment is unprocessed", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + req := v3.PaymentInitiationsReverseRequest{ + Reference: uuid.New().String(), + Description: payReq.Description, + Amount: payReq.Amount, + Asset: payReq.Asset, + Metadata: map[string]string{"reversal": "data"}, + } + + var res struct { + Data v3.PaymentInitiationsReverseResponse + } + err = ReversePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), req, &res) + Expect(err).To(BeNil()) + Expect(res.Data.TaskID).NotTo(BeNil()) + taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue()) + Eventually(taskPoller(res.Data.TaskID)).Should(HaveTaskStatus(models.TASK_STATUS_FAILED, WithError(workflow.ErrPaymentInitiationNotProcessed))) + }) + + It("can be reversed", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + err = ApprovePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &approveRes) + Expect(err).To(BeNil()) + + var msg = struct { + ConnectorID string `json:"connectorId"` + SourceAccountID string `json:"sourceAccountId,omitempty"` + DestinationAccountID string `json:"destinationAccountId,omitempty"` + }{ + ConnectorID: connectorRes.Data, + SourceAccountID: debtorID, + DestinationAccountID: creditorID, + } + Eventually(e).WithTimeout(2 * time.Second).Should(Receive(Event(evts.EventTypeSavedPayments, WithPayloadSubset(msg)))) + taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue()) + Eventually(taskPoller(approveRes.Data.ID.String())).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED)) + + req := v3.PaymentInitiationsReverseRequest{ + Reference: uuid.New().String(), + Description: payReq.Description, + Amount: payReq.Amount, + Asset: payReq.Asset, + Metadata: map[string]string{"reversal": "data"}, + } + + var res struct { + Data v3.PaymentInitiationsReverseResponse + } + err = ReversePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), req, &res) + Expect(err).To(BeNil()) + Expect(res.Data.TaskID).NotTo(BeNil()) + blockTillWorkflowComplete(ctx, connectorRes.Data, "reverse-transfer") + Eventually(taskPoller(res.Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED)) + + var paymentRes struct { + Data models.PaymentInitiationExpanded + } + err = GetPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &paymentRes) + Expect(err).To(BeNil()) + Expect(paymentRes.Data.Status).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED)) + }) + }) + + When("initiating a new payout with v3", func() { + var ( + ver = 3 + e chan *nats.Msg + err error + + debtorID string + creditorID string + payReq v3.PaymentInitiationsCreateRequest + + connectorRes struct{ Data string } + initRes struct { + Data v3.PaymentInitiationsCreateResponse + } + approveRes struct{ Data models.Task } + ) + + JustBeforeEach(func() { + e = Subscribe(GinkgoT(), app.GetValue()) + connectorConf := newConnectorConfigurationFn()(uuid.New()) + err = ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + + debtorID, creditorID = setupDebtorAndCreditorAccounts(ctx, app.GetValue(), e, ver, connectorRes.Data, createdAt) + payReq = v3.PaymentInitiationsCreateRequest{ + Reference: uuid.New().String(), + ScheduledAt: time.Now(), + ConnectorID: connectorRes.Data, + Description: "payout description", + Type: models.PAYMENT_INITIATION_TYPE_PAYOUT.String(), + Amount: big.NewInt(2233), + Asset: "EUR", + SourceAccountID: &debtorID, + DestinationAccountID: &creditorID, + Metadata: map[string]string{"pay": "out"}, + } + + err := CreatePaymentInitiation(ctx, app.GetValue(), ver, payReq, &initRes) + Expect(err).To(BeNil()) + Expect(initRes.Data.TaskID).To(Equal("")) // task nil when not sending to PSP + }) + + It("can be processed", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + err = ApprovePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &approveRes) + Expect(err).To(BeNil()) + Expect(approveRes.Data).NotTo(BeNil()) + Expect(approveRes.Data.ID.Reference).To(ContainSubstring("create-payout")) + + var msg = struct { + ConnectorID string `json:"connectorId"` + SourceAccountID string `json:"sourceAccountId,omitempty"` + DestinationAccountID string `json:"destinationAccountId,omitempty"` + }{ + ConnectorID: connectorRes.Data, + SourceAccountID: debtorID, + DestinationAccountID: creditorID, + } + Eventually(e).WithTimeout(2 * time.Second).Should(Receive(Event(evts.EventTypeSavedPayments, WithPayloadSubset(msg)))) + taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue()) + Eventually(taskPoller(approveRes.Data.ID.String())).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED)) + + var paymentRes struct { + Data models.PaymentInitiationExpanded + } + err = GetPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &paymentRes) + Expect(err).To(BeNil()) + Expect(paymentRes.Data.Status).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_PROCESSED)) + }) + + It("can be rejected", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + err = RejectPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String()) + Expect(err).To(BeNil()) + + var paymentRes struct { + Data models.PaymentInitiationExpanded + } + err = GetPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &paymentRes) + Expect(err).To(BeNil()) + Expect(paymentRes.Data.Status).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REJECTED)) + }) + + It("cannot be reversed if the payment is unprocessed", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + req := v3.PaymentInitiationsReverseRequest{ + Reference: uuid.New().String(), + Description: payReq.Description, + Amount: payReq.Amount, + Asset: payReq.Asset, + Metadata: map[string]string{"reversal": "data"}, + } + + var res struct { + Data v3.PaymentInitiationsReverseResponse + } + err = ReversePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), req, &res) + Expect(err).To(BeNil()) + Expect(res.Data.TaskID).NotTo(BeNil()) + taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue()) + Eventually(taskPoller(res.Data.TaskID)).Should(HaveTaskStatus(models.TASK_STATUS_FAILED, WithError(workflow.ErrPaymentInitiationNotProcessed))) + }) + + It("can be reversed", func() { + paymentID, err := models.PaymentInitiationIDFromString(initRes.Data.PaymentInitiationID) + Expect(err).To(BeNil()) + + err = ApprovePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &approveRes) + Expect(err).To(BeNil()) + + var msg = struct { + ConnectorID string `json:"connectorId"` + SourceAccountID string `json:"sourceAccountId,omitempty"` + DestinationAccountID string `json:"destinationAccountId,omitempty"` + }{ + ConnectorID: connectorRes.Data, + SourceAccountID: debtorID, + DestinationAccountID: creditorID, + } + Eventually(e).WithTimeout(2 * time.Second).Should(Receive(Event(evts.EventTypeSavedPayments, WithPayloadSubset(msg)))) + taskPoller := TaskPoller(ctx, GinkgoT(), app.GetValue()) + Eventually(taskPoller(approveRes.Data.ID.String())).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED)) + + req := v3.PaymentInitiationsReverseRequest{ + Reference: uuid.New().String(), + Description: payReq.Description, + Amount: payReq.Amount, + Asset: payReq.Asset, + Metadata: map[string]string{"reversal": "data"}, + } + + var res struct { + Data v3.PaymentInitiationsReverseResponse + } + err = ReversePaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), req, &res) + Expect(err).To(BeNil()) + Expect(res.Data.TaskID).NotTo(BeNil()) + blockTillWorkflowComplete(ctx, connectorRes.Data, "reverse-payout") + Eventually(taskPoller(res.Data.TaskID)).WithTimeout(2 * time.Second).Should(HaveTaskStatus(models.TASK_STATUS_SUCCEEDED)) + + var paymentRes struct { + Data models.PaymentInitiationExpanded + } + err = GetPaymentInitiation(ctx, app.GetValue(), ver, paymentID.String(), &paymentRes) + Expect(err).To(BeNil()) + Expect(paymentRes.Data.Status).To(Equal(models.PAYMENT_INITIATION_ADJUSTMENT_STATUS_REVERSED)) + }) + }) +}) diff --git a/test/e2e/api_payments_test.go b/test/e2e/api_payments_test.go new file mode 100644 index 00000000..944c030d --- /dev/null +++ b/test/e2e/api_payments_test.go @@ -0,0 +1,200 @@ +package test_suite + +import ( + "context" + "math/big" + "time" + + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/testing/utils" + v2 "github.com/formancehq/payments/internal/api/v2" + v3 "github.com/formancehq/payments/internal/api/v3" + "github.com/formancehq/payments/internal/models" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + + evts "github.com/formancehq/payments/pkg/events" + "github.com/formancehq/payments/pkg/testserver" + . "github.com/formancehq/payments/pkg/testserver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Context("Payments API Payments", func() { + var ( + db = UseTemplatedDatabase() + ctx = logging.TestingContext() + + app *utils.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, + } + }) + + When("creating a new payment with v3", func() { + var ( + connectorRes struct{ Data string } + createResponse struct{ Data models.Payment } + getResponse struct{ Data models.Payment } + e chan *nats.Msg + ver int + createdAt time.Time + initialAmount *big.Int + asset string + err error + ) + JustBeforeEach(func() { + ver = 3 + createdAt = time.Now() + initialAmount = big.NewInt(1340) + asset = "USD" + e = Subscribe(GinkgoT(), app.GetValue()) + + connectorConf := newConnectorConfigurationFn()(uuid.New()) + err = ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + }) + + It("should be ok", func() { + adj := []v3.CreatePaymentsAdjustmentsRequest{ + { + Reference: "ref_adjustment", + CreatedAt: createdAt, + Amount: big.NewInt(55), + Asset: &asset, + Status: models.PAYMENT_STATUS_REFUNDED.String(), + }, + } + + debtorID, creditorID := setupDebtorAndCreditorAccounts(ctx, app.GetValue(), e, ver, connectorRes.Data, createdAt) + createRequest := v3.CreatePaymentRequest{ + Reference: "ref", + ConnectorID: connectorRes.Data, + CreatedAt: createdAt, + InitialAmount: initialAmount, + Amount: initialAmount, + Asset: asset, + Type: models.PAYMENT_TYPE_PAYIN.String(), + SourceAccountID: &debtorID, + DestinationAccountID: &creditorID, + Scheme: models.PAYMENT_SCHEME_CARD_AMEX.String(), + Adjustments: adj, + Metadata: map[string]string{"key": "val"}, + } + + err = CreatePayment(ctx, app.GetValue(), ver, createRequest, &createResponse) + Expect(err).To(BeNil()) + + Eventually(e).Should(Receive(Event(evts.EventTypeSavedPayments))) + + err = GetPayment(ctx, app.GetValue(), ver, createResponse.Data.ID.String(), &getResponse) + Expect(err).To(BeNil()) + Expect(getResponse.Data.Amount).To(Equal(big.NewInt(0).Sub(createRequest.Amount, adj[0].Amount))) + Expect(getResponse.Data.Status.String()).To(Equal(adj[0].Status)) + Expect(getResponse.Data.Adjustments).To(HaveLen(1)) + }) + }) + + When("creating a new payment with v2", func() { + var ( + connectorRes struct{ Data string } + createResponse struct{ Data v2.PaymentResponse } + getResponse struct{ Data v2.PaymentResponse } + e chan *nats.Msg + ver int + createdAt time.Time + initialAmount *big.Int + asset string + err error + ) + JustBeforeEach(func() { + ver = 2 + createdAt = time.Now() + initialAmount = big.NewInt(1340) + asset = "USD" + e = Subscribe(GinkgoT(), app.GetValue()) + + connectorConf := newConnectorConfigurationFn()(uuid.New()) + err = ConnectorInstall(ctx, app.GetValue(), ver, connectorConf, &connectorRes) + Expect(err).To(BeNil()) + }) + + It("should be ok", func() { + debtorID, creditorID := setupDebtorAndCreditorAccounts(ctx, app.GetValue(), e, ver, connectorRes.Data, createdAt) + createRequest := v2.CreatePaymentRequest{ + Reference: "ref", + ConnectorID: connectorRes.Data, + CreatedAt: createdAt, + Amount: initialAmount, + Asset: asset, + Type: models.PAYMENT_TYPE_PAYIN.String(), + Status: models.PAYMENT_STATUS_SUCCEEDED.String(), + SourceAccountID: &debtorID, + DestinationAccountID: &creditorID, + Scheme: models.PAYMENT_SCHEME_CARD_AMEX.String(), + Metadata: map[string]string{"key": "val"}, + } + + err = CreatePayment(ctx, app.GetValue(), ver, createRequest, &createResponse) + Expect(err).To(BeNil()) + Expect(createResponse.Data.ID).NotTo(Equal("")) + + Eventually(e).Should(Receive(Event(evts.EventTypeSavedPayments))) + + err = GetPayment(ctx, app.GetValue(), ver, createResponse.Data.ID, &getResponse) + Expect(err).To(BeNil()) + Expect(getResponse.Data.Amount).To(Equal(createRequest.Amount)) + Expect(getResponse.Data.Status).To(Equal(createRequest.Status)) + }) + }) +}) + +func setupDebtorAndCreditorAccounts( + ctx context.Context, + app *testserver.Server, + e chan *nats.Msg, + ver int, + connectorID string, + createdAt time.Time, +) (debtorID, creditorID string) { + var ( + creditorRes struct{ Data models.Account } + debtorRes struct{ Data models.Account } + ) + + creditorRequest := v3.CreateAccountRequest{ + Reference: "creditor", + AccountName: "creditor", + ConnectorID: connectorID, + CreatedAt: createdAt.Add(-time.Hour), + DefaultAsset: "USD", + Type: string(models.ACCOUNT_TYPE_INTERNAL), + Metadata: map[string]string{"key": "val"}, + } + err := CreateAccount(ctx, app, ver, creditorRequest, &creditorRes) + Expect(err).To(BeNil()) + Eventually(e).Should(Receive(Event(evts.EventTypeSavedAccounts))) + + debtorRequest := v3.CreateAccountRequest{ + Reference: "debtor", + AccountName: "debtor", + ConnectorID: connectorID, + CreatedAt: createdAt, + DefaultAsset: "USD", + Type: string(models.ACCOUNT_TYPE_EXTERNAL), + Metadata: map[string]string{"ping": "pong"}, + } + err = CreateAccount(ctx, app, ver, debtorRequest, &debtorRes) + Expect(err).To(BeNil()) + Eventually(e).Should(Receive(Event(evts.EventTypeSavedAccounts))) + + return debtorRes.Data.ID.String(), creditorRes.Data.ID.String() +} diff --git a/test/e2e/suite_test.go b/test/e2e/suite_test.go new file mode 100644 index 00000000..44b77ac0 --- /dev/null +++ b/test/e2e/suite_test.go @@ -0,0 +1,123 @@ +//go:build it + +package test_suite + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/formancehq/go-libs/v2/bun/bunconnect" + "github.com/formancehq/go-libs/v2/logging" + "github.com/formancehq/go-libs/v2/testing/docker" + "github.com/formancehq/go-libs/v2/testing/platform/natstesting" + "github.com/formancehq/go-libs/v2/testing/platform/pgtesting" + "github.com/formancehq/go-libs/v2/testing/platform/temporaltesting" + . "github.com/formancehq/go-libs/v2/testing/utils" + "github.com/formancehq/payments/internal/storage" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func Test(t *testing.T) { + RegisterFailHandler(Fail) + os.Setenv("PLUGIN_MAGIC_COOKIE", magicCookieVal) + RunSpecs(t, "Test Suite") +} + +var ( + dockerPool = NewDeferred[*docker.Pool]() + pgServer = NewDeferred[*pgtesting.PostgresServer]() + temporalServer = NewDeferred[*temporaltesting.TemporalServer]() + natsServer = NewDeferred[*natstesting.NatsServer]() + debug = os.Getenv("DEBUG") == "true" + logger = logging.NewDefaultLogger(GinkgoWriter, debug, false, false) + stack = "somestackval-abcd" + magicCookieVal = "needed-for-plugin-to-work" + + DBTemplate = "dbtemplate" +) + +type ConnectorConf struct { + Name string `json:"name"` + PollingPeriod string `json:"pollingPeriod"` + PageSize int `json:"pageSize"` + Directory string `json:"directory"` +} + +type ParallelExecutionContext struct { + PostgresServer *pgtesting.PostgresServer + NatsServer *natstesting.NatsServer + TemporalServer *temporaltesting.TemporalServer +} + +var _ = SynchronizedBeforeSuite(func() []byte { + By("Initializing docker pool") + dockerPool.SetValue(docker.NewPool(GinkgoT(), logger)) + + pgServer.LoadAsync(func() *pgtesting.PostgresServer { + By("Initializing postgres server") + ret := pgtesting.CreatePostgresServer( + GinkgoT(), + dockerPool.GetValue(), + pgtesting.WithPGStatsExtension(), + pgtesting.WithPGCrypto(), + ) + By("Postgres address: " + ret.GetDSN()) + + templateDatabase := ret.NewDatabase(GinkgoT(), pgtesting.WithName(DBTemplate)) + + bunDB, err := bunconnect.OpenSQLDB(context.Background(), templateDatabase.ConnectionOptions()) + Expect(err).To(BeNil()) + + err = storage.Migrate(context.Background(), bunDB, "test") + Expect(err).To(BeNil()) + Expect(bunDB.Close()).To(BeNil()) + + return ret + }) + natsServer.LoadAsync(func() *natstesting.NatsServer { + By("Initializing nats server") + ret := natstesting.CreateServer(GinkgoT(), debug, logger) + By("Nats address: " + ret.ClientURL()) + return ret + }) + + temporalServer.LoadAsync(func() *temporaltesting.TemporalServer { + By("Initializing temporal server") + ret := temporaltesting.CreateTemporalServer(GinkgoT(), GinkgoWriter) + return ret + }) + + By("Waiting services alive") + Wait(pgServer, natsServer, temporalServer) + By("All services ready.") + + data, err := json.Marshal(ParallelExecutionContext{ + PostgresServer: pgServer.GetValue(), + NatsServer: natsServer.GetValue(), + TemporalServer: temporalServer.GetValue(), + }) + Expect(err).To(BeNil()) + + return data +}, func(data []byte) { + select { + case <-pgServer.Done(): + // Process #1, setup is terminated + return + default: + } + pec := ParallelExecutionContext{} + err := json.Unmarshal(data, &pec) + Expect(err).To(BeNil()) + + pgServer.SetValue(pec.PostgresServer) + natsServer.SetValue(pec.NatsServer) + temporalServer.SetValue(pec.TemporalServer) +}) + +func UseTemplatedDatabase() *Deferred[*pgtesting.Database] { + return pgtesting.UsePostgresDatabase(pgServer, pgtesting.CreateWithTemplate(DBTemplate)) +}