diff --git a/.github/workflows/anchor_platform_integration_check.yml b/.github/workflows/anchor_platform_integration_check.yml index 53f27271e..765e94651 100644 --- a/.github/workflows/anchor_platform_integration_check.yml +++ b/.github/workflows/anchor_platform_integration_check.yml @@ -4,10 +4,6 @@ on: push: branches: - main - - develop - - "release/**" - - "releases/**" - - "hotfix/**" pull_request: workflow_call: # allows this workflow to be called from another workflow @@ -43,7 +39,7 @@ jobs: echo 'Anchor-platform is up and running.' - name: Install NodeJs - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1378e6044..e7d24c354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,6 @@ on: push: branches: - main - - develop - - "release/**" - - "releases/**" - - "hotfix/**" pull_request: workflow_call: # allows this workflow to be called from another workflow @@ -19,14 +15,14 @@ jobs: uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: 1.22.1 cache: true cache-dependency-path: go.sum - name: golangci-lint - uses: golangci/golangci-lint-action@3cfe3a4abbb849e10058ce4af15d205b6da42804 # version v4.0.0 + uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # version v6.1.1 with: version: v1.56.2 # this is the golangci-lint version args: --timeout 5m0s @@ -74,7 +70,7 @@ jobs: uses: actions/checkout@v4 - name: Install NodeJs - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 14 @@ -102,7 +98,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: 1.22.1 cache: true @@ -141,7 +137,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: 1.22.1 cache: true diff --git a/.github/workflows/docker_image_public_release.yml b/.github/workflows/docker_image_public_release.yml index e2e7ff2ee..a098e903a 100644 --- a/.github/workflows/docker_image_public_release.yml +++ b/.github/workflows/docker_image_public_release.yml @@ -54,13 +54,13 @@ jobs: - uses: actions/checkout@v4 - name: Login to DockerHub - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.3.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push to DockerHub (release prd) - uses: docker/build-push-action@v4.1.1 + uses: docker/build-push-action@v6.9.0 with: push: true build-args: | @@ -80,7 +80,7 @@ jobs: - uses: actions/checkout@v4 - name: Login to DockerHub - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.3.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -95,7 +95,7 @@ jobs: run: echo "SHA=$(git rev-parse --short ${{ github.sha }} )" >> $GITHUB_OUTPUT - name: Build and push to DockerHub (develop branch) - uses: docker/build-push-action@v4.1.1 + uses: docker/build-push-action@v6.9.0 with: push: true build-args: | diff --git a/.github/workflows/e2e_integration_test.yml b/.github/workflows/e2e_integration_test.yml index 4a5ad80e9..804b30fe8 100644 --- a/.github/workflows/e2e_integration_test.yml +++ b/.github/workflows/e2e_integration_test.yml @@ -4,10 +4,6 @@ on: push: branches: - main - - develop - - "release/**" - - "releases/**" - - "hotfix/**" pull_request: workflow_call: # allows this workflow to be called from another workflow @@ -29,18 +25,36 @@ jobs: max-parallel: 1 matrix: platform: - - "Stellar" - - "Circle" + - "Stellar-phone" # Stellar distribution account where receivers are registered with their phone number + - "Stellar-phone-wallet" # Stellar distribution account where receivers are registered with their phone number and wallet address + - "Stellar-email" # Stellar distribution account where receivers are registered with their email + - "Circle-phone" # Circle distribution account where receivers are registered with their email include: - - platform: "Stellar" + - platform: "Stellar-phone" environment: "Receiver Registration - E2E Integration Tests (Stellar)" DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.STELLAR.ENV" - - platform: "Circle" + DISBURSEMENT_CSV_FILE_NAME: "disbursement_instructions_phone.csv" + REGISTRATION_CONTACT_TYPE: "PHONE_NUMBER" + - platform: "Stellar-phone-wallet" + environment: "Receiver Registration - E2E Integration Tests (Stellar)" + DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.STELLAR.ENV" + DISBURSEMENT_CSV_FILE_NAME: "disbursement_instructions_phone_with_wallet.csv" + REGISTRATION_CONTACT_TYPE: "PHONE_NUMBER_AND_WALLET_ADDRESS" + - platform: "Stellar-email" + environment: "Receiver Registration - E2E Integration Tests (Stellar)" + DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.STELLAR.ENV" + DISBURSEMENT_CSV_FILE_NAME: "disbursement_instructions_email.csv" + REGISTRATION_CONTACT_TYPE: "EMAIL" + - platform: "Circle-phone" environment: "Receiver Registration - E2E Integration Tests (Circle)" DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT" + DISBURSEMENT_CSV_FILE_NAME: "disbursement_instructions_phone.csv" + REGISTRATION_CONTACT_TYPE: "PHONE_NUMBER" environment: ${{ matrix.environment }} env: DISTRIBUTION_ACCOUNT_TYPE: ${{ matrix.DISTRIBUTION_ACCOUNT_TYPE }} + DISBURSEMENT_CSV_FILE_NAME: ${{ matrix.DISBURSEMENT_CSV_FILE_NAME }} + REGISTRATION_CONTACT_TYPE: ${{ matrix.REGISTRATION_CONTACT_TYPE }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e3dcf9f..cb03270cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,78 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## Unreleased -None +## [3.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/3.0.0) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/2.1.1...3.0.0)) + +Release of the Stellar Disbursement Platform `v3.0.0`. In this release, receiver registration does not need to be done +exclusively through SMS as it now supports new types. The options are `PHONE_NUMBER`, `EMAIL`, +`EMAIL_AND_WALLET_ADDRESS`, and `PHONE_NUMBER_AND_WALLET_ADDRESS`. If a receiver is registered with a wallet address, +they can receive the payment right away without having to go through the SEP-24 registration flow. + +This version is only compatible with the [stellar/stellar-disbursement-platform-frontend] version `3.0.0`. + +### Breaking Changes + +- Renamed properties and environment variables related to Email Registration Support [#412](https://github.com/stellar/stellar-disbursement-platform-backend/pull/412) + - Renamed `MAX_INVITATION_SMS_RESEND_ATTEMPT` environment variable to `MAX_INVITATION_RESEND_ATTEMPTS` + - Renamed `organization.sms_resend_interval` to `organization.receiver_invitation_resend_interval_days` + - Renamed `organization.sms_registration_message_template` to `organization.receiver_registration_message_template` + - Renamed `disbursement.sms_registration_message_template` to `disbursement.receiver_registration_message_template` + +### Added + +- Ability to register receivers using email addresses + - Update the `receiver_registered_successfully.tmpl` HTML template to display the contact info [#418](https://github.com/stellar/stellar-disbursement-platform-backend/pull/418) + - Update `/wallet-registration/verification` to accommodate different verification methods [#416](https://github.com/stellar/stellar-disbursement-platform-backend/pull/416) + - Update send and auto-retry invitation scheduler job to work with email [#415](https://github.com/stellar/stellar-disbursement-platform-backend/pull/415) + - Update `POST /wallet-registration/otp` to send OTPs through email [#413](https://github.com/stellar/stellar-disbursement-platform-backend/pull/413) + - Rename SMS-related fields in `organization` and `disbursement` to be more generic [#412](https://github.com/stellar/stellar-disbursement-platform-backend/pull/412) + - Update process disbursement instructions to accept email addresses [#404](https://github.com/stellar/stellar-disbursement-platform-backend/pull/404) + - Add an initial screen so receivers can choose between phone number and email registration during registration [#406](https://github.com/stellar/stellar-disbursement-platform-backend/pull/406) + - Add message channel priority to the `organizations` table [#400](https://github.com/stellar/stellar-disbursement-platform-backend/pull/400) + - Add `MessageDispatcher` to SDP to send messages to different channels [#391](https://github.com/stellar/stellar-disbursement-platform-backend/pull/391) + - Update the development endpoint `DELETE .../phone-number/...` to `DELETE .../contact-info/...`, allowing it to delete based on the email as well [#438](https://github.com/stellar/stellar-disbursement-platform-backend/pull/438) + - Remove the word "phone" from the default organization's `otp_message_template` [#439](https://github.com/stellar/stellar-disbursement-platform-backend/pull/439) + - Rename SMS-related field and update Helm docs [#468](https://github.com/stellar/stellar-disbursement-platform-backend/pull/468) +- Ability to register receivers with a Stellar wallet address directly by providing contact info and a wallet address. The options currently are `PHONE_NUMBER_AND_WALLET_ADDRESS` and `EMAIL_AND_WALLET_ADDRESS` + - Create `GET /registration-contact-types` endpoint [#451](https://github.com/stellar/stellar-disbursement-platform-backend/pull/451) + - Update `POST /disbursements` and `GET /disbursements` APIs to persist and return the Registration Contact Type [#452](https://github.com/stellar/stellar-disbursement-platform-backend/pull/452), [#454](https://github.com/stellar/stellar-disbursement-platform-backend/pull/454) + - Allow `disbursement.verification_field` to be empty [#456](https://github.com/stellar/stellar-disbursement-platform-backend/pull/456) + - Integrate wallet address in processing disbursement instructions [#453](https://github.com/stellar/stellar-disbursement-platform-backend/pull/453) + - Add user-managed wallets [#458](https://github.com/stellar/stellar-disbursement-platform-backend/pull/458) +- Add Twilio SendGrid as a supported email client [#444](https://github.com/stellar/stellar-disbursement-platform-backend/pull/444) + +### Changed + +- Replaced deprecated Circle Accounts API by adopting the Circle API endpoints `GET /v1/businessAccount/balances` and `GET /configuration` [#433](https://github.com/stellar/stellar-disbursement-platform-backend/pull/433) +- `PATCH /receiver` now allows patching the phone number and email address of a receiver [#436](https://github.com/stellar/stellar-disbursement-platform-backend/pull/436) +- Increased window for clients to perform token refresh [#437](https://github.com/stellar/stellar-disbursement-platform-backend/pull/437) +- Other technical changes ([#383](https://github.com/stellar/stellar-disbursement-platform-backend/pull/383), [#450](https://github.com/stellar/stellar-disbursement-platform-backend/pull/450)) + +### Fixed + +- Unable to get a token from the Forgot Password flow after messaging service failure [#466](https://github.com/stellar/stellar-disbursement-platform-backend/pull/466) +- ReCaptcha blocks retrying verification during wallet registration [#473](https://github.com/stellar/stellar-disbursement-platform-backend/pull/473) + +### Removed + +- Removed countries from the flow and deleted any references to them from the database [#455](https://github.com/stellar/stellar-disbursement-platform-backend/pull/455), [#462](https://github.com/stellar/stellar-disbursement-platform-backend/pull/462) + +### Security and Dependencies + +- Fix HTML injection vulnerability [#419](https://github.com/stellar/stellar-disbursement-platform-backend/pull/419) +- Fix HTML escaping [#420](https://github.com/stellar/stellar-disbursement-platform-backend/pull/420) +- Removed support for the HTTP headers `X-XSS-Protection`, `X-Forwarded-Host`, `X-Real-IP`, and `True-Client-IP` [#448](https://github.com/stellar/stellar-disbursement-platform-backend/pull/448) +- Improved validation to ensure the instruction file being uploaded is a `*.csv` file [#443](https://github.com/stellar/stellar-disbursement-platform-backend/pull/443) +- Ensure validation of URLs with the HTTPS schema on Pubnet [#445](https://github.com/stellar/stellar-disbursement-platform-backend/pull/445) +- Add path validation to the `readDisbursementCSV` method used in integration tests [#437](https://github.com/stellar/stellar-disbursement-platform-backend/pull/437) +- Bump `golangci/golangci-lint-action` [#380](https://github.com/stellar/stellar-disbursement-platform-backend/pull/380) +- Bump `golang` in the all-docker group [#387](https://github.com/stellar/stellar-disbursement-platform-backend/pull/387), [#394](https://github.com/stellar/stellar-disbursement-platform-backend/pull/394), [#414](https://github.com/stellar/stellar-disbursement-platform-backend/pull/414) +- Bump minor and patch dependencies across directories [#381](https://github.com/stellar/stellar-disbursement-platform-backend/pull/381), [#395](https://github.com/stellar/stellar-disbursement-platform-backend/pull/395), [#403](https://github.com/stellar/stellar-disbursement-platform-backend/pull/403), [#411](https://github.com/stellar/stellar-disbursement-platform-backend/pull/411), [#429](https://github.com/stellar/stellar-disbursement-platform-backend/pull/429), [#430](https://github.com/stellar/stellar-disbursement-platform-backend/pull/430), [#431](https://github.com/stellar/stellar-disbursement-platform-backend/pull/431), [#441](https://github.com/stellar/stellar-disbursement-platform-backend/pull/441). ## [2.1.1](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/2.1.1) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/2.1.0...2.1.1)) ### Changed + - Removed calls related to the deprecated Circle Accounts API and replaced them with calls to `GET /v1/businessAccount/balances` and `GET /configuration`. [#433](https://github.com/stellar/stellar-disbursement-platform-backend/pull/433). ## [2.1.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/2.1.0) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/2.0.0...2.1.0)) diff --git a/Dockerfile b/Dockerfile index c04e81fdb..405d8662e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # To push: # make docker-push -FROM golang:1.22.1-bullseye AS build +FROM golang:1.23.3-bullseye AS build ARG GIT_COMMIT WORKDIR /src/stellar-disbursement-platform @@ -13,11 +13,11 @@ ADD . ./ RUN go build -o /bin/stellar-disbursement-platform -ldflags "-X main.GitCommit=$GIT_COMMIT" . -FROM ubuntu:22.04 +FROM ubuntu:24.04 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates # ADD migrations/ /app/migrations/ COPY --from=build /bin/stellar-disbursement-platform /app/ EXPOSE 8001 WORKDIR /app -ENTRYPOINT ["./stellar-disbursement-platform"] +ENTRYPOINT ["./stellar-disbursement-platform"] \ No newline at end of file diff --git a/Dockerfile.development b/Dockerfile.development index 8cab186a2..f38d8e8c6 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -1,5 +1,5 @@ # Stage 1: Build the Go application -FROM golang:1.22.1-bullseye AS build +FROM golang:1.23.3-bullseye AS build ARG GIT_COMMIT WORKDIR /src/stellar-disbursement-platform @@ -9,7 +9,7 @@ COPY . ./ RUN go build -o /bin/stellar-disbursement-platform -ldflags "-X main.GitCommit=$GIT_COMMIT" . # Stage 2: Setup the development environment with Delve for debugging -FROM golang:1.22.1-bullseye AS development +FROM golang:1.23.3-bullseye AS development # set workdir according to repo structure so remote debug source code is in sync WORKDIR /app/github.com/stellar/stellar-disbursement-platform @@ -24,4 +24,3 @@ RUN go install github.com/go-delve/delve/cmd/dlv@latest RUN chmod +x /app/github.com/stellar/stellar-disbursement-platform/stellar-disbursement-platform EXPOSE 8001 2345 ENTRYPOINT ["/go/bin/dlv", "exec", "--continue", "--accept-multiclient", "--headless", "--listen=:2345", "--api-version=2", "--log", "./stellar-disbursement-platform"] - diff --git a/README.md b/README.md index 8f6430b55..3c9a5051b 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,10 @@ The Message Service sends messages to users and recipients for the following rea - Providing one-time passcodes (OTPs) to recipients - Sending emails to users during account creation and account recovery flows -Note that the Message Service requires that both SMS and email services are configured. For emails, AWS SES is supported. For SMS messages to recipients, Twilio is supported. AWS SNS support is not integrated yet. +Note that the Message Service requires that both SMS and email services are configured. For emails, AWS SES and Twilio Sendgrid are supported. For SMS messages to recipients, Twilio and AWS SNS are supported. + +If you're using the `AWS_EMAIL` or `TWILIO_EMAIL` sender types, you'll need to verify the email address you're using to send emails in order to prevent it from being flagged by email firewalls. You can do that by following the instructions in [this link for AWS SES](https://docs.aws.amazon.com/ses/latest/dg/email-authentication-methods.html) or [this link for Twilio Sendgrid](https://www.twilio.com/docs/sendgrid/glossary/sender-authentication). -If you're using the `AWS_EMAIL` sender type, you'll need to verify the email address you're using to send emails in order to prevent it from being flagged by email firewalls. You can do that by following the instructions in [this link](https://docs.aws.amazon.com/ses/latest/dg/email-authentication-methods.html). #### Wallet Registration UI @@ -255,7 +256,7 @@ We recommend Background Jobs for organizations that require a simpler setup and > [!NOTE] > Certain jobs are not listed here because they cannot be configured and are necessary to the functioning of the SDP. -* `send_receiver_wallets_sms_invitation_job`: This job is used to send disbursement invites to recipients. Its interval is configured through the `SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS` environment variable. +* `send_receiver_wallets_invitation_job`: This job is used to send disbursement invites to recipients. Its interval is configured through the `SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS` environment variable. * `payment_to_submitter_job`: This job is used to submit payments from Core to the TSS. Its interval is configured through the `SCHEDULER_PAYMENT_JOB_SECONDS` environment variable. * `payment_from_submitter_job`: This job is used to notify Core that a payment has been completed. Its interval is configured through the `SCHEDULER_PAYMENT_JOB_SECONDS` environment variable. * `patch_anchor_platform_transactions_completion`: This job is used to patch transactions in Anchor Platform once payments reach the final state 'SUCCESS' or 'FAILED'. Its interval is configured through the `SCHEDULER_PAYMENT_JOB_SECONDS` environment variable. diff --git a/cmd/auth.go b/cmd/auth.go index 7088d662a..e446c9a12 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -130,14 +130,14 @@ func (a *AuthCommand) Command() *cobra.Command { log.Ctx(ctx).Fatalf("error getting organization data: %s", err.Error()) } - invitationData := htmltemplate.InvitationMessageTemplate{ + invitationData := htmltemplate.StaffInvitationEmailMessageTemplate{ FirstName: firstName, Role: role, ForgotPasswordLink: forgotPasswordLink, OrganizationName: organization.Name, } - msgBody, err := htmltemplate.ExecuteHTMLTemplateForInvitationMessage(invitationData) + msgBody, err := htmltemplate.ExecuteHTMLTemplateForStaffInvitationEmailMessage(invitationData) if err != nil { log.Ctx(ctx).Fatalf("error executing invitation message template: %s", err.Error()) } @@ -145,7 +145,7 @@ func (a *AuthCommand) Command() *cobra.Command { err = emailMessengerClient.SendMessage(message.Message{ ToEmail: email, Title: "Welcome to Stellar Disbursement Platform", - Message: msgBody, + Body: msgBody, }) if err != nil { log.Ctx(ctx).Fatalf("error sending invitation message: %s", err.Error()) diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 4f6d18ca0..ab0c3b89d 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -74,53 +74,13 @@ func Test_persistentPostRun(t *testing.T) { err = rootCmd.Execute() require.NoError(t, err) - expectContains := `------------------------------------------------------------------------------- -Recipient: email@email.com -Subject: Welcome to Stellar Disbursement Platform -Content: - - - - Welcome to Stellar Disbursement Platform - - - -

Hello, First!

-

You have been added to your organization's Stellar Disbursement Platform as a developer. Please click the link below to set up your password and let your organization administrator know if you have any questions.

-

- Set up my password -

-

Best regards,

-

The MyCustomAid Team

- - - -------------------------------------------------------------------------------- -` + expectContainsSlice := []string{ + "Welcome to Stellar Disbursement Platform", + "

You have been added to your organization's Stellar Disbursement Platform as a developer. Please click the button below to set up your password. If you have any questions, feel free to contact your organization administrator.

", + `Set up my password`, + "

Best regards,

", + "

The MyCustomAid Team

", + } w.Close() os.Stdout = stdOut @@ -129,7 +89,9 @@ Content: _, err = io.Copy(buf, r) require.NoError(t, err) - assert.Contains(t, buf.String(), expectContains) + for _, expectContains := range expectContainsSlice { + assert.Contains(t, buf.String(), expectContains) + } // Set another SDP UI base URL rootCmd.SetArgs([]string{"auth", "add-user", "email@email.com", "First", "Last", "--roles", "developer", "--sdp-ui-base-url", "https://sdp-ui.org", "--tenant-id", tnt.ID}) @@ -144,53 +106,13 @@ Content: err = rootCmd.Execute() require.NoError(t, err) - expectContains = `------------------------------------------------------------------------------- -Recipient: email@email.com -Subject: Welcome to Stellar Disbursement Platform -Content: - - - - Welcome to Stellar Disbursement Platform - - - -

Hello, First!

-

You have been added to your organization's Stellar Disbursement Platform as a developer. Please click the link below to set up your password and let your organization administrator know if you have any questions.

-

- Set up my password -

-

Best regards,

-

The MyCustomAid Team

- - - -------------------------------------------------------------------------------- -` + expectContainsSlice = []string{ + "Welcome to Stellar Disbursement Platform", + "

You have been added to your organization's Stellar Disbursement Platform as a developer. Please click the button below to set up your password. If you have any questions, feel free to contact your organization administrator.

", + `Set up my password`, + "

Best regards,

", + "

The MyCustomAid Team

", + } w.Close() os.Stdout = stdOut @@ -199,5 +121,7 @@ Content: _, err = io.Copy(buf, r) require.NoError(t, err) - assert.Contains(t, buf.String(), expectContains) + for _, expectContains := range expectContainsSlice { + assert.Contains(t, buf.String(), expectContains) + } } diff --git a/cmd/integration_tests.go b/cmd/integration_tests.go index b4db44045..92d7e98a4 100644 --- a/cmd/integration_tests.go +++ b/cmd/integration_tests.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "go/types" "github.com/spf13/cobra" @@ -8,6 +9,7 @@ import ( "github.com/stellar/go/support/log" cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/integrationtests" ) @@ -105,6 +107,14 @@ func (c *IntegrationTestsCommand) Command() *cobra.Command { ConfigKey: &integrationTestsOpts.ServerApiBaseURL, Required: true, }, + { + Name: "registration-contact-type", + Usage: fmt.Sprintf("The registration contact type used when creating a new disbursement. Options: %v", data.AllRegistrationContactTypes()), + OptType: types.String, + CustomSetValue: cmdUtils.SetRegistrationContactType, + ConfigKey: &integrationTestsOpts.RegistrationContactType, + Required: true, + }, } integrationTestsCmd := &cobra.Command{ Use: "integration-tests", diff --git a/cmd/message.go b/cmd/message.go index f68108f02..ad782f5e4 100644 --- a/cmd/message.go +++ b/cmd/message.go @@ -40,7 +40,7 @@ func (s *MessageCommand) Command(messengerService MessengerServiceInterface) *co // message sender type { Name: "message-sender-type", - Usage: `Message Sender Type. Options: "TWILIO_SMS", "AWS_SMS", "AWS_EMAIL", "DRY_RUN"`, + Usage: `Message Sender Type. Options: "TWILIO_SMS", "TWILIO_EMAIL", AWS_SMS", "AWS_EMAIL", "DRY_RUN"`, OptType: types.String, CustomSetValue: cmdUtils.SetConfigOptionMessengerType, ConfigKey: &opts.MessengerType, @@ -115,7 +115,7 @@ func (s *MessageCommand) sendMessageCommand(messengerService MessengerServiceInt Name: "message", Usage: "The text of the message to be sent", OptType: types.String, - ConfigKey: &msg.Message, + ConfigKey: &msg.Body, Required: true, }, } diff --git a/cmd/message_test.go b/cmd/message_test.go index b5f827958..1b7067a12 100644 --- a/cmd/message_test.go +++ b/cmd/message_test.go @@ -93,7 +93,7 @@ func Test_message_send_SendMessage_wasCalled(t *testing.T) { } wantMessage := message.Message{ ToPhoneNumber: "+41555511111", - Message: "hello world", + Body: "hello world", } mMessageService.On("SendMessage", wantMessageOptions, wantMessage).Return(nil).Once() diff --git a/cmd/root.go b/cmd/root.go index 39ea9d101..b8b703197 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -81,8 +81,6 @@ func rootCmd() *cobra.Command { if err != nil { log.Ctx(ctx).Fatalf("Error setting values of config options: %s", err.Error()) } - log.Ctx(ctx).Info("Version: ", globalOptions.Version) - log.Ctx(ctx).Info("GitCommit: ", globalOptions.GitCommit) }, Run: func(cmd *cobra.Command, args []string) { err := cmd.Help() diff --git a/cmd/serve.go b/cmd/serve.go index 6d8f7230c..355cdf7fc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -116,13 +116,13 @@ func (s *ServerService) GetSchedulerJobRegistrars( }), scheduler.WithPaymentFromSubmitterJobOption(schedulerOptions.PaymentJobIntervalSeconds, models, tssDBConnectionPool), scheduler.WithPatchAnchorPlatformTransactionsCompletionJobOption(schedulerOptions.PaymentJobIntervalSeconds, apAPIService, models), - scheduler.WithSendReceiverWalletsSMSInvitationJobOption(jobs.SendReceiverWalletsSMSInvitationJobOptions{ - Models: models, - MessengerClient: serveOpts.SMSMessengerClient, - MaxInvitationSMSResendAttempts: int64(serveOpts.MaxInvitationSMSResendAttempts), - Sep10SigningPrivateKey: serveOpts.Sep10SigningPrivateKey, - CrashTrackerClient: serveOpts.CrashTrackerClient.Clone(), - JobIntervalSeconds: schedulerOptions.ReceiverInvitationJobIntervalSeconds, + scheduler.WithSendReceiverWalletsInvitationJobOption(jobs.SendReceiverWalletsInvitationJobOptions{ + Models: models, + MessageDispatcher: serveOpts.MessageDispatcher, + MaxInvitationResendAttempts: int64(serveOpts.MaxInvitationResendAttempts), + Sep10SigningPrivateKey: serveOpts.Sep10SigningPrivateKey, + CrashTrackerClient: serveOpts.CrashTrackerClient.Clone(), + JobIntervalSeconds: schedulerOptions.ReceiverInvitationJobIntervalSeconds, }), ) } @@ -139,21 +139,22 @@ type SetupConsumersOptions struct { func (s *ServerService) SetupConsumers(ctx context.Context, o SetupConsumersOptions) error { kafkaConfig := cmdUtils.KafkaConfig(o.EventBrokerOptions) - smsInvitationConsumer, err := events.NewKafkaConsumer( + receiverInvitationConsumer, err := events.NewKafkaConsumer( kafkaConfig, events.ReceiverWalletNewInvitationTopic, o.EventBrokerOptions.ConsumerGroupID, - eventhandlers.NewSendReceiverWalletsSMSInvitationEventHandler(eventhandlers.SendReceiverWalletsSMSInvitationEventHandlerOptions{ - MtnDBConnectionPool: o.ServeOpts.MtnDBConnectionPool, - AdminDBConnectionPool: o.ServeOpts.AdminDBConnectionPool, - AnchorPlatformBaseSepURL: o.ServeOpts.AnchorPlatformBasePlatformURL, - MessengerClient: o.ServeOpts.SMSMessengerClient, - MaxInvitationSMSResendAttempts: int64(o.ServeOpts.MaxInvitationSMSResendAttempts), - Sep10SigningPrivateKey: o.ServeOpts.Sep10SigningPrivateKey, + eventhandlers.NewSendReceiverWalletsInvitationEventHandler(eventhandlers.SendReceiverWalletsInvitationEventHandlerOptions{ + MtnDBConnectionPool: o.ServeOpts.MtnDBConnectionPool, + AdminDBConnectionPool: o.ServeOpts.AdminDBConnectionPool, + AnchorPlatformBaseSepURL: o.ServeOpts.AnchorPlatformBasePlatformURL, + MessageDispatcher: o.ServeOpts.MessageDispatcher, + MaxInvitationResendAttempts: int64(o.ServeOpts.MaxInvitationResendAttempts), + Sep10SigningPrivateKey: o.ServeOpts.Sep10SigningPrivateKey, + CrashTrackerClient: o.ServeOpts.CrashTrackerClient.Clone(), }), ) if err != nil { - return fmt.Errorf("creating SMS Invitation Kafka Consumer: %w", err) + return fmt.Errorf("creating Receiver Invitation Kafka Consumer: %w", err) } paymentCompletedConsumer, err := events.NewKafkaConsumer( @@ -212,7 +213,7 @@ func (s *ServerService) SetupConsumers(ctx context.Context, o SetupConsumersOpti return fmt.Errorf("creating Kafka producer: %w", err) } - go events.NewEventConsumer(smsInvitationConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) + go events.NewEventConsumer(receiverInvitationConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) go events.NewEventConsumer(paymentCompletedConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) go events.NewEventConsumer(stellarPaymentReadyToPayConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) go events.NewEventConsumer(circlePaymentReadyToPayConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) @@ -356,10 +357,10 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ Required: false, }, { - Name: "max-invitation-sms-resend-attempts", - Usage: "The maximum number of attempts to resend the SMS invitation to the Receiver Wallets.", + Name: "max-invitation-resend-attempts", + Usage: "The maximum number of attempts to resend the invitation to the Receiver Wallets.", OptType: types.Int, - ConfigKey: &serveOpts.MaxInvitationSMSResendAttempts, + ConfigKey: &serveOpts.MaxInvitationResendAttempts, FlagDefault: 3, Required: true, }, @@ -582,10 +583,14 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ serveOpts.EmailMessengerClient = emailMessengerClient adminServeOpts.EmailMessengerClient = emailMessengerClient - // Setup the SMS client - serveOpts.SMSMessengerClient, err = di.NewSMSClient(smsOpts) + // Setup the Message Dispatcher + messageDispatcherOpts := di.MessageDispatcherOpts{ + EmailOpts: &emailOpts, + SMSOpts: &smsOpts, + } + serveOpts.MessageDispatcher, err = di.NewMessageDispatcher(ctx, messageDispatcherOpts) if err != nil { - log.Ctx(ctx).Fatalf("error creating SMS client: %s", err.Error()) + log.Ctx(ctx).Fatalf("error creating message dispatcher: %s", err.Error()) } // Setup the AP Auth enforcer @@ -627,6 +632,7 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ NetworkType: serveOpts.NetworkType, EncryptionPassphrase: serveOpts.DistAccEncryptionPassphrase, TenantManager: tenant.NewManager(tenant.WithDatabase(serveOpts.AdminDBConnectionPool)), + MonitorService: serveOpts.MonitorService, }) if err != nil { log.Ctx(ctx).Fatalf("error creating Circle service: %v", err) diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 23626a2e2..3b23d0d9c 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -175,7 +175,7 @@ func Test_serve(t *testing.T) { EnableScheduler: false, SubmitterEngine: submitterEngine, DistributionAccountService: mDistAccService, - MaxInvitationSMSResendAttempts: 3, + MaxInvitationResendAttempts: 3, DistAccEncryptionPassphrase: distributionAccPrivKey, CircleService: mCircleService, } @@ -190,11 +190,18 @@ func Test_serve(t *testing.T) { require.NoError(t, err) serveOpts.CrashTrackerClient = crashTrackerClient - messengerClient, err := di.NewEmailClient(di.EmailClientOptions{EmailType: message.MessengerTypeDryRun}) + emailOpts := di.EmailClientOptions{EmailType: message.MessengerTypeDryRun} + emailClient, err := di.NewEmailClient(emailOpts) require.NoError(t, err) - serveOpts.EmailMessengerClient = messengerClient + serveOpts.EmailMessengerClient = emailClient - serveOpts.SMSMessengerClient, err = di.NewSMSClient(di.SMSClientOptions{SMSType: message.MessengerTypeDryRun}) + smsOpts := di.SMSClientOptions{SMSType: message.MessengerTypeDryRun} + + messageDispatcherOpts := di.MessageDispatcherOpts{ + EmailOpts: &emailOpts, + SMSOpts: &smsOpts, + } + serveOpts.MessageDispatcher, err = di.NewMessageDispatcher(ctx, messageDispatcherOpts) require.NoError(t, err) kafkaConfig := events.KafkaConfig{ @@ -220,7 +227,7 @@ func Test_serve(t *testing.T) { serveTenantOpts := serveadmin.ServeOptions{ Environment: "test", - EmailMessengerClient: messengerClient, + EmailMessengerClient: emailClient, AdminDBConnectionPool: dbConnectionPool, MTNDBConnectionPool: dbConnectionPool, CrashTrackerClient: crashTrackerClient, diff --git a/cmd/utils/custom_set_value.go b/cmd/utils/custom_set_value.go index a7ed5753f..da09dfc6a 100644 --- a/cmd/utils/custom_set_value.go +++ b/cmd/utils/custom_set_value.go @@ -13,6 +13,7 @@ import ( "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" @@ -287,3 +288,16 @@ func SetConfigOptionKafkaSecurityProtocol(co *config.ConfigOption) error { *(co.ConfigKey.(*events.KafkaSecurityProtocol)) = protocolParsed return nil } + +func SetRegistrationContactType(co *config.ConfigOption) error { + regAccountTypeStr := viper.GetString(co.Name) + regAccountType := data.RegistrationContactType{} + + err := regAccountType.ParseFromString(regAccountTypeStr) + if err != nil { + return fmt.Errorf("couldn't parse registration contact type in %s: %w", co.Name, err) + } + + *(co.ConfigKey.(*data.RegistrationContactType)) = regAccountType + return nil +} diff --git a/cmd/utils/custom_set_value_test.go b/cmd/utils/custom_set_value_test.go index 4c2bb5bd7..b04ab56e3 100644 --- a/cmd/utils/custom_set_value_test.go +++ b/cmd/utils/custom_set_value_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" @@ -715,3 +716,54 @@ func Test_SetConfigOptionEventBrokerType(t *testing.T) { }) } } + +func Test_SetRegistrationContactType(t *testing.T) { + opts := struct{ RegistrationContactType data.RegistrationContactType }{} + + co := config.ConfigOption{ + Name: "registration-contact-type", + OptType: types.String, + CustomSetValue: SetRegistrationContactType, + ConfigKey: &opts.RegistrationContactType, + } + + testCases := []customSetterTestCase[data.RegistrationContactType]{ + { + name: "returns an error if the value is empty", + args: []string{}, + wantErrContains: `couldn't parse registration contact type in registration-contact-type: unknown ReceiverContactType ""`, + }, + { + name: "returns an error if the value is not supported", + args: []string{"--registration-contact-type", "test"}, + wantErrContains: `couldn't parse registration contact type in registration-contact-type: unknown ReceiverContactType "TEST"`, + }, + { + name: "🎉 handles registration contact type (through CLI args): EMAIL", + args: []string{"--registration-contact-type", "EmAiL"}, + wantResult: data.RegistrationContactTypeEmail, + }, + { + name: "🎉 handles registration contact type (through CLI args): EMAIL_AND_WALLET_ADDRESS", + args: []string{"--registration-contact-type", "EMAIL_AND_WALLET_ADDRESS"}, + wantResult: data.RegistrationContactTypeEmailAndWalletAddress, + }, + { + name: "🎉 handles registration contact type (through CLI args): PHONE_NUMBER", + args: []string{"--registration-contact-type", "PHONE_NUMBER"}, + wantResult: data.RegistrationContactTypePhone, + }, + { + name: "🎉 handles registration contact type (through CLI args): PHONE_NUMBER_AND_WALLET_ADDRESS", + args: []string{"--registration-contact-type", "PHONE_NUMBER_AND_WALLET_ADDRESS"}, + wantResult: data.RegistrationContactTypePhoneAndWalletAddress, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts.RegistrationContactType = data.RegistrationContactType{} + customSetterTester[data.RegistrationContactType](t, tc, co) + }) + } +} diff --git a/cmd/utils/shared_config_options.go b/cmd/utils/shared_config_options.go index c4bddebb6..6b06b8849 100644 --- a/cmd/utils/shared_config_options.go +++ b/cmd/utils/shared_config_options.go @@ -43,6 +43,21 @@ func TwilioConfigOptions(opts *message.MessengerOptions) []*config.ConfigOption ConfigKey: &opts.TwilioServiceSID, Required: false, }, + // Twilio Email (SendGrid) + { + Name: "twilio-sendgrid-api-key", + Usage: "The API key of the Twilio SendGrid account", + OptType: types.String, + ConfigKey: &opts.TwilioSendGridAPIKey, + Required: false, + }, + { + Name: "twilio-sendgrid-sender-address", + Usage: "The email address that Twilio SendGrid will use to send emails", + OptType: types.String, + ConfigKey: &opts.TwilioSendGridSenderAddress, + Required: false, + }, } } diff --git a/db/db.go b/db/db.go index 0bde1884e..6403698de 100644 --- a/db/db.go +++ b/db/db.go @@ -19,6 +19,8 @@ const ( ) // DBConnectionPool is an interface that wraps the sqlx.DB structs methods and includes the RunInTransaction helper. +// +//go:generate mockery --name=DBConnectionPool --case=underscore --structname=MockDBConnectionPool type DBConnectionPool interface { SQLExecuter BeginTxx(ctx context.Context, opts *sql.TxOptions) (DBTransaction, error) diff --git a/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql b/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql index c60c945ed..520aaaa30 100644 --- a/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql +++ b/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql @@ -50,7 +50,9 @@ BEGIN USING status::text::%I.disbursement_status, ALTER COLUMN verification_field DROP DEFAULT, ALTER COLUMN verification_field TYPE %I.verification_type - USING verification_field::text::%I.verification_type; + USING verification_field::text::%I.verification_type, + ADD COLUMN IF NOT EXISTS registration_contact_type %I.registration_contact_types NOT NULL DEFAULT ''PHONE_NUMBER'', + DROP COLUMN IF EXISTS country_code CASCADE; INSERT INTO %I.disbursements SELECT * FROM public.disbursements; ALTER TABLE public.payments @@ -70,7 +72,7 @@ BEGIN schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, - schema_name); + schema_name, schema_name); -- Step 3: Import TSS data diff --git a/db/migrations/sdp-migrations/2023-01-27.1-create-countries-table.sql b/db/migrations/sdp-migrations/2023-01-27.1-create-countries-table.sql index df68ba86b..dd1a89d24 100644 --- a/db/migrations/sdp-migrations/2023-01-27.1-create-countries-table.sql +++ b/db/migrations/sdp-migrations/2023-01-27.1-create-countries-table.sql @@ -22,8 +22,6 @@ ALTER TABLE disbursements ALTER COLUMN country_code SET NOT NULL; CREATE TRIGGER refresh_country_updated_at BEFORE UPDATE ON countries FOR EACH ROW EXECUTE PROCEDURE update_at_refresh(); -- +migrate Down -DROP TRIGGER refresh_country_updated_at ON countries; - -ALTER TABLE disbursements DROP COLUMN country_code; +ALTER TABLE disbursements DROP COLUMN country_code CASCADE; DROP TABLE countries CASCADE; diff --git a/db/migrations/sdp-migrations/2024-08-17.0-organizations-message-channel-priority.sql b/db/migrations/sdp-migrations/2024-08-17.0-organizations-message-channel-priority.sql new file mode 100644 index 000000000..a14272342 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-08-17.0-organizations-message-channel-priority.sql @@ -0,0 +1,64 @@ +-- This migration adds the message_channel type and the message_channel_priority column to the organizations table. +-- +migrate Up + +-- Create the message_channel enum type +CREATE TYPE message_channel AS ENUM ('SMS', 'EMAIL'); + +-- Add the message_channel_priority column to the organizations table +ALTER TABLE organizations + ADD COLUMN message_channel_priority message_channel[] NOT NULL + DEFAULT ARRAY['SMS'::message_channel, 'EMAIL'::message_channel]; + +-- Create a function to check if all message_channel values are included and valid +-- +migrate StatementBegin +CREATE OR REPLACE FUNCTION check_message_channel_priority() + RETURNS TRIGGER AS $$ +DECLARE + all_channels message_channel[]; + duplicate_channels message_channel[]; +BEGIN + -- Get all possible message_channel values + SELECT array_agg(enumlabel::message_channel) + INTO all_channels + FROM pg_enum + WHERE enumtypid = 'message_channel'::regtype; + + -- Check if all channels are included in the new value + IF NOT (SELECT all_channels <@ NEW.message_channel_priority) THEN + RAISE EXCEPTION 'message_channel_priority must include all possible message_channel values'; + END IF; + + -- Check for duplicates + SELECT ARRAY(SELECT channel + FROM unnest(NEW.message_channel_priority) channel + GROUP BY channel + HAVING COUNT(*) > 1) + INTO duplicate_channels; + + IF array_length(duplicate_channels, 1) > 0 THEN + RAISE EXCEPTION 'message_channel_priority must not contain duplicate values: %', duplicate_channels; + END IF; + + RETURN NEW; +END; +$$ language 'plpgsql'; +-- +migrate StatementEnd + +-- Create the trigger +CREATE TRIGGER validate_organizations_message_channel_priority + BEFORE INSERT OR UPDATE OF message_channel_priority ON organizations + FOR EACH ROW EXECUTE FUNCTION check_message_channel_priority(); + +-- +migrate Down + +-- Drop the trigger +DROP TRIGGER IF EXISTS validate_organizations_message_channel_priority ON organizations; + +-- Drop the function +DROP FUNCTION IF EXISTS check_message_channel_priority(); + +-- Remove the message_channel_priority column +ALTER TABLE organizations DROP COLUMN IF EXISTS message_channel_priority; + +-- Remove the message_channel enum type +DROP TYPE IF EXISTS message_channel; \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-08-20.0-alter-receiver-verifications-table-add-verification-channel.sql b/db/migrations/sdp-migrations/2024-08-20.0-alter-receiver-verifications-table-add-verification-channel.sql new file mode 100644 index 000000000..7bb48d38d --- /dev/null +++ b/db/migrations/sdp-migrations/2024-08-20.0-alter-receiver-verifications-table-add-verification-channel.sql @@ -0,0 +1,12 @@ +-- +migrate Up +ALTER TABLE receiver_verifications + ADD COLUMN verification_channel message_channel; + +UPDATE receiver_verifications rv + SET verification_channel = 'SMS'::message_channel + WHERE rv.verification_channel IS NULL AND confirmed_at IS NOT NULL; + +-- +migrate Down + +ALTER TABLE receiver_verifications + DROP COLUMN verification_channel; \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-09-04.0-alter-receivers-table-indexes.sql b/db/migrations/sdp-migrations/2024-09-04.0-alter-receivers-table-indexes.sql new file mode 100644 index 000000000..54f92bab9 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-09-04.0-alter-receivers-table-indexes.sql @@ -0,0 +1,34 @@ +-- +migrate Up +-- Remove existing unique constraint on phone_number +ALTER TABLE receivers DROP CONSTRAINT IF EXISTS payments_account_phone_number_key; + +DROP INDEX IF EXISTS payments_account_phone_number_key; + +-- Make phone_number nullable +ALTER TABLE receivers ALTER COLUMN phone_number DROP NOT NULL; + +-- Add check constraint to ensure at least one contact method is provided +ALTER TABLE receivers ADD CONSTRAINT receiver_contact_check + CHECK (phone_number IS NOT NULL OR email IS NOT NULL); + +-- Create unique indexes on both phone_number and email separately +CREATE UNIQUE INDEX receiver_unique_phone_number ON receivers (LOWER(phone_number)) + WHERE phone_number IS NOT NULL; + +CREATE UNIQUE INDEX receiver_unique_email ON receivers (LOWER(email)) + WHERE email IS NOT NULL; + +-- +migrate Down +-- Remove the new check constraint +ALTER TABLE receivers DROP CONSTRAINT IF EXISTS receiver_contact_check; + +-- Remove the new unique indexes +DROP INDEX IF EXISTS receiver_unique_phone_number; +DROP INDEX IF EXISTS receiver_unique_email; + +-- Restore phone_number to NOT NULL +UPDATE receivers SET phone_number = 'UNKNOWN' WHERE phone_number IS NULL; +ALTER TABLE receivers ALTER COLUMN phone_number SET NOT NULL; + +-- Recreate the original unique constraint and index on phone_number +ALTER TABLE receivers ADD CONSTRAINT payments_account_phone_number_key UNIQUE (phone_number); \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-09-06.0-change-organization-sms-columns.sql b/db/migrations/sdp-migrations/2024-09-06.0-change-organization-sms-columns.sql new file mode 100644 index 000000000..a06aea5c4 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-09-06.0-change-organization-sms-columns.sql @@ -0,0 +1,38 @@ +-- +migrate Up +-- Rename organizations.sms_resend_interval to organizations.receiver_invitation_resend_interval +ALTER TABLE organizations + RENAME COLUMN sms_resend_interval TO receiver_invitation_resend_interval_days; + +-- Rename organizations.sms_registration_message_template to organizations.receiver_registration_message_template +ALTER TABLE organizations + RENAME COLUMN sms_registration_message_template TO receiver_registration_message_template; + +-- Update the constraint name for resend interval +ALTER TABLE organizations + DROP CONSTRAINT organization_sms_resend_interval_valid_value_check; + +ALTER TABLE organizations + ADD CONSTRAINT organization_invitation_resend_interval_valid_value_check + CHECK ((receiver_invitation_resend_interval_days IS NOT NULL AND receiver_invitation_resend_interval_days > 0) OR receiver_invitation_resend_interval_days IS NULL); + +-- Rename disbursements.sms_registration_message_template to disbursements.receiver_registration_message_template +ALTER TABLE disbursements + RENAME COLUMN sms_registration_message_template TO receiver_registration_message_template; + + +-- +migrate Down +ALTER TABLE organizations + RENAME COLUMN receiver_invitation_resend_interval_days TO sms_resend_interval; + +ALTER TABLE organizations + RENAME COLUMN receiver_registration_message_template TO sms_registration_message_template; + +ALTER TABLE organizations + DROP CONSTRAINT organization_invitation_resend_interval_valid_value_check; + +ALTER TABLE organizations + ADD CONSTRAINT organization_sms_resend_interval_valid_value_check + CHECK ((sms_resend_interval IS NOT NULL AND sms_resend_interval > 0) OR sms_resend_interval IS NULL); + +ALTER TABLE disbursements + RENAME COLUMN receiver_registration_message_template TO sms_registration_message_template; diff --git a/db/migrations/sdp-migrations/2024-09-16.0-receiver-wallet-add-otp-confirmed-with-column.sql b/db/migrations/sdp-migrations/2024-09-16.0-receiver-wallet-add-otp-confirmed-with-column.sql new file mode 100644 index 000000000..775016cdf --- /dev/null +++ b/db/migrations/sdp-migrations/2024-09-16.0-receiver-wallet-add-otp-confirmed-with-column.sql @@ -0,0 +1,7 @@ +-- +migrate Up +ALTER TABLE receiver_wallets + ADD COLUMN otp_confirmed_with VARCHAR(256) NULL; + +-- +migrate Down +ALTER TABLE receiver_wallets + DROP COLUMN otp_confirmed_with; \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-10-18.0-update-default-value-of-otp-message-template.sql b/db/migrations/sdp-migrations/2024-10-18.0-update-default-value-of-otp-message-template.sql new file mode 100644 index 000000000..cce82edf3 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-10-18.0-update-default-value-of-otp-message-template.sql @@ -0,0 +1,17 @@ +-- +migrate Up + +ALTER TABLE organizations + ALTER COLUMN otp_message_template SET DEFAULT '{{.OTP}} is your {{.OrganizationName}} verification code.'; + +UPDATE organizations + SET otp_message_template = '{{.OTP}} is your {{.OrganizationName}} verification code.' + WHERE otp_message_template = '{{.OTP}} is your {{.OrganizationName}} phone verification code.'; + +-- +migrate Down + +ALTER TABLE organizations + ALTER COLUMN otp_message_template SET DEFAULT '{{.OTP}} is your {{.OrganizationName}} phone verification code.'; + +UPDATE organizations + SET otp_message_template = '{{.OTP}} is your {{.OrganizationName}} phone verification code.' + WHERE otp_message_template = '{{.OTP}} is your {{.OrganizationName}} verification code.'; diff --git a/db/migrations/sdp-migrations/2024-10-25.0-add-twilio-email.sql b/db/migrations/sdp-migrations/2024-10-25.0-add-twilio-email.sql new file mode 100644 index 000000000..8900e0481 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-10-25.0-add-twilio-email.sql @@ -0,0 +1,22 @@ +-- This is to add `TWILIO_EMAIL` to the `message_type` enum. + +-- +migrate Up +ALTER TYPE message_type ADD VALUE 'TWILIO_EMAIL'; + + +-- +migrate Down +CREATE TYPE temp_message_type AS ENUM ( + 'TWILIO_SMS', + 'AWS_SMS', + 'AWS_EMAIL', + 'DRY_RUN' + ); + +DELETE FROM messages WHERE type = 'TWILIO_EMAIL'; + +ALTER TABLE messages + ALTER COLUMN type TYPE temp_message_type USING type::text::temp_message_type; + +DROP TYPE message_type; + +ALTER TYPE temp_message_type RENAME TO message_type; \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-10-31.0-disbursement-add-registration-contact-type.sql b/db/migrations/sdp-migrations/2024-10-31.0-disbursement-add-registration-contact-type.sql new file mode 100644 index 000000000..533342650 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-10-31.0-disbursement-add-registration-contact-type.sql @@ -0,0 +1,26 @@ +-- This migration adds the receiver_contact_type enum type and the receiver_contact_type column to the disbursements table. + +-- +migrate Up +CREATE TYPE registration_contact_types AS ENUM ( + 'EMAIL', + 'EMAIL_AND_WALLET_ADDRESS', + 'PHONE_NUMBER', + 'PHONE_NUMBER_AND_WALLET_ADDRESS' +); + +ALTER TABLE disbursements + ADD COLUMN registration_contact_type registration_contact_types; + +UPDATE disbursements + SET registration_contact_type = 'PHONE_NUMBER' + WHERE registration_contact_type IS NULL; + +ALTER TABLE disbursements + ALTER COLUMN registration_contact_type SET NOT NULL; + + +-- +migrate Down +ALTER TABLE disbursements + DROP COLUMN registration_contact_type; + +DROP TYPE IF EXISTS registration_contact_types; diff --git a/db/migrations/sdp-migrations/2024-11-01.0-disbursement-drop-country.sql b/db/migrations/sdp-migrations/2024-11-01.0-disbursement-drop-country.sql new file mode 100644 index 000000000..9e6bf45e0 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-11-01.0-disbursement-drop-country.sql @@ -0,0 +1,236 @@ +-- This migration drops the countries table and any references to it. + +-- +migrate Up +ALTER TABLE disbursements + DROP COLUMN country_code; + +DROP TABLE countries CASCADE; + + +-- +migrate Down +CREATE TABLE countries ( + code VARCHAR(3) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + UNIQUE (name), + CONSTRAINT country_code_length_check CHECK (char_length(code) = 3) +); + +INSERT INTO countries + (code, name) +VALUES + ('AFG', 'Afghanistan'), + ('ALB', 'Albania'), + ('DZA', 'Algeria'), + ('ASM', 'American Samoa'), + ('AND', 'Andorra'), + ('AGO', 'Angola'), + ('ATG', 'Antigua and Barbuda'), + ('ARG', 'Argentina'), + ('ARM', 'Armenia'), + ('ABW', 'Aruba'), + ('AUS', 'Australia'), + ('AUT', 'Austria'), + ('AZE', 'Azerbaijan'), + ('BHS', 'Bahamas'), + ('BHR', 'Bahrain'), + ('BGD', 'Bangladesh'), + ('BRB', 'Barbados'), + ('BLR', 'Belarus'), + ('BEL', 'Belgium'), + ('BLZ', 'Belize'), + ('BEN', 'Benin'), + ('BMU', 'Bermuda'), + ('BTN', 'Bhutan'), + ('BOL', 'Bolivia'), + ('BIH', 'Bosnia and Herzegovina'), + ('BWA', 'Botswana'), + ('BRA', 'Brazil'), + ('BRN', 'Brunei'), + ('BGR', 'Bulgaria'), + ('BFA', 'Burkina Faso'), + ('BDI', 'Burundi'), + ('CPV', 'Cabo Verde'), + ('KHM', 'Cambodia'), + ('CMR', 'Cameroon'), + ('CAN', 'Canada'), + ('CAF', 'Central African Republic'), + ('TCD', 'Chad'), + ('CHL', 'Chile'), + ('CHN', 'China'), + ('COL', 'Colombia'), + ('COM', 'Comoros (the)'), + ('COG', 'Congo (the)'), + ('COK', 'Cook Islands (the)'), + ('CRI', 'Costa Rica'), + ('HRV', 'Croatia'), + ('CYP', 'Cyprus'), + ('CZE', 'Czechia'), + ('CIV', 'Côte d''Ivoire (Ivory Coast)'), + ('COD', 'Democratic Republic of the Congo'), + ('DNK', 'Denmark'), + ('DJI', 'Djibouti'), + ('DMA', 'Dominica'), + ('DOM', 'Dominican Republic'), + ('ECU', 'Ecuador'), + ('EGY', 'Egypt'), + ('SLV', 'El Salvador'), + ('GNQ', 'Equatorial Guinea'), + ('ERI', 'Eritrea'), + ('EST', 'Estonia'), + ('SWZ', 'Eswatini'), + ('ETH', 'Ethiopia'), + ('FJI', 'Fiji'), + ('FIN', 'Finland'), + ('FRA', 'France'), + ('GUF', 'French Guiana'), + ('PYF', 'French Polynesia'), + ('ATF', 'French Southern Territories (the)'), + ('GAB', 'Gabon'), + ('GMB', 'Gambia (the)'), + ('GEO', 'Georgia'), + ('DEU', 'Germany'), + ('GHA', 'Ghana'), + ('GRC', 'Greece'), + ('GRL', 'Greenland'), + ('GRD', 'Grenada'), + ('GUM', 'Guam'), + ('GTM', 'Guatemala'), + ('GIN', 'Guinea'), + ('GNB', 'Guinea-Bissau'), + ('GUY', 'Guyana'), + ('HTI', 'Haiti'), + ('HND', 'Honduras'), + ('HUN', 'Hungary'), + ('ISL', 'Iceland'), + ('IND', 'India'), + ('IDN', 'Indonesia'), + ('IRQ', 'Iraq'), + ('IRL', 'Ireland'), + ('ISR', 'Israel'), + ('ITA', 'Italy'), + ('JAM', 'Jamaica'), + ('JPN', 'Japan'), + ('JOR', 'Jordan'), + ('KAZ', 'Kazakhstan'), + ('KEN', 'Kenya'), + ('KIR', 'Kiribati'), + ('KOR', 'South Korea'), + ('KWT', 'Kuwait'), + ('KGZ', 'Kyrgyzstan'), + ('LAO', 'Laos'), + ('LVA', 'Latvia'), + ('LBN', 'Lebanon'), + ('LSO', 'Lesotho'), + ('LBR', 'Liberia'), + ('LBY', 'Libya'), + ('LIE', 'Liechtenstein'), + ('LTU', 'Lithuania'), + ('LUX', 'Luxembourg'), + ('MDG', 'Madagascar'), + ('MWI', 'Malawi'), + ('MYS', 'Malaysia'), + ('MDV', 'Maldives'), + ('MLI', 'Mali'), + ('MLT', 'Malta'), + ('MHL', 'Marshall Islands (the)'), + ('MTQ', 'Martinique'), + ('MRT', 'Mauritania'), + ('MUS', 'Mauritius'), + ('MEX', 'Mexico'), + ('FSM', 'Micronesia'), + ('MDA', 'Moldova'), + ('MCO', 'Monaco'), + ('MNG', 'Mongolia'), + ('MNE', 'Montenegro'), + ('MAR', 'Morocco'), + ('MOZ', 'Mozambique'), + ('MMR', 'Myanmar'), + ('NAM', 'Namibia'), + ('NRU', 'Nauru'), + ('NPL', 'Nepal'), + ('NLD', 'Netherlands (the)'), + ('NZL', 'New Zealand'), + ('NIC', 'Nicaragua'), + ('NER', 'Niger'), + ('NGA', 'Nigeria'), + ('MKD', 'North Macedonia (Republic of)'), + ('NOR', 'Norway'), + ('OMN', 'Oman'), + ('PAK', 'Pakistan'), + ('PLW', 'Palau'), + ('PAN', 'Panama'), + ('PNG', 'Papua New Guinea'), + ('PRY', 'Paraguay'), + ('PER', 'Peru'), + ('PHL', 'Philippines (the)'), + ('POL', 'Poland'), + ('PRT', 'Portugal'), + ('PRI', 'Puerto Rico'), + ('QAT', 'Qatar'), + ('ROU', 'Romania'), + ('RUS', 'Russia'), + ('RWA', 'Rwanda'), + ('REU', 'Réunion'), + ('BLM', 'Saint Barts'), + ('KNA', 'Saint Kitts and Nevis'), + ('LCA', 'Saint Lucia'), + ('MAF', 'Saint Martin'), + ('VCT', 'Saint Vincent and the Grenadines'), + ('WSM', 'Samoa'), + ('SMR', 'San Marino'), + ('STP', 'Sao Tome and Principe'), + ('SAU', 'Saudi Arabia'), + ('SEN', 'Senegal'), + ('SRB', 'Serbia'), + ('SYC', 'Seychelles'), + ('SLE', 'Sierra Leone'), + ('SGP', 'Singapore'), + ('SVK', 'Slovakia'), + ('SVN', 'Slovenia'), + ('SLB', 'Solomon Islands'), + ('SOM', 'Somalia'), + ('ZAF', 'South Africa'), + ('SSD', 'South Sudan'), + ('ESP', 'Spain'), + ('LKA', 'Sri Lanka'), + ('SDN', 'Sudan (the)'), + ('SUR', 'Suriname'), + ('SWE', 'Sweden'), + ('CHE', 'Switzerland'), + ('TWN', 'Taiwan'), + ('TJK', 'Tajikistan'), + ('TZA', 'Tanzania'), + ('THA', 'Thailand'), + ('TLS', 'Timor-Leste'), + ('TGO', 'Togo'), + ('TON', 'Tonga'), + ('TTO', 'Trinidad and Tobago'), + ('TUN', 'Tunisia'), + ('TUR', 'Turkey'), + ('TKM', 'Turkmenistan'), + ('TCA', 'Turks and Caicos Islands'), + ('TUV', 'Tuvalu'), + ('UGA', 'Uganda'), + ('UKR', 'Ukraine'), + ('ARE', 'United Arab Emirates'), + ('GBR', 'United Kingdom'), + ('UMI', 'United States Minor Outlying Islands'), + ('USA', 'United States of America'), + ('URY', 'Uruguay'), + ('UZB', 'Uzbekistan'), + ('VUT', 'Vanuatu'), + ('VEN', 'Venezuela'), + ('VNM', 'Vietnam'), + ('VGB', 'Virgin Islands (British)'), + ('VIR', 'Virgin Islands (U.S.)'), + ('YEM', 'Yemen'), + ('ZMB', 'Zambia'), + ('ZWE', 'Zimbabwe') +ON CONFLICT DO NOTHING; + +ALTER TABLE disbursements + ADD COLUMN country_code VARCHAR(3), + ADD CONSTRAINT fk_disbursement_country_code FOREIGN KEY (country_code) REFERENCES countries (code); diff --git a/db/migrations/sdp-migrations/2024-11-01.1-disbursement-make-verification-field-nullable.sql b/db/migrations/sdp-migrations/2024-11-01.1-disbursement-make-verification-field-nullable.sql new file mode 100644 index 000000000..fae89bea0 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-11-01.1-disbursement-make-verification-field-nullable.sql @@ -0,0 +1,7 @@ +-- This migration drops the verification_field NOT NULL constraint from the disbursements table. + +-- +migrate Up +ALTER TABLE disbursements + ALTER COLUMN verification_field DROP NOT NULL; + +-- +migrate Down diff --git a/db/migrations/sdp-migrations/2024-11-05-wallets-add-user-managed-column.sql b/db/migrations/sdp-migrations/2024-11-05-wallets-add-user-managed-column.sql new file mode 100644 index 000000000..7dd27840d --- /dev/null +++ b/db/migrations/sdp-migrations/2024-11-05-wallets-add-user-managed-column.sql @@ -0,0 +1,15 @@ +-- add a migration script that adds `user_managed` boolean column to wallets table with default value of false + +-- +migrate Up +ALTER TABLE wallets + ADD COLUMN user_managed BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE UNIQUE INDEX idx_unique_user_managed_wallet + ON Wallets (user_managed) + WHERE user_managed IS TRUE; + +-- +migrate Down +DROP INDEX idx_unique_user_managed_wallet; + +ALTER TABLE wallets + DROP COLUMN user_managed; \ No newline at end of file diff --git a/db/mocks/db_connection_pool.go b/db/mocks/db_connection_pool.go new file mode 100644 index 000000000..d83e153ac --- /dev/null +++ b/db/mocks/db_connection_pool.go @@ -0,0 +1,417 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + db "github.com/stellar/stellar-disbursement-platform-backend/db" + mock "github.com/stretchr/testify/mock" + + sql "database/sql" + + sqlx "github.com/jmoiron/sqlx" +) + +// MockDBConnectionPool is an autogenerated mock type for the DBConnectionPool type +type MockDBConnectionPool struct { + mock.Mock +} + +// BeginTxx provides a mock function with given fields: ctx, opts +func (_m *MockDBConnectionPool) BeginTxx(ctx context.Context, opts *sql.TxOptions) (db.DBTransaction, error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for BeginTxx") + } + + var r0 db.DBTransaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *sql.TxOptions) (db.DBTransaction, error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, *sql.TxOptions) db.DBTransaction); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(db.DBTransaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *sql.TxOptions) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Close provides a mock function with given fields: +func (_m *MockDBConnectionPool) Close() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DSN provides a mock function with given fields: ctx +func (_m *MockDBConnectionPool) DSN(ctx context.Context) (string, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for DSN") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DriverName provides a mock function with given fields: +func (_m *MockDBConnectionPool) DriverName() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for DriverName") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// ExecContext provides a mock function with given fields: ctx, query, args +func (_m *MockDBConnectionPool) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + var _ca []interface{} + _ca = append(_ca, ctx, query) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for ExecContext") + } + + var r0 sql.Result + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) (sql.Result, error)); ok { + return rf(ctx, query, args...) + } + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) sql.Result); ok { + r0 = rf(ctx, query, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(sql.Result) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, ...interface{}) error); ok { + r1 = rf(ctx, query, args...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetContext provides a mock function with given fields: ctx, dest, query, args +func (_m *MockDBConnectionPool) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { + var _ca []interface{} + _ca = append(_ca, ctx, dest, query) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetContext") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, interface{}, string, ...interface{}) error); ok { + r0 = rf(ctx, dest, query, args...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Ping provides a mock function with given fields: ctx +func (_m *MockDBConnectionPool) Ping(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Ping") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// PrepareContext provides a mock function with given fields: ctx, query +func (_m *MockDBConnectionPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for PrepareContext") + } + + var r0 *sql.Stmt + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*sql.Stmt, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *sql.Stmt); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sql.Stmt) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// QueryContext provides a mock function with given fields: ctx, query, args +func (_m *MockDBConnectionPool) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + var _ca []interface{} + _ca = append(_ca, ctx, query) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for QueryContext") + } + + var r0 *sql.Rows + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) (*sql.Rows, error)); ok { + return rf(ctx, query, args...) + } + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) *sql.Rows); ok { + r0 = rf(ctx, query, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sql.Rows) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, ...interface{}) error); ok { + r1 = rf(ctx, query, args...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// QueryRowxContext provides a mock function with given fields: ctx, query, args +func (_m *MockDBConnectionPool) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row { + var _ca []interface{} + _ca = append(_ca, ctx, query) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for QueryRowxContext") + } + + var r0 *sqlx.Row + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) *sqlx.Row); ok { + r0 = rf(ctx, query, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sqlx.Row) + } + } + + return r0 +} + +// QueryxContext provides a mock function with given fields: ctx, query, args +func (_m *MockDBConnectionPool) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { + var _ca []interface{} + _ca = append(_ca, ctx, query) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for QueryxContext") + } + + var r0 *sqlx.Rows + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) (*sqlx.Rows, error)); ok { + return rf(ctx, query, args...) + } + if rf, ok := ret.Get(0).(func(context.Context, string, ...interface{}) *sqlx.Rows); ok { + r0 = rf(ctx, query, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sqlx.Rows) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, ...interface{}) error); ok { + r1 = rf(ctx, query, args...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Rebind provides a mock function with given fields: query +func (_m *MockDBConnectionPool) Rebind(query string) string { + ret := _m.Called(query) + + if len(ret) == 0 { + panic("no return value specified for Rebind") + } + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(query) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SelectContext provides a mock function with given fields: ctx, dest, query, args +func (_m *MockDBConnectionPool) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { + var _ca []interface{} + _ca = append(_ca, ctx, dest, query) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SelectContext") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, interface{}, string, ...interface{}) error); ok { + r0 = rf(ctx, dest, query, args...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SqlDB provides a mock function with given fields: ctx +func (_m *MockDBConnectionPool) SqlDB(ctx context.Context) (*sql.DB, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for SqlDB") + } + + var r0 *sql.DB + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*sql.DB, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *sql.DB); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sql.DB) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SqlxDB provides a mock function with given fields: ctx +func (_m *MockDBConnectionPool) SqlxDB(ctx context.Context) (*sqlx.DB, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for SqlxDB") + } + + var r0 *sqlx.DB + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*sqlx.DB, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *sqlx.DB); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sqlx.DB) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockDBConnectionPool creates a new instance of MockDBConnectionPool. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDBConnectionPool(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDBConnectionPool { + mock := &MockDBConnectionPool{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/dev/README.md b/dev/README.md index eca0359b4..f100ac3b6 100644 --- a/dev/README.md +++ b/dev/README.md @@ -20,6 +20,7 @@ - [Ensure Docker Containers are Running:](#ensure-docker-containers-are-running) - [Using VS Code:](#using-vs-code) - [Using IntelliJ GoLang:](#using-intellij-golang) + - [Monitoring the SDP](#monitoring-the-sdp) - [Troubleshooting](#troubleshooting) - [Sample Tenant Management Postman collection](#sample-tenant-management-postman-collection) - [Distribution account out of funds](#distribution-account-out-of-funds) @@ -30,9 +31,15 @@ Follow these instructions to get started with the Stellar Disbursement Platform ## Quick Setup and Deployment -### Docker +### Pre-requisites -Make sure you have Docker installed on your system. If not, you can download it from [here](https://www.docker.com/products/docker-desktop). +* **Docker:** Make sure you have Docker installed on your system. If not, you can download it from [here](https://www.docker.com/products/docker-desktop). +* **Git:** You will need Git to clone the repository. You can download it from [here](https://git-scm.com/downloads). +* **Go:** If you want to use the `make_env.sh` script to create Stellar accounts and a `.env` file, you will need to have Go installed on your system. You can download it from [here](https://golang.org/dl/). +* **jq:** If you want to use the `main.sh` script to bring up the local environment, you will need to have `jq` installed. You can install it using Homebrew: +```sh +brew install jq +``` ### Clone the repository: @@ -286,6 +293,32 @@ Make sure the Docker containers are up and running by executing the `main.sh` sc The debugger should now attach to the running Docker container, and you should be able to hit breakpoints and debug your code. +### Monitoring the SDP + +The SDP supports monitoring via Prometheus and Grafana. + +#### Start Prometheus and Grafana containers + +The containers can be started by running the following command from the `dev` directory: + +```sh +docker compose -p sdp-multi-tenant -f docker-compose-monitoring.yml up -d +``` + +This will start the following services: +* `prometheus`: Prometheus service running on port `9090`. +* `grafana`: Grafana service running on port `3002`. + +#### Load the SDP Grafana Dashboard + +1. Access the Grafana dashboard by opening a browser and going to [http://localhost:3002](http://localhost:3002). +2. Log in with the default credentials: + - Username: `admin` + - Password: `admin` +3. Click on the `+` icon on the left sidebar and select `Import Dashboard`. +4. Copy the contents of the [dashboard.json](../resources/grafana/dashboard.json) file and paste it into the `Import via dashboard JSON model` text box. + + ## Troubleshooting #### Sample Tenant Management Postman collection diff --git a/dev/docker-compose-monitoring.yml b/dev/docker-compose-monitoring.yml new file mode 100644 index 000000000..a7cd22cd6 --- /dev/null +++ b/dev/docker-compose-monitoring.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus:/etc/prometheus/ + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:latest + ports: + - "3002:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-storage:/var/lib/grafana + - ./grafana/datasource.yaml:/etc/grafana/provisioning/datasources/datasource.yaml + +volumes: + grafana-storage: \ No newline at end of file diff --git a/dev/docker-compose-sdp-anchor.yml b/dev/docker-compose-sdp-anchor.yml index dc5ac650a..cad58b92f 100644 --- a/dev/docker-compose-sdp-anchor.yml +++ b/dev/docker-compose-sdp-anchor.yml @@ -51,12 +51,12 @@ services: SINGLE_TENANT_MODE: "false" # scheduler options - #ENABLE_SCHEDULER: "false" # set to disabled to use kafka for this. - #SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS: "10" - #SCHEDULER_PAYMENT_JOB_SECONDS: "10" + ENABLE_SCHEDULER: "true" # set to disabled to use kafka for this. + SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS: "10" + SCHEDULER_PAYMENT_JOB_SECONDS: "10" # Kafka Configuration - only used if ENABLE_SCHEDULER is set to "false" - EVENT_BROKER_TYPE: "KAFKA" + EVENT_BROKER_TYPE: "NONE" BROKER_URLS: "kafka:9092" CONSUMER_GROUP_ID: "group-id" KAFKA_SECURITY_PROTOCOL: "PLAINTEXT" @@ -75,12 +75,28 @@ services: TWILIO_ACCOUNT_SID: MY_TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN: MY_TWILIO_AUTH_TOKEN TWILIO_SERVICE_SID: MY_TWILIO_SERVICE_SID + TWILIO_SENDGRID_API_KEY: MY_TWILIO_SENDGRID_API_KEY + TWILIO_SENDGRID_SENDER_ADDRESS: MY_TWILIO_SENDGRID_SENDER_ADDRESS EC256_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdo6o+tdFkF94B7z8\nnoybH6/zO3PryLLjLbj54/zOi4WhRANCAAQncc2mE8AQoe+1GOyXkqPBz21MypLa\nmZg3JusuzFnpy5C+DbKIShdmLE/ZwnvtywcKVcLpxvXBCn8E0YO8Yqg+\n-----END PRIVATE KEY-----" SEP10_SIGNING_PRIVATE_KEY: ${SEP10_SIGNING_PRIVATE_KEY} SEP24_JWT_SECRET: jwt_secret_1234567890 RECAPTCHA_SITE_SECRET_KEY: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe ANCHOR_PLATFORM_OUTGOING_JWT_SECRET: mySdpToAnchorPlatformSecret - entrypoint: "./dev/scripts/debug_entrypoint.sh" + entrypoint: "" + command: + - sh + - -c + - | + sleep 5 + ./stellar-disbursement-platform db admin migrate up + ./stellar-disbursement-platform db tss migrate up + ./stellar-disbursement-platform db auth migrate up --all + ./stellar-disbursement-platform db sdp migrate up --all + ./stellar-disbursement-platform db setup-for-network --all + echo "starting dlv stellar-disbursement-platform" + /go/bin/dlv exec ./stellar-disbursement-platform serve --continue --accept-multiclient --headless --listen=:2345 --api-version=2 --log + volumes: + - ./scripts/add_test_users.sh:/app/github.com/stellar/stellar-disbursement-platform/dev/scripts/add_test_users.sh db-anchor-platform: container_name: anchor-platform-postgres-db-mtn @@ -118,7 +134,7 @@ services: DATA_DATABASE: postgres DATA_FLYWAY_ENABLED: "true" DATA_DDL_AUTO: update - METRICS_ENABLED: "false" # Metrics would be available at port 8082 + METRICS_ENABLED: "true" # Metrics would be available at port 8082 METRICS_EXTRAS_ENABLED: "false" SEP10_ENABLED: "true" SEP10_HOME_DOMAINS: "localhost:8000, *.stellar.local:8000" # Comma separated list of home domains diff --git a/dev/env-config.js b/dev/env-config.js index 6b1a8dcbc..f74fc26ab 100644 --- a/dev/env-config.js +++ b/dev/env-config.js @@ -3,5 +3,6 @@ window._env_ = { STELLAR_EXPERT_URL: "https://stellar.expert/explorer/testnet", HORIZON_URL: "https://horizon-testnet.stellar.org", USDC_ASSET_ISSUER: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - RECAPTCHA_SITE_KEY: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" + RECAPTCHA_SITE_KEY: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", + SINGLE_TENANT_MODE: false }; diff --git a/dev/grafana/datasource.yaml b/dev/grafana/datasource.yaml new file mode 100644 index 000000000..9c1d44c4e --- /dev/null +++ b/dev/grafana/datasource.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: prometheus + type: prometheus + access: proxy + url: http://host.docker.internal:9090 + isDefault: true + editable: true \ No newline at end of file diff --git a/dev/main.sh b/dev/main.sh index ac2dd5781..7ae2358cc 100755 --- a/dev/main.sh +++ b/dev/main.sh @@ -25,10 +25,18 @@ if [ ! -f ./.env ]; then exit 1 fi -# Check if curl is installed -if ! command -v curl &> /dev/null -then - echo "Error: curl is not installed. Please install curl to continue." +declare -a required_tools=( docker curl jq ) +declare failed=0 + +for tool in ${required_tools[@]}; do + if ! command -v $tool &> /dev/null + then + echo "Error: $tool is not installed. Please install $tool to continue." + failed=1 + fi +done + +if [[ $failed != 0 ]]; then exit 1 fi @@ -153,4 +161,9 @@ for tenant in "${tenants[@]}"; do url="http://$tenant.stellar.local:3000" echo -e "🔗Tenant $tenant: \033]8;;$url\033\\$url\033]8;;\033\\" echo "username: owner@$tenant.local password: Password123!" + + if ! grep -q $tenant /etc/hosts; then + echo >&2 "WARN $tenant.stellar.local missing from /etc/hosts" + fi done + diff --git a/dev/migrate_1.1.6_to_2.0.0.sh b/dev/migrate_1.1.6_to_2.0.0.sh deleted file mode 100755 index add53a313..000000000 --- a/dev/migrate_1.1.6_to_2.0.0.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/bin/bash -# This script is used to migrate the database from the single-tenant structure to the multi-tenant structure. -# ATTENTION: make sure to update the variables below before running this script. Refer to the sections marked with 👋. -set -eu - -# Check if curl is installed -if ! command -v curl &> /dev/null -then - echo "Error: curl is not installed. Please install curl to continue." - exit 1 -fi - -# 👋 Remember to update the variables below before running this script. -singleTenantDBURL="postgres://localhost:5432/sdp-116?sslmode=disable" -multiTenantDBURL="postgres://localhost:5432/sdp-116-mtn?sslmode=disable" -multiTenantDBName="sdp-116-mtn" -psqlDumpOutput="sdp-mainBranch-v1_16.sql" - -perform_step() { - export DIVIDER="--------------------------------------------------------------------------------" - - step=$1 - step_name="$2" - command="$3" - pause_message="${4:-}" - - echo - echo $DIVIDER - printf "STEP %s ⌛: %s\n" "$step" "$step_name" - eval "$command" - printf "STEP %s ✅: %s\n" "$step" "$step_name" - echo $DIVIDER - - if [ -n "$pause_message" ]; then - read -p "$pause_message" - fi -} - -stepCounter=1 - -perform_step $((stepCounter++)) "deleting pre-existing single-tenant dump file" "rm -f $psqlDumpOutput" "In the next step, we will delete and recreate the MTN database. Click enter to continue." - -perform_step $((stepCounter++)) "deleting and recreating the multi-tenant database" "dropdb $multiTenantDBName && createdb $multiTenantDBName" "In the next step, we will be creating a new dump from the single-tenant DB. Click enter to continue." - -perform_step $((stepCounter++)) "dumping single-tenant database" "pg_dump $singleTenantDBURL > $psqlDumpOutput" "In the next step, we will be restoring the single-tenant dump in the multi-tenant database. Click enter to continue." - -perform_step $((stepCounter++)) "restoring single-tenant dump into multi-tenant database" "psql -d $multiTenantDBURL < $psqlDumpOutput" "In the next step, we will be running the TSS and Admin migrations. Click enter to continue." - -perform_step $((stepCounter++)) "running tss and admin migrations" "go run main.go db admin migrate up && go run main.go db tss migrate up" - - -read -p "🚨 ATTENTION: for the next step, you'll need the admin server to be up and running under http://localhost:8003. Make sure to run 'go run main.go serve', and then hit Enter to continue" - - -# 👋 Remember to update the variables below before running this script. -tenant="bluecorp" -create_tenant() { - ADMIN_ACCOUNT="SDP-admin" - ADMIN_API_KEY="api_key_1234567890" - basicAuthCredentials=$(echo -n "$ADMIN_ACCOUNT:$ADMIN_API_KEY" | base64) - AuthHeader="Authorization: Basic $basicAuthCredentials" - - baseURL="http://$tenant.stellar.local:8000" - sdpUIBaseURL="http://$tenant.stellar.local:3000" - ownerEmail="owner@$tenant.org" - - curl -X POST http://localhost:8003/tenants \ - -H "Content-Type: application/json" \ - -H "$AuthHeader" \ - -d '{ - "name": "'"$tenant"'", - "organization_name": "Blue Corp", - "base_url": "'"$baseURL"'", - "sdp_ui_base_url": "'"$sdpUIBaseURL"'", - "owner_email": "'"$ownerEmail"'", - "owner_first_name": "john", - "owner_last_name": "doe" - }' - echo -} - -perform_step $((stepCounter++)) "provisioning tenant $tenant" "create_tenant" "Your tenant was successfully created! Hit enter to copy the single-tenant data to the multi-tenant structure, and dump the single-tenant structure." - -sql_script=$(cat < github.com/gin-gonic/gin v1.9.1 github.com/go-chi/chi v4.1.2+incompatible -github.com/go-chi/chi/v5 v5.0.10 -github.com/go-chi/httprate v0.8.0 +github.com/go-chi/chi/v5 v5.1.0 +github.com/go-chi/httprate v0.14.1 github.com/go-errors/errors v1.5.1 github.com/go-gorp/gorp/v3 v3.1.0 github.com/go-kit/log v0.2.1 -github.com/go-logfmt/logfmt v0.5.1 -github.com/go-logr/logr v1.4.1 +github.com/go-logfmt/logfmt v0.6.0 +github.com/go-logr/logr v1.4.2 github.com/go-logr/stdr v1.2.2 -github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab github.com/go-playground/locales v0.14.0 github.com/go-playground/universal-translator v0.18.0 github.com/go-playground/validator/v10 v10.11.1 -github.com/go-sql-driver/mysql v1.6.0 -github.com/gobuffalo/logger v1.0.6 +github.com/go-sql-driver/mysql v1.8.1 github.com/gobuffalo/packd v1.0.2 -github.com/gobuffalo/packr/v2 v2.8.3 github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d github.com/goccy/go-json v0.9.11 -github.com/godror/godror v0.24.2 +github.com/godror/godror v0.40.4 +github.com/godror/knownpb v0.1.1 github.com/gofiber/fiber/v2 v2.52.2 => github.com/gofiber/fiber/v2 v2.52.5 github.com/gogo/protobuf v1.3.2 github.com/golang-jwt/jwt v3.2.2+incompatible -github.com/golang-jwt/jwt/v4 v4.5.0 +github.com/golang-jwt/jwt/v4 v4.5.1 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da github.com/golang/mock v1.6.0 @@ -102,7 +105,7 @@ github.com/google/renameio/v2 v2.0.0 github.com/google/s2a-go v0.1.7 github.com/google/uuid v1.6.0 github.com/googleapis/enterprise-certificate-proxy v0.3.2 -github.com/googleapis/gax-go/v2 v2.12.3 +github.com/googleapis/gax-go/v2 v2.12.4 github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 github.com/gorilla/css v1.0.0 github.com/gorilla/handlers v1.5.2 @@ -132,24 +135,24 @@ github.com/iris-contrib/schema v0.0.6 github.com/jarcoal/httpmock v0.0.0-20161210151336-4442edb3db31 github.com/jmespath/go-jmespath v0.4.0 github.com/jmespath/go-jmespath/internal/testify v1.5.1 -github.com/jmoiron/sqlx v1.3.5 +github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/josharian/intern v1.0.0 github.com/jpillora/backoff v1.0.0 github.com/json-iterator/go v1.1.12 github.com/julienschmidt/httprouter v1.3.0 -github.com/karrick/godirwalk v1.16.1 github.com/kataras/blocks v0.0.7 github.com/kataras/golog v0.1.8 github.com/kataras/iris/v12 v12.2.0 github.com/kataras/pio v0.0.11 github.com/kataras/sitemap v0.0.6 github.com/kataras/tunnel v0.0.4 -github.com/klauspost/compress v1.17.7 +github.com/klauspost/compress v1.17.9 github.com/kr/fs v0.1.0 github.com/kr/pretty v0.3.1 github.com/kr/pty v1.1.1 github.com/kr/text v0.2.0 +github.com/kylelemons/godebug v1.1.0 github.com/labstack/echo/v4 v4.10.0 github.com/labstack/gommon v0.4.0 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 @@ -162,14 +165,11 @@ github.com/mailgun/raymond/v2 v2.0.48 github.com/mailru/easyjson v0.7.7 github.com/manifoldco/promptui v0.9.0 github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 -github.com/markbates/errx v1.1.0 -github.com/markbates/oncer v1.0.0 -github.com/markbates/safe v1.0.1 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-oci8 v0.1.1 github.com/mattn/go-runewidth v0.0.15 -github.com/mattn/go-sqlite3 v1.14.15 +github.com/mattn/go-sqlite3 v1.14.22 github.com/matttproud/golang_protobuf_extensions v1.0.4 github.com/microcosm-cc/bluemonday v1.0.23 github.com/mitchellh/cli v1.1.5 @@ -178,9 +178,13 @@ github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/reflectwalk v1.0.2 +github.com/moby/docker-image-spec v1.3.1 +github.com/moby/term v0.5.0 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd github.com/modern-go/reflect2 v1.0.2 +github.com/morikuni/aec v1.0.0 github.com/moul/http2curl v0.0.0-20161031194548-4e24498b31db +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f github.com/nats-io/nats.go v1.34.0 github.com/nats-io/nkeys v0.4.7 @@ -188,10 +192,12 @@ github.com/nats-io/nuid v1.0.1 github.com/nelsam/hel/v2 v2.3.3 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e github.com/nxadm/tail v1.4.11 -github.com/nyaruka/phonenumbers v1.1.8 +github.com/nyaruka/phonenumbers v1.4.2 github.com/olekukonko/tablewriter v0.0.5 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.27.10 +github.com/opencontainers/go-digest v1.0.0 +github.com/opencontainers/image-spec v1.1.0 github.com/opentracing/opentracing-go v1.1.0 github.com/pelletier/go-toml v1.9.5 github.com/pelletier/go-toml/v2 v2.2.2 @@ -203,14 +209,14 @@ github.com/pkg/xattr v0.4.9 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/posener/complete v1.2.3 github.com/poy/onpar v1.1.2 -github.com/prometheus/client_golang v1.17.0 -github.com/prometheus/client_model v0.5.0 -github.com/prometheus/common v0.44.0 -github.com/prometheus/procfs v0.12.0 +github.com/prometheus/client_golang v1.20.5 +github.com/prometheus/client_model v0.6.1 +github.com/prometheus/common v0.55.0 +github.com/prometheus/procfs v0.15.1 github.com/rivo/uniseg v0.2.0 github.com/rogpeppe/go-internal v1.11.0 -github.com/rs/cors v1.11.0 -github.com/rubenv/sql-migrate v1.5.2 +github.com/rs/cors v1.11.1 +github.com/rubenv/sql-migrate v1.7.0 github.com/russross/blackfriday/v2 v2.1.0 github.com/sagikazarmark/crypt v0.19.0 github.com/sagikazarmark/locafero v0.4.0 @@ -218,7 +224,9 @@ github.com/sagikazarmark/slog-shim v0.1.0 github.com/sanity-io/litter v1.5.5 github.com/schollz/closestmatch v2.1.0+incompatible github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 -github.com/segmentio/kafka-go v0.4.46 +github.com/segmentio/kafka-go v0.4.47 +github.com/sendgrid/rest v2.6.9+incompatible +github.com/sendgrid/sendgrid-go v3.16.0+incompatible github.com/sergi/go-diff v1.2.0 github.com/shopspring/decimal v1.3.1 github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c @@ -226,10 +234,10 @@ github.com/sirupsen/logrus v1.9.3 github.com/sourcegraph/conc v0.3.0 github.com/spf13/afero v1.11.0 github.com/spf13/cast v1.6.0 -github.com/spf13/cobra v1.7.0 +github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 -github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043 +github.com/stellar/go v0.0.0-20241115082344-969db9917c2d github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 github.com/stellar/throttled v2.2.3-0.20190823235211-89d75816f59d+incompatible github.com/stretchr/objx v0.5.2 @@ -237,10 +245,10 @@ github.com/stretchr/testify v1.9.0 github.com/subosito/gotenv v1.6.0 github.com/tdewolff/minify/v2 v2.12.4 github.com/tdewolff/parse/v2 v2.6.4 -github.com/twilio/twilio-go v1.11.0 +github.com/twilio/twilio-go v1.23.6 github.com/tyler-smith/go-bip39 v0.0.0-20180618194314-52158e4697b8 github.com/ugorji/go/codec v1.2.7 -github.com/urfave/negroni v1.0.0 +github.com/urfave/negroni/v3 v3.1.1 github.com/valyala/bytebufferpool v1.0.0 github.com/valyala/fasthttp v1.52.0 github.com/valyala/fasttemplate v1.2.2 @@ -268,31 +276,33 @@ go.etcd.io/etcd/client/v3 v3.5.12 go.opencensus.io v0.24.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 -go.opentelemetry.io/otel v1.24.0 -go.opentelemetry.io/otel/metric v1.24.0 -go.opentelemetry.io/otel/trace v1.24.0 +go.opentelemetry.io/otel v1.28.0 +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 +go.opentelemetry.io/otel/metric v1.28.0 +go.opentelemetry.io/otel/sdk v1.28.0 +go.opentelemetry.io/otel/trace v1.28.0 go.uber.org/atomic v1.9.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.21.0 -golang.org/x/crypto v0.22.0 -golang.org/x/exp v0.0.0-20231006140011-7918f672742d -golang.org/x/mod v0.13.0 -golang.org/x/net v0.24.0 -golang.org/x/oauth2 v0.20.0 -golang.org/x/sync v0.7.0 -golang.org/x/sys v0.19.0 -golang.org/x/term v0.19.0 -golang.org/x/text v0.14.0 +golang.org/x/crypto v0.29.0 +golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d +golang.org/x/mod v0.17.0 +golang.org/x/net v0.30.0 +golang.org/x/oauth2 v0.21.0 +golang.org/x/sync v0.9.0 +golang.org/x/sys v0.27.0 +golang.org/x/term v0.26.0 +golang.org/x/text v0.20.0 golang.org/x/time v0.5.0 -golang.org/x/tools v0.14.0 +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 -google.golang.org/api v0.177.0 +google.golang.org/api v0.183.0 google.golang.org/appengine v1.6.8 -google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda -google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6 -google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 -google.golang.org/grpc v1.63.2 -google.golang.org/protobuf v1.34.1 +google.golang.org/genproto v0.0.0-20240528184218-531527333157 +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 +google.golang.org/grpc v1.64.1 +google.golang.org/protobuf v1.34.2 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gopkg.in/djherbis/atime.v1 v1.0.0 gopkg.in/djherbis/stream.v1 v1.3.1 @@ -303,4 +313,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 gopkg.in/tylerb/graceful.v1 v1.2.15 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 +gotest.tools/v3 v3.5.1 moul.io/http2curl/v2 v2.3.0 diff --git a/go.mod b/go.mod index f1fc6478d..e448d2842 100644 --- a/go.mod +++ b/go.mod @@ -5,60 +5,61 @@ go 1.22.1 require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/avast/retry-go v3.0.0+incompatible - github.com/aws/aws-sdk-go v1.45.26 - github.com/getsentry/sentry-go v0.28.1 + github.com/aws/aws-sdk-go v1.55.5 + github.com/getsentry/sentry-go v0.29.1 github.com/go-chi/chi v4.1.2+incompatible - github.com/go-chi/chi/v5 v5.0.10 - github.com/go-chi/httprate v0.8.0 + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/httprate v0.14.1 github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d - github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/golang-jwt/jwt/v4 v4.5.1 github.com/google/uuid v1.6.0 github.com/gorilla/schema v1.4.1 - github.com/jmoiron/sqlx v1.3.5 + github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/manifoldco/promptui v0.9.0 - github.com/nyaruka/phonenumbers v1.1.8 - github.com/prometheus/client_golang v1.17.0 - github.com/rs/cors v1.11.0 - github.com/rubenv/sql-migrate v1.5.2 - github.com/segmentio/kafka-go v0.4.46 + github.com/nyaruka/phonenumbers v1.4.2 + github.com/prometheus/client_golang v1.20.5 + github.com/rs/cors v1.11.1 + github.com/rubenv/sql-migrate v1.7.0 + github.com/segmentio/kafka-go v0.4.47 + github.com/sendgrid/rest v2.6.9+incompatible + github.com/sendgrid/sendgrid-go v3.16.0+incompatible github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.7.0 + github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 - github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043 + github.com/stellar/go v0.0.0-20241115082344-969db9917c2d github.com/stretchr/testify v1.9.0 - github.com/twilio/twilio-go v1.11.0 - golang.org/x/crypto v0.22.0 - golang.org/x/exp v0.0.0-20231006140011-7918f672742d + github.com/twilio/twilio-go v1.23.6 + golang.org/x/crypto v0.29.0 + golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d ) require ( github.com/BurntSushi/toml v1.3.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/golang/mock v1.6.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/klauspost/compress v1.17.7 // indirect + github.com/klauspost/compress v1.17.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.44.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 // indirect @@ -72,9 +73,10 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/protobuf v1.34.1 // 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 + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/tylerb/graceful.v1 v1.2.15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 3a64e19f0..aad52ed96 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= @@ -8,13 +10,13 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/aws/aws-sdk-go v1.45.26 h1:PJ2NJNY5N/yeobLYe1Y+xLdavBi67ZI8gvph6ftwVCg= -github.com/aws/aws-sdk-go v1.45.26/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= +github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -24,7 +26,7 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,36 +40,27 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955 h1:gmtGRvSexPU4B1T/yYo0sLOKzER1YT+b4kPxPpm0Ty4= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA= -github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= -github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/getsentry/sentry-go v0.29.1 h1:DyZuChN8Hz3ARxGVV8ePaNXh1dQ7d76AiB117xcREwA= +github.com/getsentry/sentry-go v0.29.1/go.mod h1:x3AtIzN01d6SiWkderzaH28Tm0lgkafpJ5Bm3li39O0= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= -github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/httprate v0.8.0 h1:CyKng28yhGnlGXH9EDGC/Qizj29afJQSNW15W/yj34o= -github.com/go-chi/httprate v0.8.0/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= +github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU= -github.com/gobuffalo/logger v1.0.6/go.mod h1:J31TBEHR1QLV2683OXTAItYIg8pv2JMHnF/quuAbMjs= -github.com/gobuffalo/packd v1.0.2 h1:Yg523YqnOxGIWCp69W12yYBKsoChwI7mtu6ceM9Bwfw= -github.com/gobuffalo/packd v1.0.2/go.mod h1:sUc61tDqGMXON80zpKGp92lDb86Km28jfvX7IAyxFT8= -github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY= -github.com/gobuffalo/packr/v2 v2.8.3/go.mod h1:0SahksCVcx4IMnigTjiFuyldmTrdTctXsOdiU5KwbKc= +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/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d h1:KbPOUXFUDJxwZ04vbmDOc3yuruGvVO+LOa7cVER3yWw= github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -88,15 +81,13 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= -github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= -github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -104,7 +95,8 @@ 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/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= @@ -115,26 +107,19 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 h1:ykXz+pRRTibcSjG1yRhpdSHInF8yZY/mfn+Rz2Nd1rE= github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739/go.mod h1:zUx1mhth20V3VKgL5jbd1BSQcW4Fy6Qs4PZvQwRFwzM= -github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= -github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= -github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= -github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI= -github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= -github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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/moul/http2curl v0.0.0-20161031194548-4e24498b31db h1:eZgFHVkk9uOTaOQLC6tgjkzdp7Ays8eEVecBcfHZlJQ= github.com/moul/http2curl v0.0.0-20161031194548-4e24498b31db/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= -github.com/nyaruka/phonenumbers v1.1.8 h1:mjFu85FeoH2Wy18aOMUvxqi1GgAqiQSJsa/cCC5yu2s= -github.com/nyaruka/phonenumbers v1.1.8/go.mod h1:DC7jZd321FqUe+qWSNcHi10tyIyGNXGcNbfkPvdp1Vs= +github.com/nyaruka/phonenumbers v1.4.2 h1:V791/B74Sb5i/X7Od2AVKA0BkvcNaInf7DWykPS2YSU= +github.com/nyaruka/phonenumbers v1.4.2/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= @@ -153,20 +138,20 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= -github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= -github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= -github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= -github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0= -github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rubenv/sql-migrate v1.7.0 h1:HtQq1xyTN2ISmQDggnh0c9U3JlP8apWh8YO2jzlXpTI= +github.com/rubenv/sql-migrate v1.7.0/go.mod h1:S4wtDEG1CKn+0ShpTtzWhFpHHI5PvCUtiGI+C+Z2THE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -174,8 +159,12 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 h1:S4OC0+OBKz6mJnzuHioeEat74PuQ4Sgvbf8eus695sc= github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2/go.mod h1:8zLRYR5npGjaOXgPSKat5+oOh+UHd8OdbS18iqX9F6Y= -github.com/segmentio/kafka-go v0.4.46 h1:Sx8/kvtY+/G8nM0roTNnFezSJj3bT2sW0Xy/YY3CgBI= -github.com/segmentio/kafka-go v0.4.46/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= +github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= +github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -186,14 +175,14 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +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/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043 h1:5UQzsvt9VtD3ijpzPtdW0/lXWCNgDs6GzmLUE8ZuWfk= -github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043/go.mod h1:TuXKLL7WViqwrvpWno2I4UYGn2Ny9KZld1jUIN6fnK8= +github.com/stellar/go v0.0.0-20241115082344-969db9917c2d h1:8cS83V7vUxJPsAdYXtKlJe5qwiCWJecvLDcwNLzXzJI= +github.com/stellar/go v0.0.0-20241115082344-969db9917c2d/go.mod h1:2jxuLI6d8tmmeauAgFYApGrBen5x/FlRfCdatzgRJ7s= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -210,8 +199,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/twilio/twilio-go v1.11.0 h1:ixO2DfAV4c0Yza0Tom5F5ZZB8WUbigiFc9wD84vbYnc= -github.com/twilio/twilio-go v1.11.0/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU= +github.com/twilio/twilio-go v1.23.6 h1:9gjIZ8w3MN+8ifPZgK74vF3CLfnJ6ytMNqOI2r2ipLs= +github.com/twilio/twilio-go v1.23.6/go.mod h1:zRkMjudW7v7MqQ3cWNZmSoZJ7EBjPZ4OpNh2zm7Q6ko= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= @@ -244,10 +233,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= +golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -256,17 +245,17 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-20181122145206-62eef0e2fa9b/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -278,30 +267,25 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.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= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -310,8 +294,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 5b945f8bb..8e5b5e977 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) -version: "2.1.1" -appVersion: "2.1.1" +version: "3.0.0" +appVersion: "3.0.0" type: application maintainers: - name: Stellar Development Foundation diff --git a/helmchart/sdp/README.md b/helmchart/sdp/README.md index 85e57c306..2f1d22888 100644 --- a/helmchart/sdp/README.md +++ b/helmchart/sdp/README.md @@ -85,6 +85,7 @@ These parameters are shared by all charts. | `global.eventBroker.consumerGroupId` | The consumer group ID for the event broker. | `nil` | | `global.eventBroker.kafka` | Configuration related to the Kafka event broker. | | | `global.eventBroker.kafka.securityProtocol` | The security protocol to be used for the Kafka broker. Options: "PLAINTEXT", "SASL_SSL", "SASL_PLAINTEXT", "SSL". | `nil` | +| `global.singleTenantMode` | Determines if the SDP service is running in single-tenant mode. | `false` | ### Stellar Disbursement Platform (SDP) parameters @@ -105,7 +106,7 @@ Configuration parameters for the SDP Core Service which is the core backend serv | `sdp.image` | Configuration related to the Docker image used by the SDP service. | | | `sdp.image.repository` | Docker image repository for the SDP backend service. | `stellar/stellar-disbursement-platform-backend` | | `sdp.image.pullPolicy` | Image pull policy for the SDP service. For locally built images, consider using "Never" or "IfNotPresent". | `Always` | -| `sdp.image.tag` | Docker image tag for the SDP service. If set, this overrides the default value from `.Chart.AppVersion`. | `latest` | +| `sdp.image.tag` | Docker image tag for the SDP service. If set, this overrides the default value from `.Chart.AppVersion`. | `3.0.0-rc.1` | | `sdp.deployment` | Configuration related to the deployment of the SDP service. | | | `sdp.deployment.annotations` | Annotations to be added to the deployment. | `nil` | | `sdp.deployment.podAnnotations` | Annotations specific to the pods. | `{}` | @@ -125,7 +126,7 @@ Configuration parameters for the SDP Core Service which is the core backend serv | `sdp.configMap.data.SEP10_SIGNING_PUBLIC_KEY` | Anchor platform SEP10 signing public key. | `nil` | | `sdp.configMap.data.DISTRIBUTION_PUBLIC_KEY` | The public key of the HOST's Stellar distribution account, used to create channel accounts. | `nil` | | `sdp.configMap.data.METRICS_TYPE` | Defines the type of metrics system in use. Options: "PROMETHEUS". | `PROMETHEUS` | -| `sdp.configMap.data.EMAIL_SENDER_TYPE` | The messenger type used to send invitations to new dashboard users. Options: "DRY_RUN", "AWS_EMAIL". | `DRY_RUN` | +| `sdp.configMap.data.EMAIL_SENDER_TYPE` | The messenger type used to send invitations to new dashboard users. Options: "DRY_RUN", "AWS_EMAIL", "TWILIO_EMAIL". | `DRY_RUN` | | `sdp.configMap.data.SMS_SENDER_TYPE` | The messenger type used to send text messages to recipients. Options: "DRY_RUN", "TWILIO_SMS". | `DRY_RUN` | | `sdp.configMap.data.RECAPTCHA_SITE_KEY` | Site key for ReCaptcha. Required if using ReCaptcha. | `nil` | | `sdp.configMap.data.CORS_ALLOWED_ORIGINS` | Specifies the domains allowed to make cross-origin requests. "*" means all domains are allowed. | `*` | @@ -136,7 +137,7 @@ Configuration parameters for the SDP Core Service which is the core backend serv | `sdp.configMap.data.ENABLE_SCHEDULER` | Whether the scheduled jobs are enabled in this instance ("true" or "false"). Default "false". | `false` | | `sdp.configMap.data.SCHEDULER_PAYMENT_JOB_SECONDS` | The interval in seconds for the payment job that syncs payments between the SDP and the TSS. | `3600` | | `sdp.configMap.data.SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS` | The interval in seconds for the receiver invitation job that sends invitations to new receivers. 0 or negative values disable the job. | `3600` | -| `sdp.configMap.data.MAX_INVITATION_SMS_RESEND_ATTEMPTS` | The maximum number of times an invitation SMS can be resent. 0 or negative values disable the job. | `3` | +| `sdp.configMap.data.MAX_INVITATION_RESEND_ATTEMPTS` | The maximum number of times an invitation can be resent. 0 or negative values disable the job. | `3` | | `sdp.configMap.data.TENANT_XLM_BOOTSTRAP_AMOUNT` | The amount of XLM to be sent to a newly created tenant distribution account. | `5` | | `sdp.kubeSecrets` | Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. | | | `sdp.kubeSecrets.secretName` | The name of the Kubernetes secret object. Only use this if create is false. | `sdp-backend-secret-name` | @@ -150,6 +151,8 @@ Configuration parameters for the SDP Core Service which is the core backend serv | `sdp.kubeSecrets.data.TWILIO_ACCOUNT_SID` | Account SID for authenticating to the Twilio service, used for sending text messages. | `MY_TWILIO_ACCOUNT_SID` | | `sdp.kubeSecrets.data.TWILIO_AUTH_TOKEN` | Authentication token for the Twilio service. | `MY_TWILIO_AUTH_TOKEN` | | `sdp.kubeSecrets.data.TWILIO_SERVICE_SID` | Service SID for the specific Twilio service being utilized. | `MY_TWILIO_SERVICE_SID` | +| `sdp.kubeSecrets.data.TWILIO_SENDGRID_API_KEY` | API key for the Twilio SendGrid (email) service. | `MY_TWILIO_SENDGRID_API_KEY` | +| `sdp.kubeSecrets.data.TWILIO_SENDGRID_SENDER_ADDRESS` | Email address used to send emails via Twilio SendGrid. | `MY_TWILIO_SENDGRID_SENDER_ADDRESS` | | `sdp.kubeSecrets.data.EC256_PRIVATE_KEY` | The EC256 Private Key. This key is used to sign the authentication token. This EC key needs to be at least as strong as prime256v1 (P-256). | `""` | | `sdp.kubeSecrets.data.SEP10_SIGNING_PRIVATE_KEY` | The public key of the Stellar account that signs the SEP-10 transactions. It's also used to sign URLs. | `nil` | | `sdp.kubeSecrets.data.SEP24_JWT_SECRET` | The JWT secret that's used by the Anchor Platform to sign the SEP-24 JWT token. Must be the same as Anchor Platform's SECRET_SEP24_INTERACTIVE_URL_JWT_SECRET. | `nil` | @@ -288,7 +291,7 @@ Configuration parameters for the Dashboard. This is the user interface administr | `dashboard.route.mtnDomain` | Public domain/address of the multi-tenant Dashboard. This is a wild-card domain used for multi-tenant setups e.g. "*.sdp-dashboard.localhost.com". | `nil` | | `dashboard.route.port` | Primary port on which the Dashboard listens. | `80` | | `dashboard.image` | Configuration related to the Docker image used by the Dashboard. | | -| `dashboard.image.fullName` | Full name of the Docker image. | `stellar/stellar-disbursement-platform-frontend:latest` | +| `dashboard.image.fullName` | Full name of the Docker image. | `stellar/stellar-disbursement-platform-frontend:3.0.0-rc.1` | | `dashboard.image.pullPolicy` | Image pull policy for the dashboard. For locally built images, consider using "Never" or "IfNotPresent". | `Always` | | `dashboard.deployment` | Configuration related to the deployment of the Dashboard. | | | `dashboard.deployment.annotations` | Annotations to be added to the deployment. | `{}` | diff --git a/helmchart/sdp/templates/01.1-configmap-sdp.yaml b/helmchart/sdp/templates/01.1-configmap-sdp.yaml index 3e9498fe4..eee4b16cf 100644 --- a/helmchart/sdp/templates/01.1-configmap-sdp.yaml +++ b/helmchart/sdp/templates/01.1-configmap-sdp.yaml @@ -33,5 +33,6 @@ data: {{- if eq .Values.global.eventBroker.type "KAFKA" }} KAFKA_SECURITY_PROTOCOL: {{ .Values.global.eventBroker.kafka.securityProtocol | quote }} {{- end }} + SINGLE_TENANT_MODE: {{ .Values.global.singleTenantMode | quote }} {{- tpl (toYaml .Values.sdp.configMap.data | nindent 2) . }} {{- end }} diff --git a/helmchart/sdp/templates/01.4-configmap-dashboard.yaml b/helmchart/sdp/templates/01.4-configmap-dashboard.yaml index 7fd61c67d..539b1b80d 100644 --- a/helmchart/sdp/templates/01.4-configmap-dashboard.yaml +++ b/helmchart/sdp/templates/01.4-configmap-dashboard.yaml @@ -23,10 +23,11 @@ data: {{- end }} {{- if eq (include "isPubnet" .) "true" }} HORIZON_URL: "https://horizon.stellar.org", - STELLAR_EXPERT_URL: "https://stellar.expert/explorer/pubnet" + STELLAR_EXPERT_URL: "https://stellar.expert/explorer/pubnet", {{- else }} HORIZON_URL: "https://horizon-testnet.stellar.org", - STELLAR_EXPERT_URL: "https://stellar.expert/explorer/testnet" + STELLAR_EXPERT_URL: "https://stellar.expert/explorer/testnet", {{- end }} + SINGLE_TENANT_MODE: {{ .Values.global.singleTenantMode }} }; {{- end }} diff --git a/helmchart/sdp/values.yaml b/helmchart/sdp/values.yaml index 46b64fc8d..311b045dd 100644 --- a/helmchart/sdp/values.yaml +++ b/helmchart/sdp/values.yaml @@ -77,6 +77,9 @@ global: kafka: securityProtocol: #required + ## @param global.singleTenantMode Determines if the SDP service is running in single-tenant mode. + singleTenantMode: false + # =========================== START sdp =========================== ## @section Stellar Disbursement Platform (SDP) parameters ## @descriptionStart @@ -108,7 +111,7 @@ sdp: image: repository: stellar/stellar-disbursement-platform-backend pullPolicy: Always - tag: "latest" + tag: "3.0.0-rc.1" ## @extra sdp.deployment Configuration related to the deployment of the SDP service. ## @param sdp.deployment.annotations Annotations to be added to the deployment. @@ -139,7 +142,7 @@ sdp: ## @param sdp.configMap.data.SEP10_SIGNING_PUBLIC_KEY Anchor platform SEP10 signing public key. ## @param sdp.configMap.data.DISTRIBUTION_PUBLIC_KEY The public key of the HOST's Stellar distribution account, used to create channel accounts. ## @param sdp.configMap.data.METRICS_TYPE Defines the type of metrics system in use. Options: "PROMETHEUS". - ## @param sdp.configMap.data.EMAIL_SENDER_TYPE The messenger type used to send invitations to new dashboard users. Options: "DRY_RUN", "AWS_EMAIL". + ## @param sdp.configMap.data.EMAIL_SENDER_TYPE The messenger type used to send invitations to new dashboard users. Options: "DRY_RUN", "AWS_EMAIL", "TWILIO_EMAIL". ## @param sdp.configMap.data.SMS_SENDER_TYPE The messenger type used to send text messages to recipients. Options: "DRY_RUN", "TWILIO_SMS". ## @param sdp.configMap.data.RECAPTCHA_SITE_KEY Site key for ReCaptcha. Required if using ReCaptcha. ## @param sdp.configMap.data.CORS_ALLOWED_ORIGINS Specifies the domains allowed to make cross-origin requests. "*" means all domains are allowed. @@ -150,7 +153,7 @@ sdp: ## @param sdp.configMap.data.ENABLE_SCHEDULER Whether the scheduled jobs are enabled in this instance ("true" or "false"). Default "false". ## @param sdp.configMap.data.SCHEDULER_PAYMENT_JOB_SECONDS The interval in seconds for the payment job that syncs payments between the SDP and the TSS. ## @param sdp.configMap.data.SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS The interval in seconds for the receiver invitation job that sends invitations to new receivers. 0 or negative values disable the job. - ## @param sdp.configMap.data.MAX_INVITATION_SMS_RESEND_ATTEMPTS The maximum number of times an invitation SMS can be resent. 0 or negative values disable the job. + ## @param sdp.configMap.data.MAX_INVITATION_RESEND_ATTEMPTS The maximum number of times an invitation can be resent. 0 or negative values disable the job. ## @param sdp.configMap.data.TENANT_XLM_BOOTSTRAP_AMOUNT The amount of XLM to be sent to a newly created tenant distribution account. configMap: annotations: @@ -173,7 +176,7 @@ sdp: ENABLE_SCHEDULER: "false" SCHEDULER_PAYMENT_JOB_SECONDS: "3600" SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS: "3600" - MAX_INVITATION_SMS_RESEND_ATTEMPTS: "3" + MAX_INVITATION_RESEND_ATTEMPTS: "3" TENANT_XLM_BOOTSTRAP_AMOUNT: "5" ## @extra sdp.kubeSecrets Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. @@ -188,6 +191,8 @@ sdp: ## @param sdp.kubeSecrets.data.TWILIO_ACCOUNT_SID Account SID for authenticating to the Twilio service, used for sending text messages. ## @param sdp.kubeSecrets.data.TWILIO_AUTH_TOKEN Authentication token for the Twilio service. ## @param sdp.kubeSecrets.data.TWILIO_SERVICE_SID Service SID for the specific Twilio service being utilized. + ## @param sdp.kubeSecrets.data.TWILIO_SENDGRID_API_KEY API key for the Twilio SendGrid (email) service. + ## @param sdp.kubeSecrets.data.TWILIO_SENDGRID_SENDER_ADDRESS Email address used to send emails via Twilio SendGrid. ## @param sdp.kubeSecrets.data.EC256_PRIVATE_KEY [string] The EC256 Private Key. This key is used to sign the authentication token. This EC key needs to be at least as strong as prime256v1 (P-256). ## @param sdp.kubeSecrets.data.SEP10_SIGNING_PRIVATE_KEY The public key of the Stellar account that signs the SEP-10 transactions. It's also used to sign URLs. ## @param sdp.kubeSecrets.data.SEP24_JWT_SECRET The JWT secret that's used by the Anchor Platform to sign the SEP-24 JWT token. Must be the same as Anchor Platform's SECRET_SEP24_INTERACTIVE_URL_JWT_SECRET. @@ -217,6 +222,8 @@ sdp: TWILIO_ACCOUNT_SID: MY_TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN: MY_TWILIO_AUTH_TOKEN TWILIO_SERVICE_SID: MY_TWILIO_SERVICE_SID + TWILIO_SENDGRID_API_KEY: MY_TWILIO_SENDGRID_API_KEY + TWILIO_SENDGRID_SENDER_ADDRESS: MY_TWILIO_SENDGRID_SENDER_ADDRESS SENTRY_DSN: #optional EC256_PRIVATE_KEY: #required SEP10_SIGNING_PRIVATE_KEY: #required @@ -245,7 +252,7 @@ sdp: enabled: true className: "nginx" annotations: - nginx.ingress.kubernetes.io/custom-response-headers: "X-XSS-Protection: 1; mode=block || X-Frame-Options: DENY || X-Content-Type-Options: nosniff || Strict-Transport-Security: max-age=31536000; includeSubDomains" + nginx.ingress.kubernetes.io/custom-response-headers: "X-Frame-Options: DENY || X-Content-Type-Options: nosniff || Strict-Transport-Security: max-age=31536000; includeSubDomains" nginx.ingress.kubernetes.io/limit-rpm: "120" nginx.ingress.kubernetes.io/limit-burst-multiplier: "5" tls: @@ -398,7 +405,7 @@ anchorPlatform: enabled: true className: "nginx" annotations: - nginx.ingress.kubernetes.io/custom-response-headers: "X-XSS-Protection: 1; mode=block || X-Frame-Options: DENY || X-Content-Type-Options: nosniff || Strict-Transport-Security: max-age=31536000; includeSubDomains" + nginx.ingress.kubernetes.io/custom-response-headers: "X-Frame-Options: DENY || X-Content-Type-Options: nosniff || Strict-Transport-Security: max-age=31536000; includeSubDomains" nginx.ingress.kubernetes.io/limit-rpm: "120" nginx.ingress.kubernetes.io/limit-burst-multiplier: "5" tls: @@ -529,7 +536,7 @@ dashboard: ## @param dashboard.image.fullName Full name of the Docker image. ## @param dashboard.image.pullPolicy Image pull policy for the dashboard. For locally built images, consider using "Never" or "IfNotPresent". image: - fullName: stellar/stellar-disbursement-platform-frontend:latest + fullName: stellar/stellar-disbursement-platform-frontend:3.0.0-rc.1 pullPolicy: Always ## @extra dashboard.deployment Configuration related to the deployment of the Dashboard. diff --git a/internal/circle/client.go b/internal/circle/client.go index 768b0e114..92744203a 100644 --- a/internal/circle/client.go +++ b/internal/circle/client.go @@ -15,6 +15,7 @@ import ( "github.com/avast/retry-go" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" @@ -42,29 +43,38 @@ type ClientInterface interface { // Client provides methods to interact with the Circle API. type Client struct { - BasePath string - APIKey string - httpClient httpclient.HttpClientInterface - tenantManager tenant.ManagerInterface + BasePath string + APIKey string + httpClient httpclient.HttpClientInterface + tenantManager tenant.ManagerInterface + monitorService monitor.MonitorServiceInterface } // ClientFactory is a function that creates a ClientInterface. -type ClientFactory func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface +type ClientFactory func(opts ClientOptions) ClientInterface var _ ClientFactory = NewClient +type ClientOptions struct { + NetworkType utils.NetworkType + APIKey string + TenantManager tenant.ManagerInterface + MonitorService monitor.MonitorServiceInterface +} + // NewClient creates a new instance of Circle Client. -func NewClient(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { +func NewClient(opts ClientOptions) ClientInterface { circleEnv := Sandbox - if networkType == utils.PubnetNetworkType { + if opts.NetworkType == utils.PubnetNetworkType { circleEnv = Production } return &Client{ - BasePath: string(circleEnv), - APIKey: apiKey, - httpClient: httpclient.DefaultClient(), - tenantManager: tntManager, + BasePath: string(circleEnv), + APIKey: opts.APIKey, + httpClient: httpclient.DefaultClient(), + tenantManager: opts.TenantManager, + monitorService: opts.MonitorService, } } @@ -77,7 +87,7 @@ func (client *Client) Ping(ctx context.Context) (bool, error) { return false, fmt.Errorf("building path: %w", err) } - resp, err := client.request(ctx, u, http.MethodGet, false, nil) + resp, err := client.request(ctx, pingPath, u, http.MethodGet, false, nil) if err != nil { return false, fmt.Errorf("making request: %w", err) } @@ -120,7 +130,7 @@ func (client *Client) PostTransfer(ctx context.Context, transferReq TransferRequ return nil, err } - resp, err := client.request(ctx, u, http.MethodPost, true, transferData) + resp, err := client.request(ctx, transferPath, u, http.MethodPost, true, transferData) if err != nil { return nil, fmt.Errorf("making request: %w", err) } @@ -144,7 +154,7 @@ func (client *Client) GetTransferByID(ctx context.Context, id string) (*Transfer return nil, fmt.Errorf("building path: %w", err) } - resp, err := client.request(ctx, u, http.MethodGet, true, nil) + resp, err := client.request(ctx, transferPath, u, http.MethodGet, true, nil) if err != nil { return nil, fmt.Errorf("making request: %w", err) } @@ -166,7 +176,7 @@ func (client *Client) GetBusinessBalances(ctx context.Context) (*Balances, error return nil, fmt.Errorf("building path: %w", err) } - resp, err := client.request(ctx, url, http.MethodGet, true, nil) + resp, err := client.request(ctx, businessBalancesPath, url, http.MethodGet, true, nil) if err != nil { return nil, fmt.Errorf("making request: %w", err) } @@ -189,7 +199,7 @@ func (client *Client) GetAccountConfiguration(ctx context.Context) (*AccountConf return nil, fmt.Errorf("building path: %w", err) } - resp, err := client.request(ctx, url, http.MethodGet, true, nil) + resp, err := client.request(ctx, configurationPath, url, http.MethodGet, true, nil) if err != nil { return nil, fmt.Errorf("making request: %w", err) } @@ -216,10 +226,11 @@ func (re RetryableError) Error() string { } // request makes an HTTP request to the Circle API. -func (client *Client) request(ctx context.Context, u string, method string, isAuthed bool, bodyBytes []byte) (*http.Response, error) { +func (client *Client) request(ctx context.Context, path, u, method string, isAuthed bool, bodyBytes []byte) (*http.Response, error) { var resp *http.Response err := retry.Do( func() error { + startTime := time.Now() bodyReader := bytes.NewReader(bodyBytes) req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) if err != nil { @@ -235,6 +246,8 @@ func (client *Client) request(ctx context.Context, u string, method string, isAu } resp, err = client.httpClient.Do(req) + client.recordCircleAPIMetrics(ctx, method, path, startTime, resp, err) + if err != nil { return fmt.Errorf("submitting request to %s: %w", u, err) } @@ -287,6 +300,33 @@ func parseRetryAfter(retryAfter string) time.Duration { return time.Duration(seconds) * time.Second } +func (client *Client) recordCircleAPIMetrics(ctx context.Context, method, endpoint string, startTime time.Time, resp *http.Response, reqErr error) { + t, err := tenant.GetTenantFromContext(ctx) + if err != nil { + log.Ctx(ctx).Errorf("getting tenant from context: %v", err) + return + } + + duration := time.Since(startTime) + status, statusCode := monitor.ParseHTTPResponseStatus(resp, reqErr) + + labels := monitor.CircleLabels{ + Method: method, + Endpoint: endpoint, + Status: status, + StatusCode: statusCode, + TenantName: t.Name, + }.ToMap() + + if monitorErr := client.monitorService.MonitorHistogram(duration.Seconds(), monitor.CircleAPIRequestDurationTag, labels); monitorErr != nil { + log.Ctx(ctx).Errorf("monitoring histogram: %v", err) + } + + if monitorErr := client.monitorService.MonitorCounters(monitor.CircleAPIRequestsTotalTag, labels); monitorErr != nil { + log.Ctx(ctx).Errorf("monitoring counter: %v", err) + } +} + func (client *Client) handleError(ctx context.Context, resp *http.Response) error { if slices.Contains(authErrorStatusCodes, resp.StatusCode) { tnt, getCtxTntErr := tenant.GetTenantFromContext(ctx) @@ -305,7 +345,7 @@ func (client *Client) handleError(ctx context.Context, resp *http.Response) erro return fmt.Errorf("parsing API error: %w", err) } - return fmt.Errorf("Circle API error: %w", apiError) //nolint:golint,unused + return fmt.Errorf("circle API error: %w", apiError) } var _ ClientInterface = (*Client)(nil) diff --git a/internal/circle/client_test.go b/internal/circle/client_test.go index 31f193b53..9e71b1aea 100644 --- a/internal/circle/client_test.go +++ b/internal/circle/client_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strconv" "testing" "github.com/google/uuid" @@ -14,6 +15,8 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" + monitorMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor/mocks" httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" @@ -21,8 +24,14 @@ import ( func Test_NewClient(t *testing.T) { mockTntManager := &tenant.TenantManagerMock{} + mMonitorService := monitorMocks.NewMockMonitorService(t) t.Run("production environment", func(t *testing.T) { - clientInterface := NewClient(utils.PubnetNetworkType, "test-key", mockTntManager) + clientInterface := NewClient(ClientOptions{ + NetworkType: utils.PubnetNetworkType, + APIKey: "test-key", + TenantManager: mockTntManager, + MonitorService: mMonitorService, + }) cc, ok := clientInterface.(*Client) assert.True(t, ok) assert.Equal(t, string(Production), cc.BasePath) @@ -30,7 +39,12 @@ func Test_NewClient(t *testing.T) { }) t.Run("sandbox environment", func(t *testing.T) { - clientInterface := NewClient(utils.TestnetNetworkType, "test-key", mockTntManager) + clientInterface := NewClient(ClientOptions{ + NetworkType: utils.TestnetNetworkType, + APIKey: "test-key", + TenantManager: mockTntManager, + MonitorService: mMonitorService, + }) cc, ok := clientInterface.(*Client) assert.True(t, ok) assert.Equal(t, string(Sandbox), cc.BasePath) @@ -42,9 +56,9 @@ func Test_Client_Ping(t *testing.T) { ctx := context.Background() t.Run("ping error", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) testError := errors.New("test error") - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(nil, testError). Once() @@ -55,8 +69,8 @@ func Test_Client_Ping(t *testing.T) { }) t.Run("ping successful", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) - httpClientMock. + cc, cMocks := newClientWithMocks(t) + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusOK, @@ -88,9 +102,9 @@ func Test_Client_PostTransfer(t *testing.T) { } t.Run("post transfer error", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) testError := errors.New("test error") - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(nil, testError). Once() @@ -101,7 +115,7 @@ func Test_Client_PostTransfer(t *testing.T) { }) t.Run("post transfer fails to validate request", func(t *testing.T) { - cc, _, _ := newClientWithMocks(t) + cc, _ := newClientWithMocks(t) transfer, err := cc.PostTransfer(ctx, TransferRequest{}) assert.EqualError(t, err, fmt.Errorf("validating transfer request: %w", errors.New("source type must be provided")).Error()) assert.Nil(t, transfer) @@ -109,29 +123,45 @@ func Test_Client_PostTransfer(t *testing.T) { t.Run("post transfer fails auth", func(t *testing.T) { unauthorizedResponse := `{"code": 401, "message": "Malformed key. Does it contain three parts?"}` - cc, httpClientMock, tntManagerMock := newClientWithMocks(t) - tnt := &tenant.Tenant{ID: "test-id"} + cc, cMocks := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id", Name: "test-tenant"} ctx = tenant.SaveTenantInContext(ctx, tnt) - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), }, nil). Once() - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(nil).Once() + expectedLabels := map[string]string{ + "endpoint": transferPath, + "method": http.MethodPost, + "status": "success", + "status_code": strconv.Itoa(http.StatusUnauthorized), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() transfer, err := cc.PostTransfer(ctx, validTransferReq) - assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.EqualError(t, err, "handling API response error: circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") assert.Nil(t, transfer) }) t.Run("post transfer successful", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) - httpClientMock. + cc, cMocks := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id", Name: "test-tenant"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusCreated, @@ -148,6 +178,20 @@ func Test_Client_PostTransfer(t *testing.T) { }). Once() + expectedLabels := map[string]string{ + "endpoint": transferPath, + "method": http.MethodPost, + "status": "success", + "status_code": strconv.Itoa(http.StatusCreated), + "tenant_name": "test-tenant", + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() + transfer, err := cc.PostTransfer(ctx, validTransferReq) assert.NoError(t, err) assert.Equal(t, "test-id", transfer.ID) @@ -157,9 +201,9 @@ func Test_Client_PostTransfer(t *testing.T) { func Test_Client_GetTransferByID(t *testing.T) { ctx := context.Background() t.Run("get transfer by id error", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) testError := errors.New("test error") - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(nil, testError). Once() @@ -171,29 +215,45 @@ func Test_Client_GetTransferByID(t *testing.T) { t.Run("get transfer by id fails auth", func(t *testing.T) { unauthorizedResponse := `{"code": 401, "message": "Malformed key. Does it contain three parts?"}` - cc, httpClientMock, tntManagerMock := newClientWithMocks(t) - tnt := &tenant.Tenant{ID: "test-id"} + cc, cMocks := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id", Name: "test-tenant"} ctx = tenant.SaveTenantInContext(ctx, tnt) - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), }, nil). Once() - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(nil).Once() + expectedLabels := map[string]string{ + "endpoint": transferPath, + "method": http.MethodGet, + "status": "success", + "status_code": strconv.Itoa(http.StatusUnauthorized), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() transfer, err := cc.GetTransferByID(ctx, "test-id") - assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.EqualError(t, err, "handling API response error: circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") assert.Nil(t, transfer) }) t.Run("get transfer by id successful", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) - httpClientMock. + cc, cMocks := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id", Name: "test-tenant"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusOK, @@ -209,6 +269,20 @@ func Test_Client_GetTransferByID(t *testing.T) { }). Once() + expectedLabels := map[string]string{ + "endpoint": transferPath, + "method": http.MethodGet, + "status": "success", + "status_code": strconv.Itoa(http.StatusOK), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() + transfer, err := cc.GetTransferByID(ctx, "test-id") assert.NoError(t, err) assert.Equal(t, "test-id", transfer.ID) @@ -218,9 +292,9 @@ func Test_Client_GetTransferByID(t *testing.T) { func Test_Client_GetBusinessBalances(t *testing.T) { ctx := context.Background() t.Run("get business balances error", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) testError := errors.New("test error") - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Run(func(args mock.Arguments) { req, ok := args.Get(0).(*http.Request) @@ -243,28 +317,41 @@ func Test_Client_GetBusinessBalances(t *testing.T) { "code": 401, "message": "Malformed key. Does it contain three parts?" }` - cc, httpClientMock, tntManagerMock := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) tnt := &tenant.Tenant{ID: "test-id"} ctx = tenant.SaveTenantInContext(ctx, tnt) - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), }, nil). Once() - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(nil).Once() + expectedLabels := map[string]string{ + "endpoint": businessBalancesPath, + "method": http.MethodGet, + "status": "success", + "status_code": strconv.Itoa(http.StatusUnauthorized), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() transfer, err := cc.GetBusinessBalances(ctx) - assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.EqualError(t, err, "handling API response error: circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") assert.Nil(t, transfer) }) t.Run("get business balances successful", func(t *testing.T) { - const getWalletResponseJSON = `{ + const getBalancesResponseJSON = `{ "data": { "available": [ { @@ -276,12 +363,14 @@ func Test_Client_GetBusinessBalances(t *testing.T) { } } }` - cc, httpClientMock, _ := newClientWithMocks(t) - httpClientMock. + cc, cMocks := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id", Name: "test-tenant"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(getWalletResponseJSON)), + Body: io.NopCloser(bytes.NewBufferString(getBalancesResponseJSON)), }, nil). Run(func(args mock.Arguments) { req, ok := args.Get(0).(*http.Request) @@ -292,9 +381,23 @@ func Test_Client_GetBusinessBalances(t *testing.T) { assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) }). Once() + expectedLabels := map[string]string{ + "endpoint": businessBalancesPath, + "method": http.MethodGet, + "status": "success", + "status_code": strconv.Itoa(http.StatusOK), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() businessBalances, err := cc.GetBusinessBalances(ctx) assert.NoError(t, err) + wantBusinessBalances := &Balances{ Available: []Balance{ {Amount: "22306.90", Currency: "USD"}, @@ -308,9 +411,9 @@ func Test_Client_GetBusinessBalances(t *testing.T) { func Test_Client_GetAccountConfiguration(t *testing.T) { ctx := context.Background() t.Run("get configuration error", func(t *testing.T) { - cc, httpClientMock, _ := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) testError := errors.New("test error") - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Run(func(args mock.Arguments) { req, ok := args.Get(0).(*http.Request) @@ -333,23 +436,36 @@ func Test_Client_GetAccountConfiguration(t *testing.T) { "code": 401, "message": "Malformed key. Does it contain three parts?" }` - cc, httpClientMock, tntManagerMock := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) tnt := &tenant.Tenant{ID: "test-id"} ctx = tenant.SaveTenantInContext(ctx, tnt) - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), }, nil). Once() - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(nil).Once() + expectedLabels := map[string]string{ + "endpoint": configurationPath, + "method": http.MethodGet, + "status": "success", + "status_code": strconv.Itoa(http.StatusUnauthorized), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() transfer, err := cc.GetAccountConfiguration(ctx) - assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.EqualError(t, err, "handling API response error: circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") assert.Nil(t, transfer) }) @@ -361,8 +477,11 @@ func Test_Client_GetAccountConfiguration(t *testing.T) { } } }` - cc, httpClientMock, _ := newClientWithMocks(t) - httpClientMock. + tnt := &tenant.Tenant{ID: "test-id", Name: "test-tenant"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + cc, cMocks := newClientWithMocks(t) + cMocks.httpClientMock. On("Do", mock.Anything). Return(&http.Response{ StatusCode: http.StatusOK, @@ -377,6 +496,19 @@ func Test_Client_GetAccountConfiguration(t *testing.T) { assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) }). Once() + expectedLabels := map[string]string{ + "endpoint": configurationPath, + "method": http.MethodGet, + "status": "success", + "status_code": strconv.Itoa(http.StatusOK), + "tenant_name": tnt.Name, + } + cMocks.monitorServiceMock. + On("MonitorHistogram", mock.Anything, monitor.CircleAPIRequestDurationTag, expectedLabels). + Return(nil).Once() + cMocks.monitorServiceMock. + On("MonitorCounters", monitor.CircleAPIRequestsTotalTag, expectedLabels). + Return(nil).Once() config, err := cc.GetAccountConfiguration(ctx) assert.NoError(t, err) @@ -394,11 +526,11 @@ func Test_Client_handleError(t *testing.T) { tnt := &tenant.Tenant{ID: "test-id"} ctx = tenant.SaveTenantInContext(ctx, tnt) - cc, _, tntManagerMock := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) t.Run("deactivate tenant distribution account error", func(t *testing.T) { testError := errors.New("foo") - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(testError).Once() @@ -411,12 +543,12 @@ func Test_Client_handleError(t *testing.T) { StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewBufferString(`{"code": 401, "message": "Unauthorized"}`)), } - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(nil).Once() err := cc.handleError(ctx, unauthorizedResponse) - assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=401, Message=Unauthorized, Errors=[], StatusCode=401")), err.Error()) + assert.EqualError(t, fmt.Errorf("circle API error: %w", errors.New("APIError: Code=401, Message=Unauthorized, Errors=[], StatusCode=401")), err.Error()) }) t.Run("deactivates tenant distribution account if Circle error response is forbidden", func(t *testing.T) { @@ -424,12 +556,12 @@ func Test_Client_handleError(t *testing.T) { StatusCode: http.StatusForbidden, Body: io.NopCloser(bytes.NewBufferString(`{"code": 403, "message": "Forbidden"}`)), } - tntManagerMock. + cMocks.tenantManagerMock. On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). Return(nil).Once() err := cc.handleError(ctx, unauthorizedResponse) - assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=403, Message=Forbidden, Errors=[], StatusCode=403")), err.Error()) + assert.EqualError(t, fmt.Errorf("circle API error: %w", errors.New("APIError: Code=403, Message=Forbidden, Errors=[], StatusCode=403")), err.Error()) }) t.Run("does not deactivate tenant distribution account if Circle error response is not unauthorized or forbidden", func(t *testing.T) { @@ -439,7 +571,7 @@ func Test_Client_handleError(t *testing.T) { } err := cc.handleError(ctx, unauthorizedResponse) - assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=400, Message=Bad Request, Errors=[], StatusCode=400")), err.Error()) + assert.EqualError(t, fmt.Errorf("circle API error: %w", errors.New("APIError: Code=400, Message=Bad Request, Errors=[], StatusCode=400")), err.Error()) }) t.Run("records error correctly when not proper json", func(t *testing.T) { @@ -449,10 +581,10 @@ func Test_Client_handleError(t *testing.T) { } err := cc.handleError(ctx, unauthorizedResponse) - assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=0, Message=error code: 1015, Errors=[], StatusCode=429")), err.Error()) + assert.EqualError(t, fmt.Errorf("circle API error: %w", errors.New("APIError: Code=0, Message=error code: 1015, Errors=[], StatusCode=429")), err.Error()) }) - tntManagerMock.AssertExpectations(t) + cMocks.tenantManagerMock.AssertExpectations(t) } func Test_Client_request(t *testing.T) { @@ -512,7 +644,8 @@ func Test_Client_request(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - cc, httpClientMock, _ := newClientWithMocks(t) + cc, cMocks := newClientWithMocks(t) + httpClientMock := cMocks.httpClientMock ctx := context.Background() u := "https://api-sandbox.circle.com/test" @@ -521,12 +654,12 @@ func Test_Client_request(t *testing.T) { body := []byte("test-body") for _, resp := range tt.responses { - httpClientMock. + cMocks.httpClientMock. On("Do", mock.Anything). Return(&resp, nil).Once() } - resp, err := cc.request(ctx, u, method, isAuthed, body) + resp, err := cc.request(ctx, "/test", u, method, isAuthed, body) if tt.expectedError != "" { require.Error(t, err) @@ -549,14 +682,26 @@ func Test_Client_request(t *testing.T) { } } -func newClientWithMocks(t *testing.T) (Client, *httpclientMocks.HttpClientMock, *tenant.TenantManagerMock) { +func newClientWithMocks(t *testing.T) (Client, *clientMocks) { httpClientMock := httpclientMocks.NewHttpClientMock(t) tntManagerMock := tenant.NewTenantManagerMock(t) + monitorSvcMock := monitorMocks.NewMockMonitorService(t) return Client{ - BasePath: "http://localhost:8080", - APIKey: "test-key", - httpClient: httpClientMock, - tenantManager: tntManagerMock, - }, httpClientMock, tntManagerMock + BasePath: "http://localhost:8080", + APIKey: "test-key", + httpClient: httpClientMock, + tenantManager: tntManagerMock, + monitorService: monitorSvcMock, + }, &clientMocks{ + httpClientMock: httpClientMock, + tenantManagerMock: tntManagerMock, + monitorServiceMock: monitorSvcMock, + } +} + +type clientMocks struct { + httpClientMock *httpclientMocks.HttpClientMock + tenantManagerMock *tenant.TenantManagerMock + monitorServiceMock *monitorMocks.MockMonitorService } diff --git a/internal/circle/service.go b/internal/circle/service.go index 64d96eb64..e53c53177 100644 --- a/internal/circle/service.go +++ b/internal/circle/service.go @@ -6,6 +6,7 @@ import ( "github.com/stellar/go/strkey" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -16,6 +17,7 @@ type Service struct { NetworkType utils.NetworkType EncryptionPassphrase string TenantManager tenant.ManagerInterface + MonitorService monitor.MonitorServiceInterface } const StellarChainCode = "XLM" @@ -36,6 +38,7 @@ type ServiceOptions struct { TenantManager tenant.ManagerInterface NetworkType utils.NetworkType EncryptionPassphrase string + MonitorService monitor.MonitorServiceInterface } func (o ServiceOptions) Validate() error { @@ -51,6 +54,10 @@ func (o ServiceOptions) Validate() error { return fmt.Errorf("TenantManager is required") } + if o.MonitorService == nil { + return fmt.Errorf("MonitorService is required") + } + err := o.NetworkType.Validate() if err != nil { return fmt.Errorf("validating NetworkType: %w", err) @@ -75,6 +82,7 @@ func NewService(opts ServiceOptions) (*Service, error) { NetworkType: opts.NetworkType, EncryptionPassphrase: opts.EncryptionPassphrase, TenantManager: opts.TenantManager, + MonitorService: opts.MonitorService, }, nil } @@ -111,7 +119,12 @@ func (s *Service) getClientForTenantInContext(ctx context.Context) (ClientInterf if err != nil { return nil, fmt.Errorf("retrieving decrypted Circle API key: %w", err) } - return s.ClientFactory(s.NetworkType, apiKey, s.TenantManager), nil + return s.ClientFactory(ClientOptions{ + APIKey: apiKey, + NetworkType: s.NetworkType, + TenantManager: s.TenantManager, + MonitorService: s.MonitorService, + }), nil } func (s *Service) Ping(ctx context.Context) (bool, error) { diff --git a/internal/circle/service_test.go b/internal/circle/service_test.go index 1f24f27e9..4658bb9af 100644 --- a/internal/circle/service_test.go +++ b/internal/circle/service_test.go @@ -9,16 +9,18 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + monitorMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_ServiceOptions_Validate(t *testing.T) { - var clientFactory ClientFactory = func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + var clientFactory ClientFactory = func(clientOpts ClientOptions) ClientInterface { return nil } circleClientConfigModel := &ClientConfigModel{} mockTenantManager := &tenant.TenantManagerMock{} + mockMonitorSvc := monitorMocks.NewMockMonitorService(t) testCases := []struct { name string @@ -40,12 +42,18 @@ func Test_ServiceOptions_Validate(t *testing.T) { opts: ServiceOptions{ClientFactory: clientFactory, ClientConfigModel: circleClientConfigModel}, expectedErrContains: "TenantManager is required", }, + { + name: "MonitorService validation fails", + opts: ServiceOptions{ClientFactory: clientFactory, ClientConfigModel: circleClientConfigModel, TenantManager: mockTenantManager}, + expectedErrContains: "MonitorService is required", + }, { name: "NetworkType validation fails", opts: ServiceOptions{ ClientFactory: clientFactory, ClientConfigModel: circleClientConfigModel, TenantManager: mockTenantManager, + MonitorService: mockMonitorSvc, NetworkType: utils.NetworkType("FOOBAR"), }, expectedErrContains: `validating NetworkType: invalid network type "FOOBAR"`, @@ -56,6 +64,7 @@ func Test_ServiceOptions_Validate(t *testing.T) { ClientFactory: clientFactory, ClientConfigModel: circleClientConfigModel, TenantManager: mockTenantManager, + MonitorService: mockMonitorSvc, NetworkType: utils.TestnetNetworkType, EncryptionPassphrase: "FOO BAR", }, @@ -68,6 +77,7 @@ func Test_ServiceOptions_Validate(t *testing.T) { ClientConfigModel: circleClientConfigModel, TenantManager: mockTenantManager, NetworkType: utils.TestnetNetworkType, + MonitorService: mockMonitorSvc, EncryptionPassphrase: "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6", }, }, @@ -93,11 +103,12 @@ func Test_NewService(t *testing.T) { }) t.Run("🎉 successfully creates a new Service", func(t *testing.T) { - clientFactory := func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + clientFactory := func(clientOptions ClientOptions) ClientInterface { return nil } clientConfigModel := &ClientConfigModel{} mockTntManager := &tenant.TenantManagerMock{} + mockMonitorSvc := monitorMocks.NewMockMonitorService(t) networkType := utils.TestnetNetworkType encryptionPassphrase := "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6" @@ -107,6 +118,7 @@ func Test_NewService(t *testing.T) { TenantManager: mockTntManager, NetworkType: networkType, EncryptionPassphrase: encryptionPassphrase, + MonitorService: mockMonitorSvc, }) assert.NoError(t, err) @@ -117,7 +129,18 @@ func Test_NewService(t *testing.T) { NetworkType: networkType, EncryptionPassphrase: encryptionPassphrase, } - assert.Equal(t, wantService.ClientFactory(networkType, "FOO BAR", mockTntManager), svc.ClientFactory(networkType, "FOO BAR", mockTntManager)) + + assert.Equal(t, wantService.ClientFactory(ClientOptions{ + NetworkType: networkType, + APIKey: "FOO BAR", + TenantManager: mockTntManager, + MonitorService: mockMonitorSvc, + }), svc.ClientFactory(ClientOptions{ + NetworkType: networkType, + APIKey: "FOO BAR", + TenantManager: mockTntManager, + MonitorService: mockMonitorSvc, + })) assert.Equal(t, wantService.ClientConfigModel, svc.ClientConfigModel) assert.Equal(t, wantService.NetworkType, svc.NetworkType) assert.Equal(t, wantService.EncryptionPassphrase, svc.EncryptionPassphrase) @@ -140,6 +163,7 @@ func Test_Service_getClient(t *testing.T) { networkType := utils.TestnetNetworkType clientConfigModel := NewClientConfigModel(dbConnectionPool) mockTntManager := &tenant.TenantManagerMock{} + mMonitorService := monitorMocks.NewMockMonitorService(t) // Add a client config to the database. err = clientConfigModel.Upsert(ctx, ClientConfigUpdate{ @@ -156,12 +180,18 @@ func Test_Service_getClient(t *testing.T) { TenantManager: mockTntManager, NetworkType: networkType, EncryptionPassphrase: encryptionPassphrase, + MonitorService: mMonitorService, }) assert.NoError(t, err) circleClient, err := svc.getClientForTenantInContext(ctx) assert.NoError(t, err) - wantCircleClient := NewClient(networkType, apiKey, &tenant.TenantManagerMock{}) + wantCircleClient := NewClient(ClientOptions{ + NetworkType: networkType, + APIKey: apiKey, + TenantManager: mockTntManager, + MonitorService: mMonitorService, + }) assert.Equal(t, wantCircleClient, circleClient) } @@ -192,13 +222,14 @@ func Test_Service_allMethods(t *testing.T) { // Method used to spin up a service with a mock client. createService := func(t *testing.T, mCircleClient *MockClient) *Service { svc, err := NewService(ServiceOptions{ - ClientFactory: func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + ClientFactory: func(clientOpts ClientOptions) ClientInterface { return mCircleClient }, ClientConfigModel: clientConfigModel, TenantManager: mockTntManager, NetworkType: networkType, EncryptionPassphrase: encryptionPassphrase, + MonitorService: monitorMocks.NewMockMonitorService(t), }) require.NoError(t, err) return svc diff --git a/internal/data/assets.go b/internal/data/assets.go index 723f80df5..8ec6e3834 100644 --- a/internal/data/assets.go +++ b/internal/data/assets.go @@ -224,10 +224,10 @@ func (a *AssetModel) SoftDelete(ctx context.Context, sqlExec db.SQLExecuter, id } type ReceiverWalletAsset struct { - WalletID string `db:"wallet_id"` - ReceiverWallet ReceiverWallet `db:"receiver_wallet"` - Asset Asset `db:"asset"` - DisbursementSMSTemplate *string `json:"-" db:"sms_registration_message_template"` + WalletID string `db:"wallet_id"` + ReceiverWallet ReceiverWallet `db:"receiver_wallet"` + Asset Asset `db:"asset"` + DisbursementReceiverRegistrationMsgTemplate *string `json:"-" db:"receiver_registration_message_template"` } // GetAssetsPerReceiverWallet returns the assets associated with a READY payment for each receiver @@ -245,7 +245,7 @@ func (a *AssetModel) GetAssetsPerReceiverWallet(ctx context.Context, receiverWal SELECT p.id AS payment_id, d.wallet_id, - COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, + COALESCE(d.receiver_registration_message_template, '') as receiver_registration_message_template, p.asset_id FROM payments p @@ -254,7 +254,7 @@ func (a *AssetModel) GetAssetsPerReceiverWallet(ctx context.Context, receiverWal WHERE p.status = $1 GROUP BY - p.id, p.asset_id, d.wallet_id, d.sms_registration_message_template + p.id, p.asset_id, d.wallet_id, d.receiver_registration_message_template ORDER BY p.updated_at DESC ), messages_resent_since_invitation AS ( @@ -279,13 +279,13 @@ func (a *AssetModel) GetAssetsPerReceiverWallet(ctx context.Context, receiverWal ) SELECT DISTINCT lpw.wallet_id, - lpw.sms_registration_message_template, + lpw.receiver_registration_message_template, rw.id AS "receiver_wallet.id", rw.invitation_sent_at AS "receiver_wallet.invitation_sent_at", COALESCE(mrsi.total_invitation_sms_resent_attempts, 0) AS "receiver_wallet.total_invitation_sms_resent_attempts", r.id AS "receiver_wallet.receiver.id", - r.phone_number AS "receiver_wallet.receiver.phone_number", - r.email AS "receiver_wallet.receiver.email", + COALESCE(r.phone_number, '') AS "receiver_wallet.receiver.phone_number", + COALESCE(r.email, '') AS "receiver_wallet.receiver.email", a.id AS "asset.id", a.code AS "asset.code", a.issuer AS "asset.issuer", diff --git a/internal/data/assets_test.go b/internal/data/assets_test.go index fd4b9fd07..f3588baa0 100644 --- a/internal/data/assets_test.go +++ b/internal/data/assets_test.go @@ -451,8 +451,6 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { require.NoError(t, err) // 1. Create assets, wallets and disbursements: - country := CreateCountryFixture(t, ctx, dbConnectionPool, "ATL", "Atlantis") - asset1 := CreateAssetFixture(t, ctx, dbConnectionPool, "FOO1", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") asset2 := CreateAssetFixture(t, ctx, dbConnectionPool, "FOO2", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -460,32 +458,28 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { walletB := CreateWalletFixture(t, ctx, dbConnectionPool, "walletB", "https://www.b.com", "www.b.com", "b://") disbursementA1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletA, - Status: ReadyDisbursementStatus, - Asset: asset1, - SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template A1", + Wallet: walletA, + Status: ReadyDisbursementStatus, + Asset: asset1, + ReceiverRegistrationMessageTemplate: "Disbursement SMS Registration Message Template A1", }) disbursementA2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletA, - Status: ReadyDisbursementStatus, - Asset: asset2, - SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template A2", + Wallet: walletA, + Status: ReadyDisbursementStatus, + Asset: asset2, + ReceiverRegistrationMessageTemplate: "Disbursement SMS Registration Message Template A2", }) disbursementB1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletB, - Status: ReadyDisbursementStatus, - Asset: asset1, - SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template B1", + Wallet: walletB, + Status: ReadyDisbursementStatus, + Asset: asset1, + ReceiverRegistrationMessageTemplate: "Disbursement SMS Registration Message Template B1", }) disbursementB2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletB, - Status: ReadyDisbursementStatus, - Asset: asset2, - SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template B2", + Wallet: walletB, + Status: ReadyDisbursementStatus, + Asset: asset2, + ReceiverRegistrationMessageTemplate: "Disbursement SMS Registration Message Template B2", }) // 2. Create receivers, and receiver wallets: @@ -642,13 +636,13 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverX.PhoneNumber, }, ReceiverWalletStats: ReceiverWalletStats{ - TotalInvitationSMSResentAttempts: 2, + TotalInvitationResentAttempts: 2, }, InvitationSentAt: &invitationSentAt, }, - WalletID: walletA.ID, - Asset: *asset1, - DisbursementSMSTemplate: &disbursementA1.SMSRegistrationMessageTemplate, + WalletID: walletA.ID, + Asset: *asset1, + DisbursementReceiverRegistrationMsgTemplate: &disbursementA1.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -660,9 +654,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { }, InvitationSentAt: &invitationSentAt, }, - WalletID: walletA.ID, - Asset: *asset2, - DisbursementSMSTemplate: &disbursementA2.SMSRegistrationMessageTemplate, + WalletID: walletA.ID, + Asset: *asset2, + DisbursementReceiverRegistrationMsgTemplate: &disbursementA2.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -673,9 +667,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverX.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset1, - DisbursementSMSTemplate: &disbursementB1.SMSRegistrationMessageTemplate, + WalletID: walletB.ID, + Asset: *asset1, + DisbursementReceiverRegistrationMsgTemplate: &disbursementB1.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -686,9 +680,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverX.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset2, - DisbursementSMSTemplate: &disbursementB2.SMSRegistrationMessageTemplate, + WalletID: walletB.ID, + Asset: *asset2, + DisbursementReceiverRegistrationMsgTemplate: &disbursementB2.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -699,9 +693,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletA.ID, - Asset: *asset1, - DisbursementSMSTemplate: &disbursementA1.SMSRegistrationMessageTemplate, + WalletID: walletA.ID, + Asset: *asset1, + DisbursementReceiverRegistrationMsgTemplate: &disbursementA1.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -712,9 +706,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletA.ID, - Asset: *asset2, - DisbursementSMSTemplate: &disbursementA2.SMSRegistrationMessageTemplate, + WalletID: walletA.ID, + Asset: *asset2, + DisbursementReceiverRegistrationMsgTemplate: &disbursementA2.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -725,9 +719,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset1, - DisbursementSMSTemplate: &disbursementB1.SMSRegistrationMessageTemplate, + WalletID: walletB.ID, + Asset: *asset1, + DisbursementReceiverRegistrationMsgTemplate: &disbursementB1.ReceiverRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -738,9 +732,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset2, - DisbursementSMSTemplate: &disbursementB2.SMSRegistrationMessageTemplate, + WalletID: walletB.ID, + Asset: *asset2, + DisbursementReceiverRegistrationMsgTemplate: &disbursementB2.ReceiverRegistrationMessageTemplate, }, } diff --git a/internal/data/circle_transfer_requests_test.go b/internal/data/circle_transfer_requests_test.go index 5067b6745..1ce965107 100644 --- a/internal/data/circle_transfer_requests_test.go +++ b/internal/data/circle_transfer_requests_test.go @@ -346,13 +346,11 @@ func Test_CircleTransferRequestModel_GetOrInsert(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, }) receiverReady := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) rwReady := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, ReadyReceiversWalletStatus) @@ -547,13 +545,11 @@ func Test_CircleTransferRequestModel_GetCurrentTransfersForPaymentIDs(t *testing models, outerErr := NewModels(dbConnectionPool) require.NoError(t, outerErr) asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, }) receiverReady := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) rwReady := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, ReadyReceiversWalletStatus) diff --git a/internal/data/countries.go b/internal/data/countries.go deleted file mode 100644 index d05eed75a..000000000 --- a/internal/data/countries.go +++ /dev/null @@ -1,69 +0,0 @@ -package data - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/stellar/stellar-disbursement-platform-backend/db" -) - -type Country struct { - Code string `json:"code" db:"code"` - Name string `json:"name" db:"name"` - CreatedAt time.Time `json:"created_at,omitempty" db:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitempty" db:"updated_at"` - DeletedAt *time.Time `json:"-" db:"deleted_at"` -} - -type CountryModel struct { - dbConnectionPool db.DBConnectionPool -} - -func (m *CountryModel) Get(ctx context.Context, code string) (*Country, error) { - var country Country - query := ` - SELECT - c.code, - c.name, - c.created_at, - c.updated_at - FROM - countries c - WHERE - c.code = $1 - ` - - err := m.dbConnectionPool.GetContext(ctx, &country, query, code) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrRecordNotFound - } - return nil, fmt.Errorf("error querying country code %s: %w", code, err) - } - return &country, nil -} - -// GetAll returns all countries in the database -func (m *CountryModel) GetAll(ctx context.Context) ([]Country, error) { - countries := []Country{} - query := ` - SELECT - c.code, - c.name, - c.created_at, - c.updated_at - FROM - countries c - ORDER BY - c.name ASC - ` - - err := m.dbConnectionPool.SelectContext(ctx, &countries, query) - if err != nil { - return nil, fmt.Errorf("error querying countries: %w", err) - } - return countries, nil -} diff --git a/internal/data/countries_test.go b/internal/data/countries_test.go deleted file mode 100644 index ad3a2ee8f..000000000 --- a/internal/data/countries_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package data - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" -) - -func Test_CountryModelGet(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - ctx := context.Background() - - countryModel := &CountryModel{dbConnectionPool: dbConnectionPool} - - t.Run("returns error when country is not found", func(t *testing.T) { - _, err := countryModel.Get(ctx, "not-found") - require.Error(t, err) - require.Equal(t, ErrRecordNotFound, err) - }) - - t.Run("returns asset successfully", func(t *testing.T) { - expected := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") - actual, err := countryModel.Get(ctx, "FRA") - require.NoError(t, err) - - assert.Equal(t, expected, actual) - }) -} - -func Test_CountryModelGetAll(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - ctx := context.Background() - - countryModel := &CountryModel{dbConnectionPool: dbConnectionPool} - - t.Run("returns all countries successfully", func(t *testing.T) { - expected := ClearAndCreateCountryFixtures(t, ctx, dbConnectionPool) - actual, err := countryModel.GetAll(ctx) - require.NoError(t, err) - - assert.Equal(t, expected, actual) - }) - - t.Run("returns empty array when no countries", func(t *testing.T) { - DeleteAllCountryFixtures(t, ctx, dbConnectionPool) - actual, err := countryModel.GetAll(ctx) - require.NoError(t, err) - - assert.Equal(t, []Country{}, actual) - }) -} diff --git a/internal/data/disbursement_instructions.go b/internal/data/disbursement_instructions.go index 2281a2df4..e529eeda9 100644 --- a/internal/data/disbursement_instructions.go +++ b/internal/data/disbursement_instructions.go @@ -4,16 +4,35 @@ import ( "context" "errors" "fmt" + "slices" + + "github.com/stellar/go/support/log" + "golang.org/x/exp/maps" "github.com/stellar/stellar-disbursement-platform-backend/db" ) type DisbursementInstruction struct { - Phone string `csv:"phone"` - ID string `csv:"id"` - Amount string `csv:"amount"` - VerificationValue string `csv:"verification"` - ExternalPaymentId *string `csv:"paymentID"` + Phone string `csv:"phone"` + Email string `csv:"email"` + ID string `csv:"id"` + Amount string `csv:"amount"` + VerificationValue string `csv:"verification"` + ExternalPaymentId string `csv:"paymentID"` + WalletAddress string `csv:"walletAddress"` +} + +func (di *DisbursementInstruction) Contact() (string, error) { + if di.Phone != "" && di.Email != "" { + return "", errors.New("phone and email are both provided") + } + if di.Phone != "" { + return di.Phone, nil + } + if di.Email != "" { + return di.Email, nil + } + return "", errors.New("phone and email are empty") } type DisbursementInstructionModel struct { @@ -25,11 +44,6 @@ type DisbursementInstructionModel struct { disbursementModel *DisbursementModel } -type InstructionLine struct { - line int - disbursementInstruction *DisbursementInstruction -} - const MaxInstructionsPerDisbursement = 10000 // NewDisbursementInstructionModel creates a new DisbursementInstructionModel. @@ -45,14 +59,23 @@ func NewDisbursementInstructionModel(dbConnectionPool db.DBConnectionPool) *Disb } var ( - ErrMaxInstructionsExceeded = errors.New("maximum number of instructions exceeded") - ErrReceiverVerificationMismatch = errors.New("receiver verification mismatch") + ErrMaxInstructionsExceeded = errors.New("maximum number of instructions exceeded") + ErrReceiverVerificationMismatch = errors.New("receiver verification mismatch") + ErrReceiverWalletAddressMismatch = errors.New("receiver wallet address mismatch") ) +type DisbursementInstructionsOpts struct { + UserID string + Instructions []*DisbursementInstruction + Disbursement *Disbursement + DisbursementUpdate *DisbursementUpdate + MaxNumberOfInstructions int +} + // ProcessAll Processes all disbursement instructions and persists the data to the database. // -// |--- For each phone number in the instructions: -// | |--- Check if a receiver exists. +// |--- For each line in the instructions: +// | |--- Check if a receiver exists by their contact information (phone, email). // | | |--- If a receiver does not exist, create one. // | |--- For each receiver: // | | |--- Check if the receiver verification exists. @@ -62,171 +85,331 @@ var ( // | | | | | |--- If the verification value does not match and the verification is confirmed, return an error. // | | | | | |--- If the verification value does not match and the verification is not confirmed, update the verification value. // | | | | | |--- If the verification value matches, continue. -// | | |--- Check if the receiver wallet exists. +// | | |--- [!ReceiverContactType.IncludesWalletAddress] Check if the receiver wallet exists. // | | | |--- If the receiver wallet does not exist, create one. // | | | |--- If the receiver wallet exists and it's not REGISTERED, retry the invitation SMS. +// | | |--- [ReceiverContactType.IncludesWalletAddress] Register the supplied wallet address // | | |--- Delete all previously existing payments tied to this disbursement. // | | |--- Create all payments passed in the instructions. -func (di DisbursementInstructionModel) ProcessAll(ctx context.Context, userID string, instructions []*DisbursementInstruction, disbursement *Disbursement, update *DisbursementUpdate, maxNumberOfInstructions int) error { - if len(instructions) > maxNumberOfInstructions { +func (di DisbursementInstructionModel) ProcessAll(ctx context.Context, opts DisbursementInstructionsOpts) error { + if len(opts.Instructions) > opts.MaxNumberOfInstructions { return ErrMaxInstructionsExceeded } // We need all the following logic to be executed in one transaction. return db.RunInTransaction(ctx, di.dbConnectionPool, nil, func(dbTx db.DBTransaction) error { - // Step 1: Fetch all receivers by phone number and create missing ones - phoneNumbers := make([]string, 0, len(instructions)) - for _, instruction := range instructions { - phoneNumbers = append(phoneNumbers, instruction.Phone) + // Step 1: Fetch all receivers by contact information (phone, email, etc.) and create missing ones + registrationContactType := opts.Disbursement.RegistrationContactType + receiversByIDMap, err := di.reconcileExistingReceiversWithInstructions(ctx, dbTx, opts.Instructions, registrationContactType.ReceiverContactType) + if err != nil { + return fmt.Errorf("processing receivers: %w", err) } - existingReceivers, err := di.receiverModel.GetByPhoneNumbers(ctx, dbTx, phoneNumbers) + // Step 2: Fetch all receiver wallets and create missing ones + receiverIDToReceiverWalletIDMap, err := di.processReceiverWallets(ctx, dbTx, receiversByIDMap, opts.Disbursement) if err != nil { - return fmt.Errorf("error fetching receivers by phone number: %w", err) + return fmt.Errorf("processing receiver wallets: %w", err) } - // Create a map of existing receivers for easy lookup - receiverMap := make(map[string]*Receiver) - for _, receiver := range existingReceivers { - receiverMap[receiver.PhoneNumber] = receiver + // Step 3: Register supplied wallets or process receiver verifications based on the registration contact type + if registrationContactType.IncludesWalletAddress { + if err = di.registerSuppliedWallets(ctx, dbTx, opts.Instructions, receiversByIDMap, receiverIDToReceiverWalletIDMap); err != nil { + return fmt.Errorf("registering supplied wallets: %w", err) + } + } else { + err = di.processReceiverVerifications(ctx, dbTx, receiversByIDMap, opts.Instructions, opts.Disbursement, registrationContactType.ReceiverContactType) + if err != nil { + return fmt.Errorf("processing receiver verifications: %w", err) + } } - instructionMap := make(map[string]InstructionLine) - for line, instruction := range instructions { - instructionMap[instruction.Phone] = InstructionLine{ - line: line + 1, - disbursementInstruction: instruction, - } + // Step 4: Delete all pre-existing payments tied to this disbursement for each receiver in one call + if err = di.paymentModel.DeleteAllForDisbursement(ctx, dbTx, opts.Disbursement.ID); err != nil { + return fmt.Errorf("deleting payments: %w", err) } - // Create missing receivers - for _, instruction := range instructions { - _, exists := receiverMap[instruction.Phone] - if !exists { - receiverInsert := ReceiverInsert{ - PhoneNumber: instruction.Phone, - ExternalId: &instruction.ID, - } - receiver, insertErr := di.receiverModel.Insert(ctx, dbTx, receiverInsert) - if insertErr != nil { - return fmt.Errorf("error inserting receiver: %w", insertErr) - } - receiverMap[instruction.Phone] = receiver - } + // Step 5: Create payments for all receivers + if err = di.createPayments(ctx, dbTx, receiversByIDMap, receiverIDToReceiverWalletIDMap, opts.Instructions, opts.Disbursement); err != nil { + return fmt.Errorf("creating payments: %w", err) } - // Step 2: Fetch all receiver verifications and create missing ones. - receiverIDs := make([]string, 0, len(receiverMap)) - for _, receiver := range receiverMap { - receiverIDs = append(receiverIDs, receiver.ID) + // Step 6: Persist Payment file to Disbursement + if err = di.disbursementModel.Update(ctx, opts.DisbursementUpdate); err != nil { + return fmt.Errorf("persisting payment file: %w", err) } - verifications, err := di.receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbTx, receiverIDs, disbursement.VerificationField) - if err != nil { - return fmt.Errorf("error fetching receiver verifications: %w", err) + + // Step 7: Update Disbursement Status + if err = di.disbursementModel.UpdateStatus(ctx, dbTx, opts.UserID, opts.Disbursement.ID, ReadyDisbursementStatus); err != nil { + return fmt.Errorf("updating status: %w", err) + } + + return nil + }) +} + +func (di DisbursementInstructionModel) registerSuppliedWallets(ctx context.Context, dbTx db.DBTransaction, instructions []*DisbursementInstruction, receiversByIDMap map[string]*Receiver, receiverIDToReceiverWalletIDMap map[string]string) error { + // Construct a map of receiverWalletID to receiverWallet + receiverWalletsByIDMap, err := di.getReceiverWalletsByIDMap(ctx, dbTx, maps.Values(receiverIDToReceiverWalletIDMap)) + if err != nil { + return fmt.Errorf("building receiver wallets lookup map: %w", err) + } + + // Mark receiver wallets as registered + for _, instruction := range instructions { + receiver := findReceiverByInstruction(receiversByIDMap, instruction) + if receiver == nil { + return fmt.Errorf("receiver not found for instruction with ID %s", instruction.ID) + } + receiverWalletID, exists := receiverIDToReceiverWalletIDMap[receiver.ID] + if !exists { + return fmt.Errorf("receiver wallet not found for receiver with ID %s", receiver.ID) } + receiverWallet := receiverWalletsByIDMap[receiverWalletID] - verificationMap := make(map[string]*ReceiverVerification) - for _, verification := range verifications { - verificationMap[verification.ReceiverID] = verification + if receiverWallet.StellarAddress != "" && receiverWallet.StellarAddress != instruction.WalletAddress { + return fmt.Errorf("%w: receiver wallet address mismatch for receiver with ID %s", ErrReceiverWalletAddressMismatch, receiver.ID) } - for _, receiver := range receiverMap { - verification, verificationExists := verificationMap[receiver.ID] - instruction := instructionMap[receiver.PhoneNumber] - if !verificationExists { - verificationInsert := ReceiverVerificationInsert{ - ReceiverID: receiver.ID, - VerificationValue: instruction.disbursementInstruction.VerificationValue, - VerificationField: disbursement.VerificationField, - } - hashedVerification, insertError := di.receiverVerificationModel.Insert(ctx, dbTx, verificationInsert) - if insertError != nil { - return fmt.Errorf("error inserting receiver verification: %w", insertError) - } - verificationMap[receiver.ID] = &ReceiverVerification{ - ReceiverID: verificationInsert.ReceiverID, - VerificationField: verificationInsert.VerificationField, - HashedValue: hashedVerification, - } + if slices.Contains([]ReceiversWalletStatus{RegisteredReceiversWalletStatus, FlaggedReceiversWalletStatus}, receiverWallet.Status) { + log.Ctx(ctx).Infof("receiver wallet with ID %s is %s, skipping registration", receiverWallet.ID, receiverWallet.Status) + continue + } - } else { - if verified := CompareVerificationValue(verification.HashedValue, instruction.disbursementInstruction.VerificationValue); !verified { - if verification.ConfirmedAt != nil { - return fmt.Errorf("%w: receiver verification for %s doesn't match. Check line %d on CSV file - Internal ID %s", ErrReceiverVerificationMismatch, receiver.PhoneNumber, instruction.line, instruction.disbursementInstruction.ID) - } - err = di.receiverVerificationModel.UpdateVerificationValue(ctx, dbTx, verification.ReceiverID, verification.VerificationField, instruction.disbursementInstruction.VerificationValue) - if err != nil { - return fmt.Errorf("error updating receiver verification for disbursement id %s: %w", disbursement.ID, err) - } - } - } + receiverWalletUpdate := ReceiverWalletUpdate{ + Status: RegisteredReceiversWalletStatus, + StellarAddress: instruction.WalletAddress, } + if updateErr := di.receiverWalletModel.Update(ctx, receiverWalletID, receiverWalletUpdate, dbTx); updateErr != nil { + return fmt.Errorf("marking receiver wallet as registered: %w", updateErr) + } + } + return nil +} + +func (di DisbursementInstructionModel) getReceiverWalletsByIDMap(ctx context.Context, dbTx db.DBTransaction, receiverWalletIDs []string) (map[string]ReceiverWallet, error) { + receiverWallets, err := di.receiverWalletModel.GetByIDs(ctx, dbTx, receiverWalletIDs...) + if err != nil { + return nil, fmt.Errorf("fetching receiver wallets: %w", err) + } + receiverWalletsByIDMap := make(map[string]ReceiverWallet, len(receiverWallets)) + for _, receiverWallet := range receiverWallets { + receiverWalletsByIDMap[receiverWallet.ID] = receiverWallet + } + return receiverWalletsByIDMap, nil +} - // Step 3: Fetch all receiver wallets and create missing ones - receiverWallets, err := di.receiverWalletModel.GetByReceiverIDsAndWalletID(ctx, dbTx, receiverIDs, disbursement.Wallet.ID) +// reconcileExistingReceiversWithInstructions fetches all existing receivers by their contact information and creates missing ones. +func (di DisbursementInstructionModel) reconcileExistingReceiversWithInstructions(ctx context.Context, dbTx db.DBTransaction, instructions []*DisbursementInstruction, contactType ReceiverContactType) (map[string]*Receiver, error) { + // Step 1: Fetch existing receivers + contacts := make([]string, 0, len(instructions)) + for _, instruction := range instructions { + contact, err := instruction.Contact() if err != nil { - return fmt.Errorf("error fetching receiver wallets: %w", err) + return nil, fmt.Errorf("resolving contact information for instruction with ID %s: %w", instruction.ID, err) } - receiverIDToReceiverWalletIDMap := make(map[string]string) - for _, receiverWallet := range receiverWallets { - receiverIDToReceiverWalletIDMap[receiverWallet.Receiver.ID] = receiverWallet.ID + contacts = append(contacts, contact) + } + + existingReceivers, err := di.receiverModel.GetByContacts(ctx, dbTx, contacts...) + if err != nil { + return nil, fmt.Errorf("fetching receivers by contacts: %w", err) + } + + // Step 2: Create maps for quick lookup + existingReceiversByContactMap := make(map[string]*Receiver) + for _, receiver := range existingReceivers { + contact := receiver.ContactByType(contactType) + if contact == "" { + return nil, fmt.Errorf("receiver with ID %s has no contact information for contact type %s", receiver.ID, contactType) } + existingReceiversByContactMap[contact] = receiver + } - for _, receiverId := range receiverIDs { - receiverWalletId, exists := receiverIDToReceiverWalletIDMap[receiverId] - if !exists { - receiverWalletInsert := ReceiverWalletInsert{ - ReceiverID: receiverId, - WalletID: disbursement.Wallet.ID, - } - rwID, insertErr := di.receiverWalletModel.Insert(ctx, dbTx, receiverWalletInsert) - if insertErr != nil { - return fmt.Errorf("error inserting receiver wallet for receiver id %s: %w", receiverId, insertErr) - } - receiverIDToReceiverWalletIDMap[receiverId] = rwID - } else { - _, retryErr := di.receiverWalletModel.RetryInvitationSMS(ctx, dbTx, receiverWalletId) - if retryErr != nil { - if !errors.Is(retryErr, ErrRecordNotFound) { - return fmt.Errorf("error retrying invitation: %w", retryErr) - } - } - } + // Step 3: Create missing receivers from instructions + for _, instruction := range instructions { + if createErr := di.createReceiverFromInstructionIfNeeded(ctx, dbTx, instruction, existingReceiversByContactMap); createErr != nil { + return nil, fmt.Errorf("creating receiver from instruction: %w", createErr) + } + } + + // Step 4: Fetch all receivers again + receivers, err := di.receiverModel.GetByContacts(ctx, dbTx, contacts...) + if err != nil { + return nil, fmt.Errorf("fetching receivers by contact information: %w", err) + } + + if len(receivers) != len(instructions) { + return nil, fmt.Errorf("receiver count mismatch after processing instructions") + } + + receiversByIDMap := make(map[string]*Receiver) + for _, receiver := range receivers { + receiversByIDMap[receiver.ID] = receiver + } + + return receiversByIDMap, nil +} + +// createReceiverFromInstructionIfNeeded create a new receiver if it doesn't exist for the given instruction. +func (di DisbursementInstructionModel) createReceiverFromInstructionIfNeeded(ctx context.Context, dbTx db.DBTransaction, instruction *DisbursementInstruction, existingReceiversByContactMap map[string]*Receiver) error { + contact, err := instruction.Contact() + if err != nil { + return fmt.Errorf("resolving contact information for instruction with ID %s: %w", instruction.ID, err) + } + + _, exists := existingReceiversByContactMap[contact] + if !exists { + var receiverInsert ReceiverInsert + if instruction.Phone != "" { + receiverInsert.PhoneNumber = &instruction.Phone } + if instruction.Email != "" { + receiverInsert.Email = &instruction.Email + } + if instruction.ID != "" { + receiverInsert.ExternalId = &instruction.ID + } + _, insertErr := di.receiverModel.Insert(ctx, dbTx, receiverInsert) + if insertErr != nil { + return fmt.Errorf("inserting receiver: %w", insertErr) + } + } + + return nil +} + +func (di DisbursementInstructionModel) processReceiverVerifications(ctx context.Context, dbTx db.DBTransaction, receiversByIDMap map[string]*Receiver, instructions []*DisbursementInstruction, disbursement *Disbursement, contactType ReceiverContactType) error { + receiverIDs := maps.Keys(receiversByIDMap) - // Step 4: Delete all payments tied to this disbursement for each receiver in one call - if err = di.paymentModel.DeleteAllForDisbursement(ctx, dbTx, disbursement.ID); err != nil { - return fmt.Errorf("error deleting payments: %w", err) + verifications, err := di.receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbTx, receiverIDs, disbursement.VerificationField) + if err != nil { + return fmt.Errorf("fetching receiver verifications: %w", err) + } + + verificationByReceiverIDMap := make(map[string]*ReceiverVerification) + for _, verification := range verifications { + verificationByReceiverIDMap[verification.ReceiverID] = verification + } + + instructionsByContactMap := make(map[string]*DisbursementInstruction) + for _, instruction := range instructions { + contact, err := instruction.Contact() + if err != nil { + return fmt.Errorf("resolving contact information for instruction with ID %s: %w", instruction.ID, err) } + instructionsByContactMap[contact] = instruction + } - // Step 5: Create payments for all receivers - payments := make([]PaymentInsert, 0, len(instructions)) - for _, instruction := range instructions { - receiver := receiverMap[instruction.Phone] - payment := PaymentInsert{ + for _, receiver := range receiversByIDMap { + contact := receiver.ContactByType(contactType) + if contact == "" { + return fmt.Errorf("receiver with ID %s has no contact information for contact type %s", receiver.ID, contactType) + } + instruction := instructionsByContactMap[contact] + if instruction == nil { + return fmt.Errorf("instruction not found for receiver with ID %s", receiver.ID) + } + verification, exists := verificationByReceiverIDMap[receiver.ID] + + if !exists { + verificationInsert := ReceiverVerificationInsert{ ReceiverID: receiver.ID, - DisbursementID: disbursement.ID, - Amount: instruction.Amount, - AssetID: disbursement.Asset.ID, - ReceiverWalletID: receiverIDToReceiverWalletIDMap[receiver.ID], - ExternalPaymentID: instruction.ExternalPaymentId, + VerificationValue: instruction.VerificationValue, + VerificationField: disbursement.VerificationField, + } + _, insertErr := di.receiverVerificationModel.Insert(ctx, dbTx, verificationInsert) + if insertErr != nil { + return fmt.Errorf("error inserting receiver verification: %w", insertErr) + } + } else if !CompareVerificationValue(verification.HashedValue, instruction.VerificationValue) { + if verification.ConfirmedAt != nil { + return fmt.Errorf("%w: receiver verification for %s doesn't match. Check instruction with ID %s", ErrReceiverVerificationMismatch, contact, instruction.ID) + } + updateErr := di.receiverVerificationModel.UpdateVerificationValue(ctx, dbTx, verification.ReceiverID, verification.VerificationField, instruction.VerificationValue) + if updateErr != nil { + return fmt.Errorf("error updating receiver verification for disbursement id %s: %w", disbursement.ID, updateErr) } - payments = append(payments, payment) - } - if err = di.paymentModel.InsertAll(ctx, dbTx, payments); err != nil { - return fmt.Errorf("error inserting payments: %w", err) } + } - // Step 6: Persist Payment file to Disbursement - if err = di.disbursementModel.Update(ctx, update); err != nil { - return fmt.Errorf("error persisting payment file: %w", err) + return nil +} + +func (di DisbursementInstructionModel) processReceiverWallets(ctx context.Context, dbTx db.DBTransaction, receiversByIDMap map[string]*Receiver, disbursement *Disbursement) (map[string]string, error) { + receiverIDs := maps.Keys(receiversByIDMap) + + receiverWallets, err := di.receiverWalletModel.GetByReceiverIDsAndWalletID(ctx, dbTx, receiverIDs, disbursement.Wallet.ID) + if err != nil { + return nil, fmt.Errorf("fetching receiver wallets: %w", err) + } + receiverIDToReceiverWalletIDMap := make(map[string]string) + for _, receiverWallet := range receiverWallets { + receiverIDToReceiverWalletIDMap[receiverWallet.Receiver.ID] = receiverWallet.ID + } + + for receiverID := range receiversByIDMap { + receiverWalletID, exists := receiverIDToReceiverWalletIDMap[receiverID] + if !exists { + receiverWalletInsert := ReceiverWalletInsert{ + ReceiverID: receiverID, + WalletID: disbursement.Wallet.ID, + } + rwID, insertErr := di.receiverWalletModel.Insert(ctx, dbTx, receiverWalletInsert) + if insertErr != nil { + return nil, fmt.Errorf("inserting receiver wallet for receiver id %s: %w", receiverID, insertErr) + } + receiverIDToReceiverWalletIDMap[receiverID] = rwID + } else { + _, retryErr := di.receiverWalletModel.RetryInvitationMessage(ctx, dbTx, receiverWalletID) + if retryErr != nil { + if !errors.Is(retryErr, ErrRecordNotFound) { + return nil, fmt.Errorf("retrying invitation: %w", retryErr) + } + } } + } - // Step 7: Update Disbursement Status - if err = di.disbursementModel.UpdateStatus(ctx, dbTx, userID, disbursement.ID, ReadyDisbursementStatus); err != nil { - return fmt.Errorf("error updating status: %w", err) + return receiverIDToReceiverWalletIDMap, nil +} + +func (di DisbursementInstructionModel) createPayments(ctx context.Context, dbTx db.DBTransaction, receiverMap map[string]*Receiver, receiverIDToReceiverWalletIDMap map[string]string, instructions []*DisbursementInstruction, disbursement *Disbursement) error { + payments := make([]PaymentInsert, 0, len(instructions)) + + for _, instruction := range instructions { + receiver := findReceiverByInstruction(receiverMap, instruction) + if receiver == nil { + return fmt.Errorf("receiver not found for instruction with ID %s", instruction.ID) + } + payment := PaymentInsert{ + ReceiverID: receiver.ID, + DisbursementID: disbursement.ID, + Amount: instruction.Amount, + AssetID: disbursement.Asset.ID, + ReceiverWalletID: receiverIDToReceiverWalletIDMap[receiver.ID], + } + if instruction.ExternalPaymentId != "" { + payment.ExternalPaymentID = &instruction.ExternalPaymentId } + payments = append(payments, payment) + } + + if err := di.paymentModel.InsertAll(ctx, dbTx, payments); err != nil { + return fmt.Errorf("inserting payments: %w", err) + } + return nil +} + +func findReceiverByInstruction(receiverMap map[string]*Receiver, instruction *DisbursementInstruction) *Receiver { + contact, err := instruction.Contact() + if err != nil { return nil - }) + } + + for _, receiver := range receiverMap { + if contact == receiver.PhoneNumber || contact == receiver.Email { + return receiver + } + } + return nil } diff --git a/internal/data/disbursement_instructions_test.go b/internal/data/disbursement_instructions_test.go index 84082614e..fcccf1b71 100644 --- a/internal/data/disbursement_instructions_test.go +++ b/internal/data/disbursement_instructions_test.go @@ -3,6 +3,7 @@ package data import ( "context" "database/sql" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -23,65 +24,253 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { ctx := context.Background() asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, &DisbursementModel{dbConnectionPool: dbConnectionPool}, Disbursement{ - Name: "disbursement1", - Asset: asset, - Country: country, - Wallet: wallet, + Name: "disbursement1", + Asset: asset, + Wallet: wallet, + }) + + emailDisbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, &DisbursementModel{dbConnectionPool: dbConnectionPool}, Disbursement{ + Name: "disbursement2", + Asset: asset, + Wallet: wallet, + RegistrationContactType: RegistrationContactTypeEmail, }) di := NewDisbursementInstructionModel(dbConnectionPool) - instruction1 := DisbursementInstruction{ + smsInstruction1 := DisbursementInstruction{ Phone: "+380-12-345-671", Amount: "100.01", ID: "123456781", VerificationValue: "1990-01-01", } - instruction2 := DisbursementInstruction{ + smsInstruction2 := DisbursementInstruction{ Phone: "+380-12-345-672", Amount: "100.02", ID: "123456782", VerificationValue: "1990-01-02", } - externalPaymentID := "abc123" - instruction3 := DisbursementInstruction{ + smsInstruction3 := DisbursementInstruction{ Phone: "+380-12-345-673", Amount: "100.03", ID: "123456783", VerificationValue: "1990-01-03", - ExternalPaymentId: &externalPaymentID, + ExternalPaymentId: "abc123", + } + + emailInstruction1 := DisbursementInstruction{ + Email: "receiver1@stellar.org", + Amount: "100.01", + ID: "123456781", + VerificationValue: "1990-01-01", + } + + emailInstruction2 := DisbursementInstruction{ + Email: "receiver2@stellar.org", + Amount: "100.02", + ID: "123456782", + VerificationValue: "1990-01-02", } - instructions := []*DisbursementInstruction{&instruction1, &instruction2, &instruction3} - expectedPhoneNumbers := []string{instruction1.Phone, instruction2.Phone, instruction3.Phone} - expectedExternalIDs := []string{instruction1.ID, instruction2.ID, instruction3.ID} - expectedPayments := []string{instruction1.Amount, instruction2.Amount, instruction3.Amount} - expectedExternalPaymentIDs := []string{*instruction3.ExternalPaymentId} + + emailInstruction3 := DisbursementInstruction{ + Email: "receiver3@stellar.org", + Amount: "100.03", + ID: "123456783", + VerificationValue: "1990-01-03", + } + + smsInstructions := []*DisbursementInstruction{&smsInstruction1, &smsInstruction2, &smsInstruction3} + emailInstructions := []*DisbursementInstruction{&emailInstruction1, &emailInstruction2, &emailInstruction3} + expectedPhoneNumbers := []string{smsInstruction1.Phone, smsInstruction2.Phone, smsInstruction3.Phone} + expectedEmails := []string{emailInstruction1.Email, emailInstruction2.Email, emailInstruction3.Email} + expectedExternalIDs := []string{smsInstruction1.ID, smsInstruction2.ID, smsInstruction3.ID} + expectedPayments := []string{smsInstruction1.Amount, smsInstruction2.Amount, smsInstruction3.Amount} + expectedExternalPaymentIDs := []string{smsInstruction3.ExternalPaymentId} disbursementUpdate := &DisbursementUpdate{ ID: disbursement.ID, FileName: "instructions.csv", - FileContent: CreateInstructionsFixture(t, instructions), + FileContent: CreateInstructionsFixture(t, smsInstructions), + } + + knownWalletDisbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, &DisbursementModel{dbConnectionPool: dbConnectionPool}, Disbursement{ + Name: "disbursement with provided receiver wallets", + Asset: asset, + Wallet: wallet, + RegistrationContactType: RegistrationContactTypePhoneAndWalletAddress, + }) + + knownWalletDisbursementUpdate := func(instructions []*DisbursementInstruction) *DisbursementUpdate { + return &DisbursementUpdate{ + ID: knownWalletDisbursement.ID, + FileName: "instructions.csv", + FileContent: CreateInstructionsFixture(t, instructions), + } } - t.Run("success", func(t *testing.T) { - err := di.ProcessAll(ctx, "user-id", instructions, disbursement, disbursementUpdate, MaxInstructionsPerDisbursement) + cleanup := func() { + DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + DeleteAllReceiverVerificationFixtures(t, ctx, dbConnectionPool) + DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + } + + t.Run("failure - invalid wallet address for known wallet address instructions", func(t *testing.T) { + defer cleanup() + + instructions := []*DisbursementInstruction{ + { + WalletAddress: "GCVL44LFV3BFI627ABY3YRITFBRJVXUQVPLXQ3LISMI5UVKS5LHWTPT6", + Amount: "100.01", + ID: "1", + Phone: "+380-12-345-679", + }, + } + + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: instructions, + Disbursement: knownWalletDisbursement, + DisbursementUpdate: knownWalletDisbursementUpdate(instructions), + MaxNumberOfInstructions: 10, + }) + assert.ErrorContains(t, err, "validating receiver wallet update: invalid stellar address") + }) + + t.Run("failure - receiver wallet address mismatch for known wallet address instructions", func(t *testing.T) { + defer cleanup() + + firstInstruction := []*DisbursementInstruction{ + { + WalletAddress: "GCVL44LFV3BFI627ABY3YRITFBRJVXUQVPLXQ3LISMI5UVKS5LHWTPT7", + Amount: "100.01", + ID: "1", + Phone: "+380-12-345-671", + }, + } + update := knownWalletDisbursementUpdate(firstInstruction) + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: firstInstruction, + Disbursement: knownWalletDisbursement, + DisbursementUpdate: update, + MaxNumberOfInstructions: 10, + }) + require.NoError(t, err) + + mismatchAddressInstruction := []*DisbursementInstruction{ + { + WalletAddress: "GC524YE6Z6ISMNLHWFYXQZRR5DTF2A75DYE5TE6G7UMZJ6KZRNVHPOQS", + Amount: "100.02", + ID: "1", + Phone: "+380-12-345-671", + }, + } + mismatchUpdate := knownWalletDisbursementUpdate(mismatchAddressInstruction) + err = di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: mismatchAddressInstruction, + Disbursement: knownWalletDisbursement, + DisbursementUpdate: mismatchUpdate, + MaxNumberOfInstructions: 10, + }) + assert.ErrorIs(t, err, ErrReceiverWalletAddressMismatch) + }) + + t.Run("success - known wallet address instructions", func(t *testing.T) { + defer cleanup() + + instructions := []*DisbursementInstruction{ + { + WalletAddress: "GCVL44LFV3BFI627ABY3YRITFBRJVXUQVPLXQ3LISMI5UVKS5LHWTPT7", + Amount: "100.01", + ID: "1", + Phone: "+380-12-345-671", + }, + { + WalletAddress: "GC524YE6Z6ISMNLHWFYXQZRR5DTF2A75DYE5TE6G7UMZJ6KZRNVHPOQS", + Amount: "100.02", + ID: "2", + Phone: "+380-12-345-672", + }, + } + + update := knownWalletDisbursementUpdate(instructions) + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: instructions, + Disbursement: knownWalletDisbursement, + DisbursementUpdate: update, + MaxNumberOfInstructions: 10, + }) + require.NoError(t, err) + + // Verify Receivers + receivers, err := di.receiverModel.GetByContacts(ctx, dbConnectionPool, instructions[0].Phone, instructions[1].Phone) + require.NoError(t, err) + assertEqualReceivers(t, []string{instructions[0].Phone, instructions[1].Phone}, []string{"1", "2"}, receivers) + + // Verify Receiver Verifications + receiver1Verifications, err := di.receiverVerificationModel.GetAllByReceiverId(ctx, dbConnectionPool, receivers[0].ID) + require.NoError(t, err) + assert.Len(t, receiver1Verifications, 0) + receiver2Verifications, err := di.receiverVerificationModel.GetAllByReceiverId(ctx, dbConnectionPool, receivers[1].ID) + require.NoError(t, err) + assert.Len(t, receiver2Verifications, 0) + + // Verify Receiver Wallets + receiverWallets, err := di.receiverWalletModel.GetWithReceiverIds(ctx, dbConnectionPool, []string{receivers[0].ID, receivers[1].ID}) + require.NoError(t, err) + assert.Len(t, receiverWallets, 2) + for _, receiverWallet := range receiverWallets { + assert.Equal(t, wallet.ID, receiverWallet.Wallet.ID) + assert.Contains(t, []string{instructions[0].WalletAddress, instructions[1].WalletAddress}, receiverWallet.StellarAddress) + assert.Equal(t, RegisteredReceiversWalletStatus, receiverWallet.Status) + } + + // Verify Payments + actualPayments := GetPaymentsByDisbursementID(t, ctx, dbConnectionPool, knownWalletDisbursement.ID) + assert.Len(t, actualPayments, 2) + assert.Contains(t, actualPayments, instructions[0].Amount) + assert.Contains(t, actualPayments, instructions[1].Amount) + + actualExternalPaymentIDs := GetExternalPaymentIDsByDisbursementID(t, ctx, dbConnectionPool, knownWalletDisbursement.ID) + assert.Len(t, actualExternalPaymentIDs, 0) + + // Verify Disbursement + actualDisbursement, err := di.disbursementModel.Get(ctx, dbConnectionPool, knownWalletDisbursement.ID) + require.NoError(t, err) + assert.Equal(t, ReadyDisbursementStatus, actualDisbursement.Status) + assert.Equal(t, update.FileContent, actualDisbursement.FileContent) + assert.Equal(t, update.FileName, actualDisbursement.FileName) + }) + + t.Run("success - sms instructions", func(t *testing.T) { + defer cleanup() + + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.NoError(t, err) // Verify Receivers - receivers, err := di.receiverModel.GetByPhoneNumbers(ctx, dbConnectionPool, []string{instruction1.Phone, instruction2.Phone, instruction3.Phone}) + receivers, err := di.receiverModel.GetByContacts(ctx, dbConnectionPool, smsInstruction1.Phone, smsInstruction2.Phone, smsInstruction3.Phone) require.NoError(t, err) assertEqualReceivers(t, expectedPhoneNumbers, expectedExternalIDs, receivers) // Verify ReceiverVerifications - receiverVerifications, err := di.receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receivers[0].ID, receivers[1].ID, receivers[2].ID}, VerificationFieldDateOfBirth) + receiverVerifications, err := di.receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receivers[0].ID, receivers[1].ID, receivers[2].ID}, VerificationTypeDateOfBirth) require.NoError(t, err) - assertEqualVerifications(t, instructions, receiverVerifications, receivers) + assertEqualVerifications(t, smsInstructions, receiverVerifications, receivers) // Verify ReceiverWallets receiverWallets, err := di.receiverWalletModel.GetByReceiverIDsAndWalletID(ctx, dbConnectionPool, []string{receivers[0].ID, receivers[1].ID, receivers[2].ID}, wallet.ID) @@ -108,29 +297,98 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { require.Equal(t, disbursementUpdate.FileName, actualDisbursement.FileName) }) + t.Run("success - email instructions", func(t *testing.T) { + defer cleanup() + + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: emailInstructions, + Disbursement: emailDisbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) + require.NoError(t, err) + + // Verify Receivers + receivers, err := di.receiverModel.GetByContacts(ctx, dbConnectionPool, emailInstruction1.Email, emailInstruction2.Email, emailInstruction3.Email) + require.NoError(t, err) + assert.Len(t, receivers, len(expectedEmails)) + for _, actual := range receivers { + assert.Empty(t, actual.PhoneNumber) + assert.Contains(t, expectedEmails, actual.Email) + assert.Contains(t, expectedExternalIDs, actual.ExternalID) + } + }) + + t.Run("failure - email instructions without email fields", func(t *testing.T) { + defer cleanup() + + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: emailDisbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) + require.ErrorContains(t, err, "has no contact information for contact type EMAIL") + }) + + t.Run("failure - email instructions with email and phone fields", func(t *testing.T) { + defer cleanup() + + emailAndSMSInstructions := []*DisbursementInstruction{ + { + Phone: "+380-12-345-671", + Email: "receiver1@stellar.org", + Amount: "100.01", + ID: "123456781", + VerificationValue: "1990-01-01", + }, + } + + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: emailAndSMSInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) + errorMsg := "processing receivers: resolving contact information for instruction with ID %s: phone and email are both provided" + assert.ErrorContains(t, err, fmt.Sprintf(errorMsg, emailAndSMSInstructions[0].ID)) + }) + t.Run("success - Not confirmed Verification Value updated", func(t *testing.T) { - DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiverVerificationFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + defer cleanup() // process instructions for the first time - err := di.ProcessAll(ctx, "user-id", instructions, disbursement, disbursementUpdate, MaxInstructionsPerDisbursement) + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.NoError(t, err) - instruction1.VerificationValue = "1990-01-04" - err = di.ProcessAll(ctx, "user-id", instructions, disbursement, disbursementUpdate, MaxInstructionsPerDisbursement) + smsInstruction1.VerificationValue = "1990-01-04" + err = di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.NoError(t, err) // Verify Receivers - receivers, err := di.receiverModel.GetByPhoneNumbers(ctx, dbConnectionPool, []string{instruction1.Phone, instruction2.Phone, instruction3.Phone}) + receivers, err := di.receiverModel.GetByContacts(ctx, dbConnectionPool, smsInstruction1.Phone, smsInstruction2.Phone, smsInstruction3.Phone) require.NoError(t, err) assertEqualReceivers(t, expectedPhoneNumbers, expectedExternalIDs, receivers) // Verify ReceiverVerifications - receiverVerifications, err := di.receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receivers[0].ID, receivers[1].ID, receivers[2].ID}, VerificationFieldDateOfBirth) + receiverVerifications, err := di.receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receivers[0].ID, receivers[1].ID, receivers[2].ID}, VerificationTypeDateOfBirth) require.NoError(t, err) - assertEqualVerifications(t, instructions, receiverVerifications, receivers) + assertEqualVerifications(t, smsInstructions, receiverVerifications, receivers) // Verify Disbursement actualDisbursement, err := di.disbursementModel.Get(ctx, dbConnectionPool, disbursement.ID) @@ -141,13 +399,14 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { }) t.Run("success - existing receiver wallet", func(t *testing.T) { + defer cleanup() + // New instructions readyDisbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, &DisbursementModel{dbConnectionPool: dbConnectionPool}, &Disbursement{ - Name: "readyDisbursement", - Country: country, - Wallet: wallet, - Asset: asset, - Status: ReadyDisbursementStatus, + Name: "readyDisbursement", + Wallet: wallet, + Asset: asset, + Status: ReadyDisbursementStatus, }) newInstruction1 := DisbursementInstruction{ @@ -180,10 +439,16 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { FileContent: CreateInstructionsFixture(t, newInstructions), } - err := di.ProcessAll(ctx, "user-id", newInstructions, readyDisbursement, readyDisbursementUpdate, MaxInstructionsPerDisbursement) + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: newInstructions, + Disbursement: readyDisbursement, + DisbursementUpdate: readyDisbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.NoError(t, err) - receivers, err := di.receiverModel.GetByPhoneNumbers(ctx, dbConnectionPool, []string{newInstruction1.Phone, newInstruction2.Phone, newInstruction3.Phone}) + receivers, err := di.receiverModel.GetByContacts(ctx, dbConnectionPool, newInstruction1.Phone, newInstruction2.Phone, newInstruction3.Phone) require.NoError(t, err) assertEqualReceivers(t, newExpectedPhoneNumbers, newExpectedExternalIDs, receivers) @@ -217,7 +482,13 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { assert.NotNil(t, receiverWallet.InvitationSentAt) } - err = di.ProcessAll(ctx, "user-id", newInstructions, readyDisbursement, readyDisbursementUpdate, MaxInstructionsPerDisbursement) + err = di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: newInstructions, + Disbursement: readyDisbursement, + DisbursementUpdate: readyDisbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.NoError(t, err) // Verify ReceiverWallets @@ -237,45 +508,31 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { }) t.Run("failure - Too many instructions", func(t *testing.T) { - err := di.ProcessAll(ctx, "user-id", instructions, disbursement, disbursementUpdate, 2) + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: 2, + }) require.EqualError(t, err, "maximum number of instructions exceeded") }) t.Run("failure - Confirmed Verification Value not matching", func(t *testing.T) { - DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiverVerificationFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) - - instruction4 := DisbursementInstruction{ - Phone: "+380-12-345-674", - Amount: "100.04", - ID: "123456784", - VerificationValue: "1990-01-04", - ExternalPaymentId: &externalPaymentID, - } - - instruction5 := DisbursementInstruction{ - Phone: "+380-12-345-675", - Amount: "100.05", - ID: "123456785", - VerificationValue: "1990-01-05", - ExternalPaymentId: &externalPaymentID, - } - - instruction6 := DisbursementInstruction{ - Phone: "+380-12-345-676", - Amount: "100.06", - ID: "123456786", - VerificationValue: "1990-01-06", - ExternalPaymentId: &externalPaymentID, - } + defer cleanup() // process instructions for the first time - err := di.ProcessAll(ctx, "user-id", instructions, disbursement, disbursementUpdate, MaxInstructionsPerDisbursement) + err := di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.NoError(t, err) - receivers, err := di.receiverModel.GetByPhoneNumbers(ctx, dbConnectionPool, []string{instruction1.Phone, instruction2.Phone, instruction3.Phone, instruction4.Phone, instruction5.Phone, instruction6.Phone}) + receivers, err := di.receiverModel.GetByContacts(ctx, dbConnectionPool, smsInstruction1.Phone, smsInstruction2.Phone, smsInstruction3.Phone) + require.Len(t, receivers, 3) require.NoError(t, err) receiversMap := make(map[string]*Receiver) for _, receiver := range receivers { @@ -283,13 +540,19 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { } // confirm a verification - ConfirmVerificationForRecipient(t, ctx, dbConnectionPool, receiversMap[instruction3.Phone].ID) + ConfirmVerificationForRecipient(t, ctx, dbConnectionPool, receiversMap[smsInstruction3.Phone].ID) // process instructions with mismatched verification values - instruction3.VerificationValue = "1990-01-07" - err = di.ProcessAll(ctx, "user-id", instructions, disbursement, disbursementUpdate, MaxInstructionsPerDisbursement) + smsInstruction3.VerificationValue = "1990-01-07" + err = di.ProcessAll(ctx, DisbursementInstructionsOpts{ + UserID: "user-id", + Instructions: smsInstructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: MaxInstructionsPerDisbursement, + }) require.Error(t, err) - assert.EqualError(t, err, "running atomic function in RunInTransactionWithResult: receiver verification mismatch: receiver verification for +380-12-345-673 doesn't match. Check line 3 on CSV file - Internal ID 123456783") + assert.EqualError(t, err, "running atomic function in RunInTransactionWithResult: processing receiver verifications: receiver verification mismatch: receiver verification for +380-12-345-673 doesn't match. Check instruction with ID 123456783") }) } @@ -297,6 +560,7 @@ func assertEqualReceivers(t *testing.T, expectedPhones, expectedExternalIDs []st assert.Len(t, actualReceivers, len(expectedPhones)) for _, actual := range actualReceivers { + assert.NotNil(t, actual.PhoneNumber) assert.Contains(t, expectedPhones, actual.PhoneNumber) assert.Contains(t, expectedExternalIDs, actual.ExternalID) } diff --git a/internal/data/disbursement_receivers.go b/internal/data/disbursement_receivers.go index b6cfa6353..9f4738968 100644 --- a/internal/data/disbursement_receivers.go +++ b/internal/data/disbursement_receivers.go @@ -46,8 +46,8 @@ func (m DisbursementReceiverModel) GetAll(ctx context.Context, sqlExec db.SQLExe baseQuery := ` SELECT r.id, - r.phone_number, r.external_id, + COALESCE(r.phone_number, '') as phone_number, COALESCE(r.email, '') as email, r.created_at, r.updated_at, diff --git a/internal/data/disbursement_receivers_test.go b/internal/data/disbursement_receivers_test.go index 9d964f914..061c199ba 100644 --- a/internal/data/disbursement_receivers_test.go +++ b/internal/data/disbursement_receivers_test.go @@ -25,14 +25,12 @@ func Test_DisbursementReceiverModel_Count(t *testing.T) { paymentModel := &PaymentModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, disbursementModel, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, }) receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) @@ -87,14 +85,12 @@ func Test_DisbursementReceiverModel_GetAll(t *testing.T) { paymentModel := &PaymentModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, disbursementModel, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, }) receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) diff --git a/internal/data/disbursements.go b/internal/data/disbursements.go index 8010d27f4..e82fbefd5 100644 --- a/internal/data/disbursements.go +++ b/internal/data/disbursements.go @@ -14,22 +14,23 @@ import ( "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type Disbursement struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Country *Country `json:"country,omitempty" db:"country"` - Wallet *Wallet `json:"wallet,omitempty" db:"wallet"` - Asset *Asset `json:"asset,omitempty" db:"asset"` - Status DisbursementStatus `json:"status" db:"status"` - VerificationField VerificationField `json:"verification_field,omitempty" db:"verification_field"` - StatusHistory DisbursementStatusHistory `json:"status_history,omitempty" db:"status_history"` - SMSRegistrationMessageTemplate string `json:"sms_registration_message_template" db:"sms_registration_message_template"` - FileName string `json:"file_name,omitempty" db:"file_name"` - FileContent []byte `json:"-" db:"file_content"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Wallet *Wallet `json:"wallet,omitempty" db:"wallet"` + Asset *Asset `json:"asset,omitempty" db:"asset"` + Status DisbursementStatus `json:"status" db:"status"` + VerificationField VerificationType `json:"verification_field,omitempty" db:"verification_field"` + StatusHistory DisbursementStatusHistory `json:"status_history,omitempty" db:"status_history"` + ReceiverRegistrationMessageTemplate string `json:"receiver_registration_message_template" db:"receiver_registration_message_template"` + FileName string `json:"file_name,omitempty" db:"file_name"` + FileContent []byte `json:"-" db:"file_content"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + RegistrationContactType RegistrationContactType `json:"registration_contact_type,omitempty" db:"registration_contact_type"` *DisbursementStats } @@ -52,25 +53,6 @@ type DisbursementUpdate struct { FileContent []byte } -type VerificationField string - -const ( - VerificationFieldDateOfBirth VerificationField = "DATE_OF_BIRTH" - VerificationFieldYearMonth VerificationField = "YEAR_MONTH" - VerificationFieldPin VerificationField = "PIN" - VerificationFieldNationalID VerificationField = "NATIONAL_ID_NUMBER" -) - -// GetAllVerificationFields returns all verification fields -func GetAllVerificationFields() []VerificationField { - return []VerificationField{ - VerificationFieldDateOfBirth, - VerificationFieldYearMonth, - VerificationFieldPin, - VerificationFieldNationalID, - } -} - type DisbursementStatusHistoryEntry struct { UserID string `json:"user_id"` Status DisbursementStatus `json:"status"` @@ -90,21 +72,21 @@ var ( func (d *DisbursementModel) Insert(ctx context.Context, disbursement *Disbursement) (string, error) { const q = ` INSERT INTO - disbursements (name, status, status_history, wallet_id, asset_id, country_code, verification_field, sms_registration_message_template) + disbursements (name, status, status_history, wallet_id, asset_id, verification_field, receiver_registration_message_template, registration_contact_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id - ` - var newId string - err := d.dbConnectionPool.GetContext(ctx, &newId, q, + ` + var newID string + err := d.dbConnectionPool.GetContext(ctx, &newID, q, disbursement.Name, disbursement.Status, disbursement.StatusHistory, disbursement.Wallet.ID, disbursement.Asset.ID, - disbursement.Country.Code, - disbursement.VerificationField, - disbursement.SMSRegistrationMessageTemplate, + utils.SQLNullString(string(disbursement.VerificationField)), + disbursement.ReceiverRegistrationMessageTemplate, + disbursement.RegistrationContactType, ) if err != nil { // check if the error is a duplicate key error @@ -114,7 +96,7 @@ func (d *DisbursementModel) Insert(ctx context.Context, disbursement *Disburseme return "", fmt.Errorf("unable to create disbursement %s: %w", disbursement.Name, err) } - return newId, nil + return newID, nil } func (d *DisbursementModel) GetWithStatistics(ctx context.Context, id string) (*Disbursement, error) { @@ -131,22 +113,19 @@ func (d *DisbursementModel) GetWithStatistics(ctx context.Context, id string) (* return disbursement, nil } -func (d *DisbursementModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id string) (*Disbursement, error) { - var disbursement Disbursement - - query := ` +const selectDisbursementQuery = ` SELECT d.id, d.name, d.status, d.status_history, - d.verification_field, + COALESCE(d.verification_field::text, '') as verification_field, COALESCE(d.file_name, '') as file_name, d.file_content, d.created_at, d.updated_at, - d.verification_field, - COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, + d.registration_contact_type, + COALESCE(d.receiver_registration_message_template, '') as receiver_registration_message_template, w.id as "wallet.id", w.name as "wallet.name", w.homepage as "wallet.homepage", @@ -159,19 +138,17 @@ func (d *DisbursementModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id a.code as "asset.code", a.issuer as "asset.issuer", a.created_at as "asset.created_at", - a.updated_at as "asset.updated_at", - c.code as "country.code", - c.name as "country.name", - c.created_at as "country.created_at", - c.updated_at as "country.updated_at" + a.updated_at as "asset.updated_at" FROM disbursements d JOIN wallets w on d.wallet_id = w.id JOIN assets a on d.asset_id = a.id - JOIN countries c on d.country_code = c.code - WHERE - d.id = $1 - ` + ` + +func (d *DisbursementModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id string) (*Disbursement, error) { + var disbursement Disbursement + + query := fmt.Sprintf("%s %s", selectDisbursementQuery, "WHERE d.id = $1") err := sqlExec.GetContext(ctx, &disbursement, query, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -186,44 +163,7 @@ func (d *DisbursementModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id func (d *DisbursementModel) GetByName(ctx context.Context, sqlExec db.SQLExecuter, name string) (*Disbursement, error) { var disbursement Disbursement - query := ` - SELECT - d.id, - d.name, - d.status, - d.status_history, - d.verification_field, - COALESCE(d.file_name, '') as file_name, - d.file_content, - d.created_at, - d.updated_at, - d.verification_field, - COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, - w.id as "wallet.id", - w.name as "wallet.name", - w.homepage as "wallet.homepage", - w.sep_10_client_domain as "wallet.sep_10_client_domain", - w.deep_link_schema as "wallet.deep_link_schema", - w.enabled as "wallet.enabled", - w.created_at as "wallet.created_at", - w.updated_at as "wallet.updated_at", - a.id as "asset.id", - a.code as "asset.code", - a.issuer as "asset.issuer", - a.created_at as "asset.created_at", - a.updated_at as "asset.updated_at", - c.code as "country.code", - c.name as "country.name", - c.created_at as "country.created_at", - c.updated_at as "country.updated_at" - FROM - disbursements d - JOIN wallets w on d.wallet_id = w.id - JOIN assets a on d.asset_id = a.id - JOIN countries c on d.country_code = c.code - WHERE - d.name = $1 - ` + query := fmt.Sprintf("%s %s", selectDisbursementQuery, "WHERE d.name = $1") err := sqlExec.GetContext(ctx, &disbursement, query, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -313,7 +253,6 @@ func (d *DisbursementModel) Count(ctx context.Context, sqlExec db.SQLExecuter, q disbursements d JOIN wallets w on d.wallet_id = w.id JOIN assets a on d.asset_id = a.id - JOIN countries c on d.country_code = c.code ` query, params := d.newDisbursementQuery(baseQuery, queryParams, false) @@ -329,43 +268,7 @@ func (d *DisbursementModel) Count(ctx context.Context, sqlExec db.SQLExecuter, q func (d *DisbursementModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, queryParams *QueryParams) ([]*Disbursement, error) { disbursements := []*Disbursement{} - baseQuery := ` - SELECT - d.id, - d.name, - d.status, - d.status_history, - d.verification_field, - d.created_at, - d.updated_at, - d.verification_field, - COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, - COALESCE(d.file_name, '') as file_name, - w.id as "wallet.id", - w.name as "wallet.name", - w.homepage as "wallet.homepage", - w.sep_10_client_domain as "wallet.sep_10_client_domain", - w.deep_link_schema as "wallet.deep_link_schema", - w.enabled as "wallet.enabled", - w.created_at as "wallet.created_at", - w.updated_at as "wallet.updated_at", - a.id as "asset.id", - a.code as "asset.code", - a.issuer as "asset.issuer", - a.created_at as "asset.created_at", - a.updated_at as "asset.updated_at", - c.code as "country.code", - c.name as "country.name", - c.created_at as "country.created_at", - c.updated_at as "country.updated_at" - FROM - disbursements d - JOIN wallets w on d.wallet_id = w.id - JOIN assets a on d.asset_id = a.id - JOIN countries c on d.country_code = c.code - ` - - query, params := d.newDisbursementQuery(baseQuery, queryParams, true) + query, params := d.newDisbursementQuery(selectDisbursementQuery, queryParams, true) err := sqlExec.SelectContext(ctx, &disbursements, query, params...) if err != nil { return nil, fmt.Errorf("error querying disbursements: %w", err) diff --git a/internal/data/disbursements_test.go b/internal/data/disbursements_test.go index e764d12a8..7d81fb363 100644 --- a/internal/data/disbursements_test.go +++ b/internal/data/disbursements_test.go @@ -15,7 +15,6 @@ import ( func Test_DisbursementModelInsert(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -25,14 +24,13 @@ func Test_DisbursementModelInsert(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") wallet.Assets = nil smsTemplate := "You have a new payment waiting for you from org x. Click on the link to register." disbursement := Disbursement{ - Name: "disbursement1", + Name: "disbursement", Status: DraftDisbursementStatus, StatusHistory: []DisbursementStatusHistoryEntry{ { @@ -40,14 +38,16 @@ func Test_DisbursementModelInsert(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, - VerificationField: VerificationFieldDateOfBirth, - SMSRegistrationMessageTemplate: smsTemplate, + Asset: asset, + Wallet: wallet, + VerificationField: VerificationTypeDateOfBirth, + ReceiverRegistrationMessageTemplate: smsTemplate, + RegistrationContactType: RegistrationContactTypePhone, } - t.Run("returns error when disbursement already exists", func(t *testing.T) { + t.Run("🔴 fails to insert disbursements with non-unique name", func(t *testing.T) { + defer DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + _, err := disbursementModel.Insert(ctx, &disbursement) require.NoError(t, err) _, err = disbursementModel.Insert(ctx, &disbursement) @@ -55,8 +55,9 @@ func Test_DisbursementModelInsert(t *testing.T) { require.Equal(t, ErrRecordAlreadyExists, err) }) - t.Run("insert disbursement successfully", func(t *testing.T) { - disbursement.Name = "disbursement2" + t.Run("🟢 successfully insert disbursement", func(t *testing.T) { + defer DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + id, err := disbursementModel.Insert(ctx, &disbursement) require.NoError(t, err) require.NotNil(t, id) @@ -64,16 +65,40 @@ func Test_DisbursementModelInsert(t *testing.T) { actual, err := disbursementModel.Get(ctx, dbConnectionPool, id) require.NoError(t, err) - assert.Equal(t, "disbursement2", actual.Name) + assert.Equal(t, "disbursement", actual.Name) assert.Equal(t, DraftDisbursementStatus, actual.Status) assert.Equal(t, asset, actual.Asset) - assert.Equal(t, country, actual.Country) assert.Equal(t, wallet, actual.Wallet) - assert.Equal(t, smsTemplate, actual.SMSRegistrationMessageTemplate) + assert.Equal(t, smsTemplate, actual.ReceiverRegistrationMessageTemplate) assert.Equal(t, 1, len(actual.StatusHistory)) assert.Equal(t, DraftDisbursementStatus, actual.StatusHistory[0].Status) assert.Equal(t, "user1", actual.StatusHistory[0].UserID) - assert.Equal(t, VerificationFieldDateOfBirth, actual.VerificationField) + assert.Equal(t, VerificationTypeDateOfBirth, actual.VerificationField) + }) + + t.Run("🟢 successfully insert disbursement (empty:[VerificationField,ReceiverRegistrationMessageTemplate])", func(t *testing.T) { + defer DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + + d := disbursement + d.ReceiverRegistrationMessageTemplate = "" + d.VerificationField = "" + + id, err := disbursementModel.Insert(ctx, &d) + require.NoError(t, err) + require.NotNil(t, id) + + actual, err := disbursementModel.Get(ctx, dbConnectionPool, id) + require.NoError(t, err) + + assert.Equal(t, "disbursement", actual.Name) + assert.Equal(t, DraftDisbursementStatus, actual.Status) + assert.Equal(t, asset, actual.Asset) + assert.Equal(t, wallet, actual.Wallet) + assert.Empty(t, actual.ReceiverRegistrationMessageTemplate) + assert.Equal(t, 1, len(actual.StatusHistory)) + assert.Equal(t, DraftDisbursementStatus, actual.StatusHistory[0].Status) + assert.Equal(t, "user1", actual.StatusHistory[0].UserID) + assert.Empty(t, actual.VerificationField) }) } @@ -90,7 +115,6 @@ func Test_DisbursementModelCount(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := Disbursement{ @@ -101,9 +125,8 @@ func Test_DisbursementModelCount(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, + Asset: asset, + Wallet: wallet, } t.Run("returns 0 when no disbursements exist", func(t *testing.T) { @@ -155,7 +178,6 @@ func Test_DisbursementModelGet(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := Disbursement{ @@ -167,9 +189,8 @@ func Test_DisbursementModelGet(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, + Asset: asset, + Wallet: wallet, } t.Run("returns error when disbursement does not exist", func(t *testing.T) { @@ -200,7 +221,6 @@ func Test_DisbursementModelGetByName(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := Disbursement{ @@ -212,9 +232,8 @@ func Test_DisbursementModelGetByName(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, + Asset: asset, + Wallet: wallet, } t.Run("returns error when disbursement does not exist", func(t *testing.T) { @@ -235,7 +254,6 @@ func Test_DisbursementModelGetByName(t *testing.T) { func Test_DisbursementModelGetAll(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -246,7 +264,6 @@ func Test_DisbursementModelGetAll(t *testing.T) { paymentModel := PaymentModel{dbConnectionPool: dbConnectionPool} asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := Disbursement{ @@ -257,9 +274,8 @@ func Test_DisbursementModelGetAll(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, + Asset: asset, + Wallet: wallet, } t.Run("returns empty list when no disbursements exist", func(t *testing.T) { @@ -280,8 +296,8 @@ func Test_DisbursementModelGetAll(t *testing.T) { actualDisbursements, err := disbursementModel.GetAll(ctx, dbConnectionPool, &QueryParams{}) require.NoError(t, err) - assert.Equal(t, 2, len(actualDisbursements)) - assert.Equal(t, []*Disbursement{expected1, expected2}, actualDisbursements) + assert.Len(t, actualDisbursements, 2) + assert.Equal(t, []*Disbursement{expected2, expected1}, actualDisbursements) }) t.Run("returns disbursements successfully with limit", func(t *testing.T) { @@ -349,7 +365,7 @@ func Test_DisbursementModelGetAll(t *testing.T) { assert.Equal(t, []*Disbursement{expected1}, actualDisbursements) }) - t.Run("returns disbursements successfully with statuses parameter ", func(t *testing.T) { + t.Run("returns disbursements successfully with statuses parameter", func(t *testing.T) { DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) disbursement.Name = "disbursement1" @@ -371,6 +387,7 @@ func Test_DisbursementModelGetAll(t *testing.T) { assert.Equal(t, 2, len(actualDisbursements)) assert.Equal(t, []*Disbursement{expected2, expected1}, actualDisbursements) }) + t.Run("returns disbursements successfully with stats", func(t *testing.T) { DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) @@ -442,9 +459,9 @@ func Test_DisbursementModel_Update(t *testing.T) { }) disbursementFileContent := CreateInstructionsFixture(t, []*DisbursementInstruction{ - {"1234567890", "1", "123.12", "1995-02-20", nil}, - {"0987654321", "2", "321", "1974-07-19", nil}, - {"0987654321", "3", "321", "1974-07-19", nil}, + {Phone: "1234567890", ID: "1", Amount: "123.12", VerificationValue: "1995-02-20"}, + {Phone: "0987654321", ID: "2", Amount: "321", VerificationValue: "1974-07-19"}, + {Phone: "0987654321", ID: "3", Amount: "321", VerificationValue: "1974-07-19"}, }) t.Run("update instructions", func(t *testing.T) { @@ -507,7 +524,6 @@ func Test_DisbursementModel_Update(t *testing.T) { func Test_DisbursementModel_CompleteDisbursements(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, outerErr) defer dbConnectionPool.Close() @@ -517,15 +533,6 @@ func Test_DisbursementModel_CompleteDisbursements(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) - DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) - DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) - DeleteAllCountryFixtures(t, ctx, dbConnectionPool) - DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) - DeleteAllWalletFixtures(t, ctx, dbConnectionPool) - - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -538,8 +545,7 @@ func Test_DisbursementModel_CompleteDisbursements(t *testing.T) { Status: ReadyDisbursementStatus, Asset: asset, Wallet: wallet, - Country: country, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) _ = CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -566,8 +572,7 @@ func Test_DisbursementModel_CompleteDisbursements(t *testing.T) { Status: StartedDisbursementStatus, Asset: asset, Wallet: wallet, - Country: country, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) _ = CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -604,8 +609,7 @@ func Test_DisbursementModel_CompleteDisbursements(t *testing.T) { Status: StartedDisbursementStatus, Asset: asset, Wallet: wallet, - Country: country, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) _ = CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -623,8 +627,7 @@ func Test_DisbursementModel_CompleteDisbursements(t *testing.T) { Status: StartedDisbursementStatus, Asset: asset, Wallet: wallet, - Country: country, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) _ = CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go index c0353bf80..3ff1a4166 100644 --- a/internal/data/fixtures.go +++ b/internal/data/fixtures.go @@ -22,9 +22,7 @@ import ( ) const ( - FixtureCountryUSA = "USA" - FixtureCountryUKR = "UKR" - FixtureAssetUSDC = "USDC" + FixtureAssetUSDC = "USDC" ) func CreateAssetFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, code, issuer string) *Asset { @@ -226,73 +224,31 @@ func EnableOrDisableWalletFixtures(t *testing.T, ctx context.Context, sqlExec db require.NoError(t, err) } -func GetCountryFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, code string) *Country { +func MakeWalletUserManaged(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, walletID string) { const query = ` - SELECT - * - FROM - countries + UPDATE + wallets + SET + user_managed = true WHERE - code = $1 + id = $1 ` - country := &Country{} - err := sqlExec.GetContext(ctx, country, query, code) + _, err := sqlExec.ExecContext(ctx, query, walletID) require.NoError(t, err) - - return country } -func CreateCountryFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, code, name string) *Country { - const query = ` - WITH create_country AS ( - INSERT INTO countries - (code, name) - VALUES - ($1, $2) - ON CONFLICT DO NOTHING - RETURNING * - ) - SELECT created_at, updated_at FROM create_country - UNION ALL - SELECT created_at, updated_at FROM countries WHERE code = $1 AND name = $2 - ` - - country := &Country{ - Code: code, - Name: name, - } - - err := sqlExec.QueryRowxContext(ctx, query, code, name).Scan(&country.CreatedAt, &country.UpdatedAt) - require.NoError(t, err) - - return country -} +func CreateReceiverFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, r *Receiver) *Receiver { + t.Helper() -// DeleteAllCountryFixtures deletes all countries in the database -func DeleteAllCountryFixtures(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) { - const query = "DELETE FROM countries" - _, err := sqlExec.ExecContext(ctx, query) + randomSuffix, err := utils.RandomString(5, utils.NumberBytes) require.NoError(t, err) -} -// ClearAndCreateCountryFixtures deletes all countries in the database then creates new countries for testing -func ClearAndCreateCountryFixtures(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) []Country { - DeleteAllCountryFixtures(t, ctx, sqlExec) - expected := []Country{ - *CreateCountryFixture(t, ctx, sqlExec, "BRA", "Brazil"), - *CreateCountryFixture(t, ctx, sqlExec, "UKR", "Ukraine"), + if r == nil { + r = &Receiver{} } - return expected -} - -func CreateReceiverFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, r *Receiver) *Receiver { - randomSuffix, err := utils.RandomString(5) - require.NoError(t, err) - - if r.Email == nil { - email := fmt.Sprintf("email%s@randomemail.com", randomSuffix) - r.Email = &email + if r.Email == "" { + r.Email = fmt.Sprintf("email%s@randomemail.com", randomSuffix) } if r.PhoneNumber == "" { @@ -337,6 +293,31 @@ func CreateReceiverFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExec return &receiver } +func InsertReceiverFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, r *ReceiverInsert) *Receiver { + t.Helper() + + if r.ExternalId == nil { + randString, err := utils.RandomString(56) + require.NoError(t, err) + r.ExternalId = &randString + } + + const query = ` + INSERT INTO receivers + (email, phone_number, external_id) + VALUES + ($1, $2, $3) + RETURNING + id, COALESCE(phone_number, '') as phone_number, COALESCE(email, '') as email, external_id, created_at, updated_at + ` + + var receiver Receiver + err := sqlExec.GetContext(ctx, &receiver, query, r.Email, r.PhoneNumber, r.ExternalId) + require.NoError(t, err) + + return &receiver +} + func DeleteAllReceiversFixtures(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) { const query = "DELETE FROM receivers" _, err := sqlExec.ExecContext(ctx, query) @@ -395,32 +376,36 @@ func DeleteAllReceiverVerificationFixtures(t *testing.T, ctx context.Context, sq } func CreateReceiverWalletFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, receiverID, walletID string, status ReceiversWalletStatus) *ReceiverWallet { - kp, err := keypair.Random() - require.NoError(t, err) - stellarAddress := kp.Address() + var stellarAddress, stellarMemo, stellarMemoType, anchorPlatformTransactionID string - randNumber, err := rand.Int(rand.Reader, big.NewInt(90000)) - require.NoError(t, err) + if status != DraftReceiversWalletStatus && status != ReadyReceiversWalletStatus { + kp, err := keypair.Random() + require.NoError(t, err) + stellarAddress = kp.Address() - stellarMemo := fmt.Sprint(randNumber.Int64() + 10000) - stellarMemoType := "id" + randNumber, err := rand.Int(rand.Reader, big.NewInt(90000)) + require.NoError(t, err) - anchorPlatformTransactionID, err := utils.RandomString(10) - require.NoError(t, err) + stellarMemo = fmt.Sprint(randNumber.Int64() + 10000) + stellarMemoType = "id" + + anchorPlatformTransactionID, err = utils.RandomString(10) + require.NoError(t, err) + } const query = ` WITH inserted_receiver_wallet AS ( INSERT INTO receiver_wallets - (receiver_id, wallet_id, stellar_address, stellar_memo, stellar_memo_type, status, anchor_platform_transaction_id) + (receiver_id, wallet_id, stellar_address, stellar_memo, stellar_memo_type, status, status_history, anchor_platform_transaction_id) VALUES - ($1, $2, $3, $4, $5, $6, $7) + ($1, $2, $3, $4, $5, $6, ARRAY[create_receiver_wallet_status_history(now(), $6)], $7) RETURNING * ) SELECT rw.id, rw.stellar_address, rw.stellar_memo, rw.stellar_memo_type, rw.status, rw.status_history, rw.created_at, rw.updated_at, rw.anchor_platform_transaction_id, rw.anchor_platform_transaction_synced_at, - r.id, r.email, r.phone_number, r.external_id, r.created_at, r.updated_at, + r.id, COALESCE(r.phone_number, '') as phone_number, COALESCE(r.email, '') as email, r.external_id, r.created_at, r.updated_at, w.id, w.name, w.homepage, w.deep_link_schema, w.created_at, w.updated_at FROM inserted_receiver_wallet AS rw @@ -429,7 +414,7 @@ func CreateReceiverWalletFixture(t *testing.T, ctx context.Context, sqlExec db.S ` var receiverWallet ReceiverWallet - err = sqlExec.QueryRowxContext(ctx, query, receiverID, walletID, stellarAddress, stellarMemo, stellarMemoType, status, anchorPlatformTransactionID).Scan( + err := sqlExec.QueryRowxContext(ctx, query, receiverID, walletID, stellarAddress, stellarMemo, stellarMemoType, status, anchorPlatformTransactionID).Scan( &receiverWallet.ID, &receiverWallet.StellarAddress, &receiverWallet.StellarMemo, @@ -537,11 +522,11 @@ func CreateDisbursementFixture(t *testing.T, ctx context.Context, sqlExec db.SQL if d.Asset == nil { d.Asset = GetAssetFixture(t, ctx, sqlExec, FixtureAssetUSDC) } - if d.Country == nil { - d.Country = GetCountryFixture(t, ctx, sqlExec, FixtureCountryUKR) - } if d.VerificationField == "" { - d.VerificationField = VerificationFieldDateOfBirth + d.VerificationField = VerificationTypeDateOfBirth + } + if utils.IsEmpty(d.RegistrationContactType) { + d.RegistrationContactType = RegistrationContactTypePhone } // insert disbursement @@ -614,7 +599,15 @@ func CreateDraftDisbursementFixture(t *testing.T, ctx context.Context, sqlExec d } if insert.VerificationField == "" { - insert.VerificationField = VerificationFieldDateOfBirth + insert.VerificationField = VerificationTypeDateOfBirth + } + + if utils.IsEmpty(insert.RegistrationContactType) { + insert.RegistrationContactType = RegistrationContactTypePhone + } + + if utils.IsEmpty(insert.RegistrationContactType) { + insert.RegistrationContactType = RegistrationContactTypePhone } id, err := model.Insert(ctx, &insert) @@ -785,5 +778,4 @@ func DeleteAllFixtures(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter DeleteAllDisbursementFixtures(t, ctx, sqlExec) DeleteAllWalletFixtures(t, ctx, sqlExec) DeleteAllAssetFixtures(t, ctx, sqlExec) - DeleteAllCountryFixtures(t, ctx, sqlExec) } diff --git a/internal/data/fixtures_test.go b/internal/data/fixtures_test.go index f04913adb..57533bda3 100644 --- a/internal/data/fixtures_test.go +++ b/internal/data/fixtures_test.go @@ -43,17 +43,17 @@ func Test_CreateReceiverWalletFixture(t *testing.T) { // Create a random receiver wallet wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "My Wallet", "https://mywallet.test.com/", "mywallet.test.com", "mtwallet://") receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - rw := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, DraftReceiversWalletStatus) + rw := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) // Check receiver wallet require.Len(t, rw.ID, 36) require.NotEmpty(t, rw.StellarAddress) require.NotEmpty(t, rw.StellarMemo) require.NotEmpty(t, rw.StellarMemoType) - require.Equal(t, DraftReceiversWalletStatus, rw.Status) + require.Equal(t, RegisteredReceiversWalletStatus, rw.Status) require.Len(t, rw.StatusHistory, 1) require.NotEmpty(t, rw.StatusHistory[0].Timestamp) - require.Equal(t, DraftReceiversWalletStatus, rw.StatusHistory[0].Status) + require.Equal(t, RegisteredReceiversWalletStatus, rw.StatusHistory[0].Status) require.NotEmpty(t, rw.CreatedAt) require.NotEmpty(t, rw.UpdatedAt) @@ -90,8 +90,8 @@ func Test_Fixtures_CreateInstructionsFixture(t *testing.T) { t.Run("writes records correctly", func(t *testing.T) { instructions := []*DisbursementInstruction{ - {"1234567890", "1", "123.12", "1995-02-20", nil}, - {"0987654321", "2", "321", "1974-07-19", nil}, + {Phone: "1234567890", ID: "1", Amount: "123.12", VerificationValue: "1995-02-20"}, + {Phone: "0987654321", ID: "2", Amount: "321", VerificationValue: "1974-07-19"}, } buf := CreateInstructionsFixture(t, instructions) lines := strings.Split(string(buf), "\n") @@ -117,9 +117,9 @@ func Test_Fixtures_UpdateDisbursementInstructionsFixture(t *testing.T) { }) instructions := []*DisbursementInstruction{ - {"1234567890", "1", "123.12", "1995-02-20", nil}, - {"0987654321", "2", "321", "1974-07-19", nil}, - {"0987654321", "3", "321", "1974-07-19", nil}, + {Phone: "1234567890", ID: "1", Amount: "123.12", VerificationValue: "1995-02-20"}, + {Phone: "0987654321", ID: "2", Amount: "321", VerificationValue: "1974-07-19"}, + {Phone: "0987654321", ID: "3", Amount: "321", VerificationValue: "1974-07-19"}, } t.Run("update instructions", func(t *testing.T) { diff --git a/internal/data/models.go b/internal/data/models.go index 939d41c08..96d0c7961 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -16,7 +16,6 @@ var ( type Models struct { Disbursements *DisbursementModel Wallets *WalletModel - Countries *CountryModel Assets *AssetModel Organizations *OrganizationModel Payment *PaymentModel @@ -37,7 +36,6 @@ func NewModels(dbConnectionPool db.DBConnectionPool) (*Models, error) { return &Models{ Disbursements: &DisbursementModel{dbConnectionPool: dbConnectionPool}, Wallets: &WalletModel{dbConnectionPool: dbConnectionPool}, - Countries: &CountryModel{dbConnectionPool: dbConnectionPool}, Assets: &AssetModel{dbConnectionPool: dbConnectionPool}, Organizations: &OrganizationModel{dbConnectionPool: dbConnectionPool}, Payment: &PaymentModel{dbConnectionPool: dbConnectionPool}, diff --git a/internal/data/organizations.go b/internal/data/organizations.go index 660c04219..43b954284 100644 --- a/internal/data/organizations.go +++ b/internal/data/organizations.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "database/sql" + "database/sql/driver" "errors" "fmt" "image" @@ -20,48 +21,50 @@ import ( _ "image/png" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) const ( - DefaultSMSRegistrationMessageTemplate = "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register." - DefaultOTPMessageTemplate = "{{.OTP}} is your {{.OrganizationName}} phone verification code." + DefaultReceiverRegistrationMessageTemplate = "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register." + DefaultOTPMessageTemplate = "{{.OTP}} is your {{.OrganizationName}} verification code." ) type Organization struct { ID string `json:"id" db:"id"` Name string `json:"name" db:"name"` TimezoneUTCOffset string `json:"timezone_utc_offset" db:"timezone_utc_offset"` - // SMSResendInterval is the time period that SDP will wait to resend the invitation SMS to the receivers that aren't registered. - // If it's nil means resending the invitation SMS is deactivated. - SMSResendInterval *int64 `json:"sms_resend_interval" db:"sms_resend_interval"` + // ReceiverInvitationResendInterval is the time period that SDP will wait to resend the invitation to the receivers that aren't registered. + // If it's nil means resending the invitation is deactivated. + ReceiverInvitationResendIntervalDays *int64 `json:"receiver_invitation_resend_interval_days" db:"receiver_invitation_resend_interval_days"` // PaymentCancellationPeriodDays is the number of days for a ready payment to be automatically cancelled. - PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days" db:"payment_cancellation_period_days"` - SMSRegistrationMessageTemplate string `json:"sms_registration_message_template" db:"sms_registration_message_template"` + PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days" db:"payment_cancellation_period_days"` + ReceiverRegistrationMessageTemplate string `json:"receiver_registration_message_template" db:"receiver_registration_message_template"` // OTPMessageTemplate is the message template to send the OTP code to the receivers validates their identity when registering their wallets. // The message may have the template values {{.OTP}} and {{.OrganizationName}}, it will be parsed and the values injected when executing the template. // When the {{.OTP}} is not found in the message, it's added at the beginning of the message. // Example: // {{.OTP}} OTPMessageTemplate - OTPMessageTemplate string `json:"otp_message_template" db:"otp_message_template"` - PrivacyPolicyLink *string `json:"privacy_policy_link" db:"privacy_policy_link"` - Logo []byte `db:"logo"` - IsApprovalRequired bool `json:"is_approval_required" db:"is_approval_required"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + OTPMessageTemplate string `json:"otp_message_template" db:"otp_message_template"` + PrivacyPolicyLink *string `json:"privacy_policy_link" db:"privacy_policy_link"` + Logo []byte `db:"logo"` + IsApprovalRequired bool `json:"is_approval_required" db:"is_approval_required"` + MessageChannelPriority MessageChannelPriority `json:"message_channel_priority" db:"message_channel_priority"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } type OrganizationUpdate struct { - Name string `json:",omitempty"` - Logo []byte `json:",omitempty"` - TimezoneUTCOffset string `json:",omitempty"` - IsApprovalRequired *bool `json:",omitempty"` - SMSResendInterval *int64 `json:",omitempty"` - PaymentCancellationPeriodDays *int64 `json:",omitempty"` + Name string `json:",omitempty"` + Logo []byte `json:",omitempty"` + TimezoneUTCOffset string `json:",omitempty"` + IsApprovalRequired *bool `json:",omitempty"` + ReceiverInvitationResendIntervalDays *int64 `json:",omitempty"` + PaymentCancellationPeriodDays *int64 `json:",omitempty"` // Using pointers to accept empty strings - SMSRegistrationMessageTemplate *string `json:",omitempty"` - OTPMessageTemplate *string `json:",omitempty"` - PrivacyPolicyLink *string `json:",omitempty"` + ReceiverRegistrationMessageTemplate *string `json:",omitempty"` + OTPMessageTemplate *string `json:",omitempty"` + PrivacyPolicyLink *string `json:",omitempty"` } type LogoType string @@ -90,7 +93,7 @@ func (lt LogoType) ToHTTPContentType() string { func (ou *OrganizationUpdate) validate() error { if ou.areAllFieldsEmpty() { - return fmt.Errorf("name, timezone UTC offset, approval workflow flag, SMS Resend Interval, SMS invite template, OTP message template, privacy policy link or logo is required") + return fmt.Errorf("name, timezone UTC offset, approval workflow flag, Receiver invitation resend interval, Receiver registration invite template, OTP message template, privacy policy link or logo is required") } if len(ou.Logo) > 0 { @@ -119,15 +122,15 @@ func (ou *OrganizationUpdate) validate() error { } func (ou *OrganizationUpdate) areAllFieldsEmpty() bool { - return (ou.Name == "" && + return ou.Name == "" && len(ou.Logo) == 0 && ou.TimezoneUTCOffset == "" && ou.IsApprovalRequired == nil && - ou.SMSRegistrationMessageTemplate == nil && + ou.ReceiverRegistrationMessageTemplate == nil && ou.OTPMessageTemplate == nil && - ou.SMSResendInterval == nil && + ou.ReceiverInvitationResendIntervalDays == nil && ou.PaymentCancellationPeriodDays == nil && - ou.PrivacyPolicyLink == nil) + ou.PrivacyPolicyLink == nil } type OrganizationModel struct { @@ -194,13 +197,13 @@ func (om *OrganizationModel) Update(ctx context.Context, ou *OrganizationUpdate) args = append(args, *ou.IsApprovalRequired) } - if ou.SMSRegistrationMessageTemplate != nil { - if *ou.SMSRegistrationMessageTemplate != "" { - fields = append(fields, "sms_registration_message_template = ?") - args = append(args, *ou.SMSRegistrationMessageTemplate) + if ou.ReceiverRegistrationMessageTemplate != nil { + if *ou.ReceiverRegistrationMessageTemplate != "" { + fields = append(fields, "receiver_registration_message_template = ?") + args = append(args, *ou.ReceiverRegistrationMessageTemplate) } else { // When empty value is passed by parameter we set the DEFAULT value for the column. - fields = append(fields, "sms_registration_message_template = DEFAULT") + fields = append(fields, "receiver_registration_message_template = DEFAULT") } } @@ -224,13 +227,13 @@ func (om *OrganizationModel) Update(ctx context.Context, ou *OrganizationUpdate) } } - if ou.SMSResendInterval != nil { - if *ou.SMSResendInterval > 0 { - fields = append(fields, "sms_resend_interval = ?") - args = append(args, *ou.SMSResendInterval) + if ou.ReceiverInvitationResendIntervalDays != nil { + if *ou.ReceiverInvitationResendIntervalDays > 0 { + fields = append(fields, "receiver_invitation_resend_interval_days = ?") + args = append(args, *ou.ReceiverInvitationResendIntervalDays) } else { // When 0 (zero) is passed by parameter we set it as NULL. - fields = append(fields, "sms_resend_interval = NULL") + fields = append(fields, "receiver_invitation_resend_interval_days = NULL") } } @@ -252,3 +255,52 @@ func (om *OrganizationModel) Update(ctx context.Context, ou *OrganizationUpdate) return nil } + +type MessageChannelPriority []message.MessageChannel + +var DefaultMessageChannelPriority = MessageChannelPriority{ + message.MessageChannelSMS, + message.MessageChannelEmail, +} + +func (mcp *MessageChannelPriority) Scan(src interface{}) error { + if src == nil { + *mcp = nil + return nil + } + + byteValue, ok := src.([]byte) + if !ok { + return fmt.Errorf("unexpected type for MessageChannelPriority %T", src) + } + + // Convert []byte to string and remove the curly braces. E.g. `{SMS,EMAIL}` + strValue := strings.Trim(string(byteValue), "{}") + + // Split the string into individual channel values. E.g. `SMS,EMAIL` -> ["SMS", "EMAIL"] + channels := strings.Split(strValue, ",") + + *mcp = make(MessageChannelPriority, len(channels)) + for i, ch := range channels { + (*mcp)[i] = message.MessageChannel(strings.TrimSpace(ch)) + } + + return nil +} + +var _ sql.Scanner = (*MessageChannelPriority)(nil) + +func (mcp MessageChannelPriority) Value() (driver.Value, error) { + if len(mcp) == 0 { + return "{}", nil + } + + channels := make([]string, len(mcp)) + for i, ch := range mcp { + channels[i] = string(ch) + } + + return "{" + strings.Join(channels, ",") + "}", nil +} + +var _ driver.Valuer = MessageChannelPriority{} diff --git a/internal/data/organizations_test.go b/internal/data/organizations_test.go index b6c41f6be..57529d94c 100644 --- a/internal/data/organizations_test.go +++ b/internal/data/organizations_test.go @@ -14,6 +14,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) func Test_Organizations_DatabaseTriggers(t *testing.T) { @@ -29,7 +30,7 @@ func Test_Organizations_DatabaseTriggers(t *testing.T) { t.Run("SQL query will trigger an error if you try to have more than one organization", func(t *testing.T) { q := ` INSERT INTO organizations ( - name, timezone_utc_offset, sms_registration_message_template + name, timezone_utc_offset, receiver_registration_message_template ) VALUES ( 'Test name', '+00:00', 'Test template {{.OrganizationName}} {{.RegistrationLink}}.' @@ -45,8 +46,8 @@ func Test_Organizations_DatabaseTriggers(t *testing.T) { require.EqualError(t, err, "pq: organizations can must contain exactly one row") }) - t.Run("updating sms_registration_message_template with the tags {{.OrganizationName}} and {{.RegistrationLink}} will succeed 🎉", func(t *testing.T) { - q := "UPDATE organizations SET sms_registration_message_template = 'TAG1: {{.OrganizationName}} and TAG2: {{.RegistrationLink}}.'" + t.Run("updating receiver_registration_message_template with the tags {{.OrganizationName}} and {{.RegistrationLink}} will succeed 🎉", func(t *testing.T) { + q := "UPDATE organizations SET receiver_registration_message_template = 'TAG1: {{.OrganizationName}} and TAG2: {{.RegistrationLink}}.'" _, err := dbConnectionPool.ExecContext(ctx, q) require.NoError(t, err) }) @@ -70,18 +71,19 @@ func Test_Organizations_Get(t *testing.T) { assert.Len(t, gotOrganization.ID, 36) assert.Equal(t, "MyCustomAid", gotOrganization.Name) assert.Equal(t, "+00:00", gotOrganization.TimezoneUTCOffset) - assert.Equal(t, "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register.", gotOrganization.SMSRegistrationMessageTemplate) + assert.Equal(t, "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register.", gotOrganization.ReceiverRegistrationMessageTemplate) assert.NotEmpty(t, gotOrganization.CreatedAt) assert.NotEmpty(t, gotOrganization.UpdatedAt) assert.False(t, gotOrganization.IsApprovalRequired) assert.Nil(t, gotOrganization.PrivacyPolicyLink) + assert.Equal(t, MessageChannelPriority{"SMS", "EMAIL"}, gotOrganization.MessageChannelPriority) }) } func Test_OrganizationUpdate_validate(t *testing.T) { ou := &OrganizationUpdate{} err := ou.validate() - assert.EqualError(t, err, "name, timezone UTC offset, approval workflow flag, SMS Resend Interval, SMS invite template, OTP message template, privacy policy link or logo is required") + assert.EqualError(t, err, "name, timezone UTC offset, approval workflow flag, Receiver invitation resend interval, Receiver registration invite template, OTP message template, privacy policy link or logo is required") ou.Name = "My Org Name" err = ou.validate() @@ -194,27 +196,18 @@ func Test_Organizations_Update(t *testing.T) { ctx := context.Background() - resetOrganizationInfo := func(t *testing.T, ctx context.Context) { - const q = ` - UPDATE - organizations - SET - name = 'MyCustomAid', logo = NULL, timezone_utc_offset = '+00:00', - sms_registration_message_template = DEFAULT, otp_message_template = DEFAULT` - _, err := dbConnectionPool.ExecContext(ctx, q) - require.NoError(t, err) - } - organizationModel := &OrganizationModel{dbConnectionPool: dbConnectionPool} t.Run("returns error with invalid OrganizationUpdate", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) + ou := &OrganizationUpdate{} err := organizationModel.Update(ctx, ou) - assert.EqualError(t, err, "invalid organization update: name, timezone UTC offset, approval workflow flag, SMS Resend Interval, SMS invite template, OTP message template, privacy policy link or logo is required") + assert.EqualError(t, err, "invalid organization update: name, timezone UTC offset, approval workflow flag, Receiver invitation resend interval, Receiver registration invite template, OTP message template, privacy policy link or logo is required") }) t.Run("updates only organization's name successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -237,7 +230,7 @@ func Test_Organizations_Update(t *testing.T) { }) t.Run("updates only organization's timezone UTC offset successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -260,7 +253,7 @@ func Test_Organizations_Update(t *testing.T) { }) t.Run("updates only organization's logo successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -286,7 +279,7 @@ func Test_Organizations_Update(t *testing.T) { }) t.Run("updates only organization's is_approval_required successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -304,7 +297,7 @@ func Test_Organizations_Update(t *testing.T) { }) t.Run("updates organization's name, timezone UTC offset and logo successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -331,47 +324,47 @@ func Test_Organizations_Update(t *testing.T) { assert.Equal(t, ou.Logo, o.Logo) }) - t.Run("updates the organization's SMSRegistrationMessageTemplate", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + t.Run("updates the organization's ReceiverRegistrationMessageTemplate", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) defaultMessage := "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register." o, err := organizationModel.Get(ctx) require.NoError(t, err) - assert.Equal(t, defaultMessage, o.SMSRegistrationMessageTemplate) + assert.Equal(t, defaultMessage, o.ReceiverRegistrationMessageTemplate) // Setting custom message m := "My custom receiver wallet registration invite. MyOrg 👋" - ou := &OrganizationUpdate{SMSRegistrationMessageTemplate: &m} + ou := &OrganizationUpdate{ReceiverRegistrationMessageTemplate: &m} err = organizationModel.Update(ctx, ou) require.NoError(t, err) o, err = organizationModel.Get(ctx) require.NoError(t, err) - assert.Equal(t, m, o.SMSRegistrationMessageTemplate) + assert.Equal(t, m, o.ReceiverRegistrationMessageTemplate) // Don't update the message - err = organizationModel.Update(ctx, &OrganizationUpdate{Name: "My Org Name", SMSRegistrationMessageTemplate: nil}) + err = organizationModel.Update(ctx, &OrganizationUpdate{Name: "My Org Name", ReceiverRegistrationMessageTemplate: nil}) require.NoError(t, err) o, err = organizationModel.Get(ctx) require.NoError(t, err) - assert.Equal(t, m, o.SMSRegistrationMessageTemplate) + assert.Equal(t, m, o.ReceiverRegistrationMessageTemplate) // Back to the default value - ou.SMSRegistrationMessageTemplate = new(string) + ou.ReceiverRegistrationMessageTemplate = new(string) err = organizationModel.Update(ctx, ou) require.NoError(t, err) o, err = organizationModel.Get(ctx) require.NoError(t, err) - assert.Equal(t, defaultMessage, o.SMSRegistrationMessageTemplate) + assert.Equal(t, defaultMessage, o.ReceiverRegistrationMessageTemplate) }) t.Run("updates the organization's OTPMessageTemplate", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) - defaultMessage := "{{.OTP}} is your {{.OrganizationName}} phone verification code." + defaultMessage := "{{.OTP}} is your {{.OrganizationName}} verification code." o, err := organizationModel.Get(ctx) require.NoError(t, err) assert.Equal(t, defaultMessage, o.OTPMessageTemplate) @@ -405,33 +398,33 @@ func Test_Organizations_Update(t *testing.T) { assert.Equal(t, defaultMessage, o.OTPMessageTemplate) }) - t.Run("updates the organization's SMSResendInterval", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + t.Run("updates the organization's ReceiverInvitationResendIntervalDays", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) - assert.Nil(t, o.SMSResendInterval) + assert.Nil(t, o.ReceiverInvitationResendIntervalDays) var smsResendInterval int64 = 2 - err = organizationModel.Update(ctx, &OrganizationUpdate{SMSResendInterval: &smsResendInterval}) + err = organizationModel.Update(ctx, &OrganizationUpdate{ReceiverInvitationResendIntervalDays: &smsResendInterval}) require.NoError(t, err) o, err = organizationModel.Get(ctx) require.NoError(t, err) - assert.Equal(t, smsResendInterval, *o.SMSResendInterval) + assert.Equal(t, smsResendInterval, *o.ReceiverInvitationResendIntervalDays) // Set it as null smsResendInterval = 0 - err = organizationModel.Update(ctx, &OrganizationUpdate{SMSResendInterval: &smsResendInterval}) + err = organizationModel.Update(ctx, &OrganizationUpdate{ReceiverInvitationResendIntervalDays: &smsResendInterval}) require.NoError(t, err) o, err = organizationModel.Get(ctx) require.NoError(t, err) - assert.Nil(t, o.SMSResendInterval) + assert.Nil(t, o.ReceiverInvitationResendIntervalDays) }) t.Run("updates the organization's PaymentCancellationPeriod", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -456,7 +449,7 @@ func Test_Organizations_Update(t *testing.T) { }) t.Run("updates the organization's PrivacyPolicyLink", func(t *testing.T) { - resetOrganizationInfo(t, ctx) + defer resetOrganizationInfo(t, ctx, dbConnectionPool) o, err := organizationModel.Get(ctx) require.NoError(t, err) @@ -480,3 +473,64 @@ func Test_Organizations_Update(t *testing.T) { assert.Nil(t, o.PrivacyPolicyLink) }) } + +func TestOrganizationModel_UpdateMessageChannelPriority(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + organizationModel := &OrganizationModel{dbConnectionPool: dbConnectionPool} + + t.Run("succeeds when all channels are included", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) + + _, err := dbConnectionPool.ExecContext(ctx, "UPDATE organizations SET message_channel_priority = $1", DefaultMessageChannelPriority) + require.NoError(t, err) + + // Verify the update + org, err := organizationModel.Get(ctx) + require.NoError(t, err) + assert.Equal(t, MessageChannelPriority{message.MessageChannelSMS, message.MessageChannelEmail}, org.MessageChannelPriority) + }) + + t.Run("fails when not all channels are included", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) + + _, err := dbConnectionPool.ExecContext(ctx, "UPDATE organizations SET message_channel_priority = $1", MessageChannelPriority{"SMS"}) + require.Error(t, err) + assert.ErrorContains(t, err, "message_channel_priority must include all possible message_channel values") + }) + + t.Run("fails when duplicate channels are included", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) + + _, err := dbConnectionPool.ExecContext(ctx, "UPDATE organizations SET message_channel_priority = $1", MessageChannelPriority{"SMS", "EMAIL", "SMS"}) + require.Error(t, err) + assert.ErrorContains(t, err, "message_channel_priority must not contain duplicate values: {SMS}") + }) + + t.Run("fails when invalid channel is included", func(t *testing.T) { + defer resetOrganizationInfo(t, ctx, dbConnectionPool) + + _, err := dbConnectionPool.ExecContext(ctx, "UPDATE organizations SET message_channel_priority = $1", MessageChannelPriority{"SMS", "WHATSAPP"}) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid input value for enum message_channel: \"WHATSAPP\"") + }) +} + +func resetOrganizationInfo(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool) { + t.Helper() + + const q = ` + UPDATE + organizations + SET + name = 'MyCustomAid', logo = NULL, timezone_utc_offset = '+00:00', + receiver_registration_message_template = DEFAULT, otp_message_template = DEFAULT, message_channel_priority = '{"SMS", "EMAIL"}'` + _, err := dbConnectionPool.ExecContext(ctx, q) + require.NoError(t, err) +} diff --git a/internal/data/payments.go b/internal/data/payments.go index fbdd34640..490b43b3d 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -156,6 +156,7 @@ SELECT d.status as "disbursement.status", d.created_at as "disbursement.created_at", d.updated_at as "disbursement.updated_at", + d.registration_contact_type as "disbursement.registration_contact_type", a.id as "asset.id", a.code as "asset.code", a.issuer as "asset.issuer", diff --git a/internal/data/payments_test.go b/internal/data/payments_test.go index dc4200aba..d7ff29dd4 100644 --- a/internal/data/payments_test.go +++ b/internal/data/payments_test.go @@ -26,7 +26,6 @@ func Test_PaymentsModelGet(t *testing.T) { ctx := context.Background() asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) @@ -38,7 +37,6 @@ func Test_PaymentsModelGet(t *testing.T) { Status: DraftDisbursementStatus, Asset: asset, Wallet: wallet1, - Country: country, CreatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), }) @@ -84,11 +82,10 @@ func Test_PaymentsModelGet(t *testing.T) { receiverWallet2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet2.ID, DraftReceiversWalletStatus) disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, &disbursementModel, &Disbursement{ - Name: "disbursement 2", - Status: DraftDisbursementStatus, - Asset: asset, - Wallet: wallet2, - Country: country, + Name: "disbursement 2", + Status: DraftDisbursementStatus, + Asset: asset, + Wallet: wallet2, }) stellarTransactionID, err := utils.RandomString(64) @@ -130,7 +127,6 @@ func Test_PaymentModelCount(t *testing.T) { ctx := context.Background() asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) @@ -138,19 +134,17 @@ func Test_PaymentModelCount(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, &disbursementModel, &Disbursement{ - Name: "disbursement 1", - Status: DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, &disbursementModel, &Disbursement{ - Name: "disbursement 2", - Status: DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 2", + Status: DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) paymentModel := PaymentModel{dbConnectionPool: dbConnectionPool} @@ -232,7 +226,6 @@ func Test_PaymentModelGetAll(t *testing.T) { ctx := context.Background() asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) @@ -240,19 +233,17 @@ func Test_PaymentModelGetAll(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, &disbursementModel, &Disbursement{ - Name: "disbursement 1", - Status: DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, &disbursementModel, &Disbursement{ - Name: "disbursement 2", - Status: DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 2", + Status: DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) paymentModel := PaymentModel{dbConnectionPool: dbConnectionPool} @@ -283,6 +274,7 @@ func Test_PaymentModelGetAll(t *testing.T) { Disbursement: disbursement1, Asset: *asset, ReceiverWallet: receiverWallet, + UpdatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), }) stellarTransactionID, err = utils.RandomString(64) @@ -305,27 +297,41 @@ func Test_PaymentModelGetAll(t *testing.T) { Disbursement: disbursement2, Asset: *asset, ReceiverWallet: receiverWallet, + UpdatedAt: time.Date(2023, 3, 21, 23, 40, 20, 1431, time.UTC), }) t.Run("returns payments successfully", func(t *testing.T) { - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{}, dbConnectionPool) + params := QueryParams{SortBy: DefaultPaymentSortField, SortOrder: DefaultPaymentSortOrder} + actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool) require.NoError(t, err) assert.Equal(t, 2, len(actualPayments)) assert.Equal(t, []Payment{*expectedPayment2, *expectedPayment1}, actualPayments) }) t.Run("returns payments successfully with limit", func(t *testing.T) { - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{Page: 1, PageLimit: 1}, dbConnectionPool) + params := QueryParams{ + SortBy: DefaultPaymentSortField, + SortOrder: DefaultPaymentSortOrder, + Page: 1, + PageLimit: 1, + } + actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool) require.NoError(t, err) assert.Equal(t, 1, len(actualPayments)) - assert.Equal(t, []Payment{*expectedPayment1}, actualPayments) + assert.Equal(t, []Payment{*expectedPayment2}, actualPayments) }) t.Run("returns payments successfully with offset", func(t *testing.T) { - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{Page: 2, PageLimit: 1}, dbConnectionPool) + params := QueryParams{ + Page: 2, + PageLimit: 1, + SortBy: DefaultPaymentSortField, + SortOrder: DefaultPaymentSortOrder, + } + actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool) require.NoError(t, err) assert.Equal(t, 1, len(actualPayments)) - assert.Equal(t, []Payment{*expectedPayment2}, actualPayments) + assert.Equal(t, []Payment{*expectedPayment1}, actualPayments) }) t.Run("returns payments successfully with created at order", func(t *testing.T) { @@ -361,10 +367,15 @@ func Test_PaymentModelGetAll(t *testing.T) { PendingPaymentStatus, }, } - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{Filters: filters}, dbConnectionPool) + queryParams := QueryParams{ + Filters: filters, + SortBy: DefaultPaymentSortField, + SortOrder: DefaultPaymentSortOrder, + } + actualPayments, err := paymentModel.GetAll(ctx, &queryParams, dbConnectionPool) require.NoError(t, err) assert.Equal(t, 2, len(actualPayments)) - assert.Equal(t, []Payment{*expectedPayment1, *expectedPayment2}, actualPayments) + assert.Equal(t, []Payment{*expectedPayment2, *expectedPayment1}, actualPayments) }) t.Run("should not return duplicated entries when receiver are in more than one disbursements with different wallets", func(t *testing.T) { @@ -373,14 +384,12 @@ func Test_PaymentModelGetAll(t *testing.T) { DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) - DeleteAllCountryFixtures(t, ctx, dbConnectionPool) DeleteAllAssetFixtures(t, ctx, dbConnectionPool) DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) DeleteAllWalletFixtures(t, ctx, dbConnectionPool) usdc := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") demoWallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Demo Wallet", "https://demo-wallet.stellar.org", "https://demo-wallet.stellar.org", "demo-wallet-server.stellar.org") vibrantWallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Vibrant Assist", "https://vibrantapp.com", "api-dev.vibrantapp.com", "https://vibrantapp.com/sdp-dev") @@ -389,19 +398,17 @@ func Test_PaymentModelGetAll(t *testing.T) { receiverVibrantWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, vibrantWallet.ID, ReadyReceiversWalletStatus) disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Name: "disbursement 1", - Status: ReadyDisbursementStatus, - Asset: usdc, - Wallet: demoWallet, - Country: country, + Name: "disbursement 1", + Status: ReadyDisbursementStatus, + Asset: usdc, + Wallet: demoWallet, }) disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Name: "disbursement 2", - Status: ReadyDisbursementStatus, - Asset: usdc, - Wallet: vibrantWallet, - Country: country, + Name: "disbursement 2", + Status: ReadyDisbursementStatus, + Asset: usdc, + Wallet: vibrantWallet, }) demoWalletPayment := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -410,6 +417,7 @@ func Test_PaymentModelGetAll(t *testing.T) { Disbursement: disbursement1, Asset: *usdc, ReceiverWallet: receiverDemoWallet, + UpdatedAt: time.Date(2023, 3, 21, 23, 40, 20, 1431, time.UTC), }) vibrantWalletPayment := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -418,12 +426,15 @@ func Test_PaymentModelGetAll(t *testing.T) { Disbursement: disbursement2, Asset: *usdc, ReceiverWallet: receiverVibrantWallet, + UpdatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), }) payments, err := models.Payment.GetAll(ctx, &QueryParams{ Filters: map[FilterKey]interface{}{ FilterKeyReceiverID: receiver.ID, }, + SortBy: DefaultPaymentSortField, + SortOrder: DefaultPaymentSortOrder, }, dbConnectionPool) require.NoError(t, err) @@ -691,7 +702,6 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { func Test_PaymentModelRetryFailedPayments(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -701,15 +711,6 @@ func Test_PaymentModelRetryFailedPayments(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) - DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) - DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) - DeleteAllCountryFixtures(t, ctx, dbConnectionPool) - DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) - DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) - DeleteAllWalletFixtures(t, ctx, dbConnectionPool) - - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -717,11 +718,10 @@ func Test_PaymentModelRetryFailedPayments(t *testing.T) { receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, ReadyReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: ReadyDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) t.Run("does not update payments when no payments IDs is given", func(t *testing.T) { @@ -974,13 +974,11 @@ func Test_PaymentModelCancelPayment(t *testing.T) { DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) - DeleteAllCountryFixtures(t, ctx, dbConnectionPool) DeleteAllAssetFixtures(t, ctx, dbConnectionPool) DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) DeleteAllWalletFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -988,11 +986,10 @@ func Test_PaymentModelCancelPayment(t *testing.T) { receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, ReadyReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: ReadyDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) t.Run("no ready payment for more than 5 days won't cancel any", func(t *testing.T) { @@ -1236,7 +1233,6 @@ func Test_PaymentModel_GetReadyByDisbursementID(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1247,11 +1243,10 @@ func Test_PaymentModel_GetReadyByDisbursementID(t *testing.T) { rw2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, ReadyReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) t.Run("returns empty array when there's no payment ready", func(t *testing.T) { @@ -1302,7 +1297,6 @@ func Test_PaymentModel_GetReadyByPaymentsID(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1313,11 +1307,10 @@ func Test_PaymentModel_GetReadyByPaymentsID(t *testing.T) { rw2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, ReadyReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) t.Run("returns empty array when there's no payment ready", func(t *testing.T) { @@ -1376,7 +1369,6 @@ func Test_PaymentModel_GetReadyByReceiverWalletID(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1387,11 +1379,10 @@ func Test_PaymentModel_GetReadyByReceiverWalletID(t *testing.T) { rw2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, ReadyReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) t.Run("returns empty array when there's no payment ready", func(t *testing.T) { @@ -1463,7 +1454,6 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing t.Run("doesn't get payments when receiver wallet is not registered", func(t *testing.T) { DeleteAllFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1471,11 +1461,10 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, ReadyReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) // It's not possible to have a payment in a end state when the receiver wallet is not registered yet @@ -1498,7 +1487,6 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing t.Run("doesn't get payments not in the Success or Failed statuses", func(t *testing.T) { DeleteAllFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1506,11 +1494,10 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) _ = CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -1531,7 +1518,6 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing t.Run("gets only payments in the Success or Failed statuses", func(t *testing.T) { DeleteAllFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1541,11 +1527,10 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing receiverWallet2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, RegisteredReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) paymentReceiver1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -1584,7 +1569,6 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing t.Run("gets more than one payment when a receiver has payments in the Success or Failed statuses for the same wallet provider", func(t *testing.T) { DeleteAllFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1592,19 +1576,17 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -1643,7 +1625,6 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing t.Run("gets more than one payment when a receiver has payments for more than one wallet provider", func(t *testing.T) { DeleteAllFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet1", "https://www.wallet1.com", "www.wallet1.com", "wallet1://") wallet2 := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet2", "https://www.wallet2.com", "www.wallet2.com", "wallet2://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1653,19 +1634,17 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing receiverWallet2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet2.ID, RegisteredReceiversWalletStatus) disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet1, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet2, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -1718,7 +1697,6 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing t.Run("doesn't return error when receiver wallet has the stellar_memo and stellar_memo_type null", func(t *testing.T) { DeleteAllFixtures(t, ctx, dbConnectionPool) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet1", "https://www.wallet1.com", "www.wallet1.com", "wallet1://") asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -1726,11 +1704,10 @@ func Test_PaymentModel_GetAllReadyToPatchCompletionAnchorTransactions(t *testing receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: StartedDisbursementStatus, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, }) payment := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ @@ -1865,13 +1842,11 @@ func Test_PaymentModel_UpdateStatus(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, }) receiverReady := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) rwReady := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, ReadyReceiversWalletStatus) diff --git a/internal/data/query_builder.go b/internal/data/query_builder.go index 53f6d7f4f..231cc4bec 100644 --- a/internal/data/query_builder.go +++ b/internal/data/query_builder.go @@ -4,6 +4,8 @@ import ( "fmt" "reflect" "strings" + + "github.com/stellar/stellar-disbursement-platform-backend/db" ) // QueryBuilder is a helper struct for building SQL queries @@ -12,6 +14,7 @@ type QueryBuilder struct { whereClause string whereParams []interface{} sortClause string + groupByClause string paginationClause string paginationParams []interface{} forUpdateSkipLocked bool @@ -36,6 +39,11 @@ func (qb *QueryBuilder) AddCondition(condition string, value ...interface{}) *Qu return qb } +func (qb *QueryBuilder) AddGroupBy(fields string) *QueryBuilder { + qb.groupByClause = fmt.Sprintf("GROUP BY %s", fields) + return qb +} + // TODO [SDP-1190]: combine AddCondition and AddOrCondition into one function with a parameter for the condition type // AddOrCondition adds an OR condition to the query func (qb *QueryBuilder) AddOrCondition(condition string, value ...interface{}) *QueryBuilder { @@ -75,6 +83,9 @@ func (qb *QueryBuilder) Build() (string, []interface{}) { query = fmt.Sprintf("%s WHERE 1=1%s", query, qb.whereClause) params = append(params, qb.whereParams...) } + if qb.groupByClause != "" { + query = fmt.Sprintf("%s %s", query, qb.groupByClause) + } if qb.sortClause != "" { query = fmt.Sprintf("%s %s", query, qb.sortClause) } @@ -88,6 +99,12 @@ func (qb *QueryBuilder) Build() (string, []interface{}) { return query, params } +func (qb *QueryBuilder) BuildAndRebind(sqlExec db.SQLExecuter) (string, []interface{}) { + query, params := qb.Build() + query = sqlExec.Rebind(query) + return query, params +} + // BuildSetClause builds a SET clause for an UPDATE query based on the provided struct and its "db" tags. For instance, // given the following struct: // diff --git a/internal/data/query_builder_test.go b/internal/data/query_builder_test.go index 78e6042aa..8df4d29fb 100644 --- a/internal/data/query_builder_test.go +++ b/internal/data/query_builder_test.go @@ -87,6 +87,16 @@ func Test_QueryBuilder(t *testing.T) { assert.Equal(t, expectedQuery, actual) assert.Equal(t, []interface{}{"Disbursement 1", 20, 20}, params) }) + + t.Run("Test AddGroupBy", func(t *testing.T) { + qb := NewQueryBuilder("SELECT * FROM disbursements d") + + qb.AddGroupBy("d.id") + actual, _ := qb.Build() + + expectedQuery := "SELECT * FROM disbursements d GROUP BY d.id" + assert.Equal(t, expectedQuery, actual) + }) } func Test_BuildSetClause(t *testing.T) { diff --git a/internal/data/query_params.go b/internal/data/query_params.go index 6f56fba23..f9a24ba2e 100644 --- a/internal/data/query_params.go +++ b/internal/data/query_params.go @@ -58,3 +58,15 @@ func IsNull(filterKey FilterKey) FilterKey { func LowerThan(filterKey FilterKey) FilterKey { return FilterKey(fmt.Sprintf("%s < ?", filterKey)) } + +type Filter struct { + Key FilterKey + Value interface{} +} + +func NewFilter(key FilterKey, value interface{}) Filter { + return Filter{ + Key: key, + Value: value, + } +} diff --git a/internal/data/receiver_verification.go b/internal/data/receiver_verification.go index b93b33866..6ecea0ebd 100644 --- a/internal/data/receiver_verification.go +++ b/internal/data/receiver_verification.go @@ -13,17 +13,20 @@ import ( "golang.org/x/crypto/bcrypt" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type ReceiverVerification struct { - ReceiverID string `json:"receiver_id" db:"receiver_id"` - VerificationField VerificationField `json:"verification_field" db:"verification_field"` - HashedValue string `json:"hashed_value" db:"hashed_value"` - Attempts int `json:"attempts" db:"attempts"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - ConfirmedAt *time.Time `json:"confirmed_at" db:"confirmed_at"` - FailedAt *time.Time `json:"failed_at" db:"failed_at"` + ReceiverID string `json:"receiver_id" db:"receiver_id"` + VerificationField VerificationType `json:"verification_field" db:"verification_field"` + HashedValue string `json:"hashed_value" db:"hashed_value"` + Attempts int `json:"attempts" db:"attempts"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ConfirmedAt *time.Time `json:"confirmed_at" db:"confirmed_at"` + FailedAt *time.Time `json:"failed_at" db:"failed_at"` + VerificationChannel *message.MessageChannel `json:"verification_channel" db:"verification_channel"` } type ReceiverVerificationModel struct { @@ -31,9 +34,9 @@ type ReceiverVerificationModel struct { } type ReceiverVerificationInsert struct { - ReceiverID string `db:"receiver_id"` - VerificationField VerificationField `db:"verification_field"` - VerificationValue string `db:"hashed_value"` + ReceiverID string `db:"receiver_id"` + VerificationField VerificationType `db:"verification_field"` + VerificationValue string `db:"hashed_value"` } const MaxAttemptsAllowed = 15 @@ -52,7 +55,7 @@ func (rvi *ReceiverVerificationInsert) Validate() error { } // GetByReceiverIDsAndVerificationField returns receiver verifications by receiver IDs and verification type. -func (m *ReceiverVerificationModel) GetByReceiverIDsAndVerificationField(ctx context.Context, sqlExec db.SQLExecuter, receiverIds []string, verificationField VerificationField) ([]*ReceiverVerification, error) { +func (m *ReceiverVerificationModel) GetByReceiverIDsAndVerificationField(ctx context.Context, sqlExec db.SQLExecuter, receiverIds []string, verificationField VerificationType) ([]*ReceiverVerification, error) { receiverVerifications := []*ReceiverVerification{} query := ` SELECT @@ -95,29 +98,31 @@ func (m *ReceiverVerificationModel) GetAllByReceiverId(ctx context.Context, sqlE return receiverVerifications, nil } -// GetLatestByPhoneNumber returns the latest updated receiver verification for some receiver that is associated with a phone number. -func (m *ReceiverVerificationModel) GetLatestByPhoneNumber(ctx context.Context, phoneNumber string) (*ReceiverVerification, error) { - receiverVerification := ReceiverVerification{} +// GetLatestByContactInfo returns the latest updated receiver verification for a receiver associated with a phone number or email. +func (m *ReceiverVerificationModel) GetLatestByContactInfo(ctx context.Context, contactInfo string) (*ReceiverVerification, error) { query := ` SELECT rv.* FROM receiver_verifications rv - JOIN receivers r ON rv.receiver_id = r.id + JOIN receivers r ON rv.receiver_id = r.id WHERE r.phone_number = $1 + OR r.email = $1 ORDER BY rv.updated_at DESC, rv.verification_field ASC LIMIT 1 ` - err := m.dbConnectionPool.GetContext(ctx, &receiverVerification, query, phoneNumber) + receiverVerification := ReceiverVerification{} + err := m.dbConnectionPool.GetContext(ctx, &receiverVerification, query, contactInfo) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrRecordNotFound } - return nil, fmt.Errorf("fetching receiver verifications for phone number %s: %w", phoneNumber, err) + truncatedContactInfo := utils.TruncateString(contactInfo, 3) + return nil, fmt.Errorf("fetching receiver verifications for contact info %s: %w", truncatedContactInfo, err) } return &receiverVerification, nil @@ -154,7 +159,7 @@ func (m *ReceiverVerificationModel) Insert(ctx context.Context, sqlExec db.SQLEx func (m *ReceiverVerificationModel) UpdateVerificationValue(ctx context.Context, sqlExec db.SQLExecuter, receiverID string, - verificationField VerificationField, + verificationField VerificationType, verificationValue string, ) error { log.Ctx(ctx).Infof("Calling UpdateVerificationValue for receiver %s and verification field %s", receiverID, verificationField) @@ -179,7 +184,7 @@ func (m *ReceiverVerificationModel) UpdateVerificationValue(ctx context.Context, // UpsertVerificationValue creates or updates the receiver's verification. In case the verification exists and it's already confirmed by the receiver // it's not updated. -func (m *ReceiverVerificationModel) UpsertVerificationValue(ctx context.Context, sqlExec db.SQLExecuter, receiverID string, verificationField VerificationField, verificationValue string) error { +func (m *ReceiverVerificationModel) UpsertVerificationValue(ctx context.Context, sqlExec db.SQLExecuter, receiverID string, verificationField VerificationType, verificationValue string) error { log.Ctx(ctx).Infof("Calling UpsertVerificationValue for receiver %s and verification field %s", receiverID, verificationField) hashedValue, err := HashVerificationValue(verificationValue) if err != nil { @@ -207,29 +212,68 @@ func (m *ReceiverVerificationModel) UpsertVerificationValue(ctx context.Context, return nil } +type ReceiverVerificationUpdate struct { + ReceiverID string `db:"receiver_id"` + VerificationField VerificationType `db:"verification_field"` + VerificationChannel message.MessageChannel `db:"verification_channel"` + Attempts *int `db:"attempts"` + ConfirmedAt *time.Time `db:"confirmed_at"` + FailedAt *time.Time `db:"failed_at"` +} + +func (rvu ReceiverVerificationUpdate) Validate() error { + if strings.TrimSpace(rvu.ReceiverID) == "" { + return fmt.Errorf("receiver id is required") + } + if rvu.VerificationField == "" { + return fmt.Errorf("verification field is required") + } + if rvu.VerificationChannel == "" { + return fmt.Errorf("verification channel is required") + } + return nil +} + // UpdateReceiverVerification updates the attempts, confirmed_at, and failed_at values of a receiver verification. -func (m *ReceiverVerificationModel) UpdateReceiverVerification(ctx context.Context, receiverVerification ReceiverVerification, sqlExec db.SQLExecuter) error { +func (m *ReceiverVerificationModel) UpdateReceiverVerification(ctx context.Context, update ReceiverVerificationUpdate, sqlExec db.SQLExecuter) error { + if err := update.Validate(); err != nil { + return fmt.Errorf("validating receiver verification update: %w", err) + } + + fields := []string{} + args := []interface{}{} + + if update.Attempts != nil { + fields = append(fields, "attempts = ?") + args = append(args, update.Attempts) + } + + if update.ConfirmedAt != nil { + fields = append(fields, "confirmed_at = ?") + args = append(args, update.ConfirmedAt) + } + + if update.FailedAt != nil { + fields = append(fields, "failed_at = ?") + args = append(args, update.FailedAt) + } + query := ` UPDATE receiver_verifications SET - attempts = $1, - confirmed_at = $2, - failed_at = $3 - WHERE - receiver_id = $4 AND verification_field = $5 + %s, + verification_channel = ? + WHERE + receiver_id = ? AND + verification_field = ? ` - _, err := sqlExec.ExecContext(ctx, - query, - receiverVerification.Attempts, - receiverVerification.ConfirmedAt, - receiverVerification.FailedAt, - receiverVerification.ReceiverID, - receiverVerification.VerificationField, - ) + args = append(args, update.VerificationChannel, update.ReceiverID, update.VerificationField) + query = sqlExec.Rebind(fmt.Sprintf(query, strings.Join(fields, ", "))) + _, err := sqlExec.ExecContext(ctx, query, args...) if err != nil { - return fmt.Errorf("error updating receiver verification: %w", err) + return fmt.Errorf("updating receiver verification: %w", err) } return nil diff --git a/internal/data/receiver_verification_test.go b/internal/data/receiver_verification_test.go index 86b98648c..b4a3ba99f 100644 --- a/internal/data/receiver_verification_test.go +++ b/internal/data/receiver_verification_test.go @@ -11,6 +11,8 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) func Test_ReceiverVerificationModel_GetByReceiverIDsAndVerificationField(t *testing.T) { @@ -29,17 +31,17 @@ func Test_ReceiverVerificationModel_GetByReceiverIDsAndVerificationField(t *test verification1 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver1.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: "1990-01-01", }) verification2 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver2.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: "1990-01-02", }) CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver3.ID, - VerificationField: VerificationFieldPin, + VerificationField: VerificationTypePin, VerificationValue: "1990-01-03", }) @@ -48,11 +50,11 @@ func Test_ReceiverVerificationModel_GetByReceiverIDsAndVerificationField(t *test receiverVerificationModel := ReceiverVerificationModel{} - actualVerifications, err := receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receiver1.ID, receiver2.ID, receiver3.ID}, VerificationFieldDateOfBirth) + actualVerifications, err := receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receiver1.ID, receiver2.ID, receiver3.ID}, VerificationTypeDateOfBirth) require.NoError(t, err) assert.Equal(t, 2, len(actualVerifications)) for _, v := range actualVerifications { - assert.Equal(t, VerificationFieldDateOfBirth, v.VerificationField) + assert.Equal(t, VerificationTypeDateOfBirth, v.VerificationField) assert.Contains(t, verifiedReceivers, v.ReceiverID) assert.Contains(t, verifieldValues, v.HashedValue) } @@ -82,22 +84,22 @@ func Test_ReceiverVerificationModel_GetAllByReceiverId(t *testing.T) { t.Run("returns all when the receiver has verifications registered", func(t *testing.T) { verification1 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: "1990-01-01", }) verification2 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldYearMonth, + VerificationField: VerificationTypeYearMonth, VerificationValue: "1990-01", }) verification3 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldPin, + VerificationField: VerificationTypePin, VerificationValue: "1234", }) verification4 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldNationalID, + VerificationField: VerificationTypeNationalID, VerificationValue: "5678", }) @@ -109,28 +111,28 @@ func Test_ReceiverVerificationModel_GetAllByReceiverId(t *testing.T) { assert.Equal(t, []ReceiverVerification{ { ReceiverID: receiver.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, HashedValue: verification1.HashedValue, CreatedAt: verification1.CreatedAt, UpdatedAt: verification1.UpdatedAt, }, { ReceiverID: receiver.ID, - VerificationField: VerificationFieldYearMonth, + VerificationField: VerificationTypeYearMonth, HashedValue: verification2.HashedValue, CreatedAt: verification2.CreatedAt, UpdatedAt: verification2.UpdatedAt, }, { ReceiverID: receiver.ID, - VerificationField: VerificationFieldPin, + VerificationField: VerificationTypePin, HashedValue: verification3.HashedValue, CreatedAt: verification3.CreatedAt, UpdatedAt: verification3.UpdatedAt, }, { ReceiverID: receiver.ID, - VerificationField: VerificationFieldNationalID, + VerificationField: VerificationTypeNationalID, HashedValue: verification4.HashedValue, CreatedAt: verification4.CreatedAt, UpdatedAt: verification4.UpdatedAt, @@ -155,7 +157,7 @@ func Test_ReceiverVerificationModel_GetReceiverVerificationByReceiverId(t *testi t.Run("returns error when the receiver has no verifications registered", func(t *testing.T) { receiverVerificationModel := ReceiverVerificationModel{dbConnectionPool: dbConnectionPool} - _, err := receiverVerificationModel.GetLatestByPhoneNumber(ctx, receiver.PhoneNumber) + _, err := receiverVerificationModel.GetLatestByContactInfo(ctx, receiver.PhoneNumber) require.Error(t, err, fmt.Errorf("cannot query any receiver verifications for phone number %s", receiver.PhoneNumber)) }) @@ -163,39 +165,39 @@ func Test_ReceiverVerificationModel_GetReceiverVerificationByReceiverId(t *testi earlierTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) verification1 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: "1990-01-01", }) verification1.UpdatedAt = earlierTime verification2 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldYearMonth, + VerificationField: VerificationTypeYearMonth, VerificationValue: "1990-01", }) verification2.UpdatedAt = earlierTime verification3 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldPin, + VerificationField: VerificationTypePin, VerificationValue: "1234", }) verification3.UpdatedAt = earlierTime verification4 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldNationalID, + VerificationField: VerificationTypeNationalID, VerificationValue: "5678", }) receiverVerificationModel := ReceiverVerificationModel{dbConnectionPool: dbConnectionPool} - actualVerification, err := receiverVerificationModel.GetLatestByPhoneNumber(ctx, receiver.PhoneNumber) + actualVerification, err := receiverVerificationModel.GetLatestByContactInfo(ctx, receiver.PhoneNumber) require.NoError(t, err) assert.Equal(t, ReceiverVerification{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldNationalID, + VerificationField: VerificationTypeNationalID, HashedValue: verification4.HashedValue, CreatedAt: verification4.CreatedAt, UpdatedAt: verification4.UpdatedAt, @@ -219,14 +221,14 @@ func Test_ReceiverVerificationModel_Insert(t *testing.T) { verification := ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: "1990-01-01", } _, err = receiverVerificationModel.Insert(ctx, dbConnectionPool, verification) require.NoError(t, err) - actualVerification, err := receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receiver.ID}, VerificationFieldDateOfBirth) + actualVerification, err := receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receiver.ID}, VerificationTypeDateOfBirth) require.NoError(t, err) verified := CompareVerificationValue(actualVerification[0].HashedValue, verification.VerificationValue) assert.True(t, verified) @@ -251,7 +253,7 @@ func Test_ReceiverVerificationModel_UpdateVerificationValue(t *testing.T) { oldExpectedValue := "1990-01-01" actualBeforeUpdate, err := receiverVerificationModel.Insert(ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: oldExpectedValue, }) require.NoError(t, err) @@ -260,10 +262,10 @@ func Test_ReceiverVerificationModel_UpdateVerificationValue(t *testing.T) { assert.True(t, verified) newExpectedValue := "1990-01-02" - err = receiverVerificationModel.UpdateVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldDateOfBirth, newExpectedValue) + err = receiverVerificationModel.UpdateVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationTypeDateOfBirth, newExpectedValue) require.NoError(t, err) - actualAfterUpdate, err := receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receiver.ID}, VerificationFieldDateOfBirth) + actualAfterUpdate, err := receiverVerificationModel.GetByReceiverIDsAndVerificationField(ctx, dbConnectionPool, []string{receiver.ID}, VerificationTypeDateOfBirth) require.NoError(t, err) verified = CompareVerificationValue(actualAfterUpdate[0].HashedValue, newExpectedValue) assert.True(t, verified) @@ -280,7 +282,7 @@ func Test_ReceiverVerificationModel_UpsertVerificationValue(t *testing.T) { ctx := context.Background() receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) receiverVerificationModel := ReceiverVerificationModel{} - getReceiverVerificationHashedValue := func(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, receiverID string, verificationField VerificationField) string { + getReceiverVerificationHashedValue := func(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, receiverID string, verificationField VerificationType) string { const q = "SELECT hashed_value FROM receiver_verifications WHERE receiver_id = $1 AND verification_field = $2" var hashedValue string qErr := dbConnectionPool.GetContext(ctx, &hashedValue, q, receiverID, verificationField) @@ -291,20 +293,20 @@ func Test_ReceiverVerificationModel_UpsertVerificationValue(t *testing.T) { t.Run("upserts the verification value successfully", func(t *testing.T) { // Inserts the verification value firstVerificationValue := "123456" - err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldPin, firstVerificationValue) + err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationTypePin, firstVerificationValue) require.NoError(t, err) - currentHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationFieldPin) + currentHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationTypePin) assert.NotEmpty(t, currentHashedValue) verified := CompareVerificationValue(currentHashedValue, firstVerificationValue) assert.True(t, verified) // Updates the verification value newVerificationValue := "654321" - err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldPin, newVerificationValue) + err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationTypePin, newVerificationValue) require.NoError(t, err) - afterUpdateHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationFieldPin) + afterUpdateHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationTypePin) assert.NotEmpty(t, afterUpdateHashedValue) // Checking if the hashed value is NOT the first one. @@ -318,30 +320,29 @@ func Test_ReceiverVerificationModel_UpsertVerificationValue(t *testing.T) { t.Run("doesn't update the verification value when it was confirmed by the receiver", func(t *testing.T) { // Inserts the verification value firstVerificationValue := "0301016957187" - err := receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldNationalID, firstVerificationValue) + err := receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationTypeNationalID, firstVerificationValue) require.NoError(t, err) - currentHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationFieldNationalID) + currentHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationTypeNationalID) assert.NotEmpty(t, currentHashedValue) verified := CompareVerificationValue(currentHashedValue, firstVerificationValue) assert.True(t, verified) // Receiver confirmed the verification value now := time.Now() - err = receiverVerificationModel.UpdateReceiverVerification(ctx, ReceiverVerification{ - ReceiverID: receiver.ID, - VerificationField: VerificationFieldNationalID, - Attempts: 0, - ConfirmedAt: &now, - FailedAt: nil, + err = receiverVerificationModel.UpdateReceiverVerification(ctx, ReceiverVerificationUpdate{ + ReceiverID: receiver.ID, + VerificationField: VerificationTypeNationalID, + ConfirmedAt: &now, + VerificationChannel: message.MessageChannelSMS, }, dbConnectionPool) require.NoError(t, err) newVerificationValue := "0301017821085" - err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldNationalID, newVerificationValue) + err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationTypeNationalID, newVerificationValue) require.NoError(t, err) - afterUpdateHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationFieldNationalID) + afterUpdateHashedValue := getReceiverVerificationHashedValue(t, ctx, dbConnectionPool, receiver.ID, VerificationTypeNationalID) assert.NotEmpty(t, currentHashedValue) // Checking if the hashed value is NOT the new one. @@ -370,7 +371,7 @@ func Test_ReceiverVerificationModel_UpdateReceiverVerification(t *testing.T) { verification := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldDateOfBirth, + VerificationField: VerificationTypeDateOfBirth, VerificationValue: "1990-01-01", }) @@ -379,11 +380,16 @@ func Test_ReceiverVerificationModel_UpdateReceiverVerification(t *testing.T) { assert.Equal(t, 0, verification.Attempts) date := time.Date(2023, 1, 10, 23, 40, 20, 1000, time.UTC) - verification.Attempts = 5 - verification.ConfirmedAt = &date - verification.FailedAt = &date + verificationUpdate := ReceiverVerificationUpdate{ + ReceiverID: receiver.ID, + VerificationField: VerificationTypeDateOfBirth, + Attempts: utils.IntPtr(5), + ConfirmedAt: &date, + FailedAt: &date, + VerificationChannel: message.MessageChannelSMS, + } - err = receiverVerificationModel.UpdateReceiverVerification(ctx, *verification, dbConnectionPool) + err = receiverVerificationModel.UpdateReceiverVerification(ctx, verificationUpdate, dbConnectionPool) require.NoError(t, err) // validate if the receiver verification has been updated @@ -391,7 +397,8 @@ func Test_ReceiverVerificationModel_UpdateReceiverVerification(t *testing.T) { SELECT rv.attempts, rv.confirmed_at, - rv.failed_at + rv.failed_at, + rv.verification_channel FROM receiver_verifications rv WHERE @@ -404,6 +411,56 @@ func Test_ReceiverVerificationModel_UpdateReceiverVerification(t *testing.T) { assert.Equal(t, &date, receiverVerificationUpdated.ConfirmedAt) assert.Equal(t, &date, receiverVerificationUpdated.FailedAt) assert.Equal(t, 5, receiverVerificationUpdated.Attempts) + assert.Equal(t, message.MessageChannelSMS, *receiverVerificationUpdated.VerificationChannel) +} + +func Test_ReceiverVerificationUpdate_Validate(t *testing.T) { + tests := []struct { + name string + update ReceiverVerificationUpdate + wantErr error + }{ + { + name: "valid update", + update: ReceiverVerificationUpdate{ + ReceiverID: "receiver-id", + VerificationField: VerificationTypeDateOfBirth, + VerificationChannel: message.MessageChannelSMS, + }, + wantErr: nil, + }, + { + name: "invalid update with empty receiver id", + update: ReceiverVerificationUpdate{}, + wantErr: fmt.Errorf("receiver id is required"), + }, + { + name: "invalid update with empty verification field", + update: ReceiverVerificationUpdate{ + ReceiverID: "receiver-id", + }, + wantErr: fmt.Errorf("verification field is required"), + }, + { + name: "invalid update with empty verification channel", + update: ReceiverVerificationUpdate{ + ReceiverID: "receiver-id", + VerificationField: VerificationTypeDateOfBirth, + }, + wantErr: fmt.Errorf("verification channel is required"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.update.Validate() + if tt.wantErr != nil { + assert.EqualError(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } } func Test_ReceiverVerificationModel_CheckTotalAttempts(t *testing.T) { @@ -422,29 +479,77 @@ func Test_ReceiverVerificationModel_CheckTotalAttempts(t *testing.T) { }) } -func Test_ReceiverVerificationModel_GetLatestByPhoneNumber(t *testing.T) { +func Test_ReceiverVerificationModel_GetLatestByContactInfo(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() - ctx := context.Background() - - receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) receiverVerificationModel := ReceiverVerificationModel{dbConnectionPool: dbConnectionPool} + ctx := context.Background() - err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldDateOfBirth, "1990-01-01") - require.NoError(t, err) - err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, VerificationFieldPin, "123456") - require.NoError(t, err) - - verification, err := receiverVerificationModel.GetLatestByPhoneNumber(ctx, receiver.PhoneNumber) - require.NoError(t, err) + oldVerificationType := VerificationTypeDateOfBirth + oldVerificationValue := "1990-01-01" + latestVerificationType := VerificationTypePin + latestVerificationValue := "123456" + + testCases := []struct { + name string + contactInfo func(r Receiver, contactType ReceiverContactType) string + wantErrorIs error + }{ + { + name: "fails with ErrRecordNotFound when the contact info is not found", + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return "+13334445555" + }, + wantErrorIs: ErrRecordNotFound, + }, + { + name: "🎉 successfully finds the latest receiver verification", + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return r.ContactByType(contactType) + }, + wantErrorIs: nil, + }, + } - assert.Equal(t, VerificationFieldPin, verification.VerificationField) - assert.True(t, CompareVerificationValue(verification.HashedValue, "123456")) + for _, contactType := range GetAllReceiverContactTypes() { + receiverInsert := &Receiver{} + switch contactType { + case ReceiverContactTypeSMS: + receiverInsert.PhoneNumber = "+141555555555" + case ReceiverContactTypeEmail: + receiverInsert.Email = "foobar@test.com" + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + defer DeleteAllReceiverVerificationFixtures(t, ctx, dbConnectionPool) + + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, receiverInsert) + + err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, oldVerificationType, oldVerificationValue) + require.NoError(t, err) + err = receiverVerificationModel.UpsertVerificationValue(ctx, dbConnectionPool, receiver.ID, latestVerificationType, latestVerificationValue) + require.NoError(t, err) + + contactInfo := tc.contactInfo(*receiver, contactType) + verification, err := receiverVerificationModel.GetLatestByContactInfo(ctx, contactInfo) + if tc.wantErrorIs != nil { + require.Error(t, err) + assert.ErrorIs(t, err, ErrRecordNotFound) + assert.Nil(t, verification) + } else { + require.NoError(t, err) + assert.Equal(t, latestVerificationType, verification.VerificationField) + assert.True(t, CompareVerificationValue(verification.HashedValue, latestVerificationValue)) + } + }) + } + } } func Test_ReceiverVerification_HashAndCompareVerificationValue(t *testing.T) { diff --git a/internal/data/receiver_wallets_state_machine.go b/internal/data/receiver_wallets_state_machine.go index b60696fd2..9e443d71a 100644 --- a/internal/data/receiver_wallets_state_machine.go +++ b/internal/data/receiver_wallets_state_machine.go @@ -1,5 +1,10 @@ package data +import ( + "fmt" + "strings" +) + type ReceiversWalletStatus string const ( @@ -31,3 +36,13 @@ func ReceiversWalletStateMachineWithInitialState(initialState ReceiversWalletSta func (status ReceiversWalletStatus) State() State { return State(status) } + +// Validate validates the receiver wallet status +func (status ReceiversWalletStatus) Validate() error { + switch ReceiversWalletStatus(strings.ToUpper(string(status))) { + case DraftReceiversWalletStatus, ReadyReceiversWalletStatus, RegisteredReceiversWalletStatus, FlaggedReceiversWalletStatus: + return nil + default: + return fmt.Errorf("invalid receiver wallet status %q", status) + } +} diff --git a/internal/data/receiver_wallets_state_machine_test.go b/internal/data/receiver_wallets_state_machine_test.go index a35684aac..9b6d5cdb1 100644 --- a/internal/data/receiver_wallets_state_machine_test.go +++ b/internal/data/receiver_wallets_state_machine_test.go @@ -1,6 +1,10 @@ package data -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func Test_ReceiversWalletStatus_TransitionTo(t *testing.T) { tests := []struct { @@ -61,3 +65,48 @@ func Test_ReceiversWalletStatus_TransitionTo(t *testing.T) { }) } } + +func Test_ReceiversWalletStatus_Validate(t *testing.T) { + tests := []struct { + name string + status ReceiversWalletStatus + err string + }{ + { + "validate Draft receiver wallet status", + DraftReceiversWalletStatus, + "", + }, + { + "validate Ready receiver wallet status", + ReadyReceiversWalletStatus, + "", + }, + { + "validate Registered receiver wallet status", + RegisteredReceiversWalletStatus, + "", + }, + { + "validate Flagged receiver wallet status", + FlaggedReceiversWalletStatus, + "", + }, + { + "invalid receiver wallet status", + ReceiversWalletStatus("INVALID"), + "invalid receiver wallet status \"INVALID\"", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.status.Validate() + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.err) + } + }) + } +} diff --git a/internal/data/receivers.go b/internal/data/receivers.go index 9beb4a3ac..3cdac40b0 100644 --- a/internal/data/receivers.go +++ b/internal/data/receivers.go @@ -17,20 +17,43 @@ import ( type Receiver struct { ID string `json:"id" db:"id"` - Email *string `json:"email,omitempty" db:"email"` - PhoneNumber string `json:"phone_number,omitempty" db:"phone_number"` ExternalID string `json:"external_id,omitempty" db:"external_id"` CreatedAt *time.Time `json:"created_at,omitempty" db:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty" db:"updated_at"` + Email string `json:"email,omitempty" db:"email"` + PhoneNumber string `json:"phone_number,omitempty" db:"phone_number"` ReceiverStats } +type ReceiverContactType string + +const ( + ReceiverContactTypeEmail ReceiverContactType = "EMAIL" + ReceiverContactTypeSMS ReceiverContactType = "PHONE_NUMBER" +) + +func (r Receiver) ContactByType(contactType ReceiverContactType) string { + switch contactType { + case ReceiverContactTypeEmail: + return r.Email + case ReceiverContactTypeSMS: + return r.PhoneNumber + default: + return "" + } +} + +func GetAllReceiverContactTypes() []ReceiverContactType { + return []ReceiverContactType{ReceiverContactTypeEmail, ReceiverContactTypeSMS} +} + type ReceiverRegistrationRequest struct { - PhoneNumber string `json:"phone_number"` - OTP string `json:"otp"` - VerificationValue string `json:"verification"` - VerificationType VerificationField `json:"verification_type"` - ReCAPTCHAToken string `json:"recaptcha_token"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + OTP string `json:"otp"` + VerificationValue string `json:"verification"` + VerificationField VerificationType `json:"verification_field"` + ReCAPTCHAToken string `json:"recaptcha_token"` } type ReceiverStats struct { @@ -59,13 +82,35 @@ var ( type ReceiverModel struct{} type ReceiverInsert struct { - PhoneNumber string `db:"phone_number"` + PhoneNumber *string `db:"phone_number"` + Email *string `db:"email"` ExternalId *string `db:"external_id"` } -type ReceiverUpdate struct { - Email string `db:"email"` - ExternalId string `db:"external_id"` +type ReceiverUpdate ReceiverInsert + +func (ru ReceiverUpdate) IsEmpty() bool { + return ru.Email == nil && ru.ExternalId == nil && ru.PhoneNumber == nil +} + +func (ru ReceiverUpdate) Validate() error { + if ru.IsEmpty() { + return fmt.Errorf("no values provided to update receiver") + } + + if ru.Email != nil { + if err := utils.ValidateEmail(*ru.Email); err != nil { + return fmt.Errorf("validating email: %w", err) + } + } + + if ru.PhoneNumber != nil { + if err := utils.ValidatePhoneNumber(*ru.PhoneNumber); err != nil { + return fmt.Errorf("validating phone number: %w", err) + } + } + + return nil } type ReceivedAmounts []Amount @@ -74,14 +119,14 @@ type ReceivedAmounts []Amount func (ra *ReceivedAmounts) Scan(src interface{}) error { var receivedAmounts sql.NullString if err := (&receivedAmounts).Scan(src); err != nil { - return fmt.Errorf("error scanning status history value: %w", err) + return fmt.Errorf("scanning status history value: %w", err) } if receivedAmounts.Valid { var shEntry []Amount err := json.Unmarshal([]byte(receivedAmounts.String), &shEntry) if err != nil { - return fmt.Errorf("error unmarshaling status_history column: %w", err) + return fmt.Errorf("unmarshaling status_history column: %w", err) } *ra = shEntry @@ -143,8 +188,8 @@ func (r *ReceiverModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id stri SELECT rc.id, rc.external_id, + COALESCE(rc.phone_number, '') as phone_number, COALESCE(rc.email, '') as email, - rc.phone_number, rc.created_at, rc.updated_at, COALESCE(total_payments, 0) as total_payments, @@ -164,7 +209,7 @@ func (r *ReceiverModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id stri if errors.Is(err, sql.ErrNoRows) { return nil, ErrRecordNotFound } else { - return nil, fmt.Errorf("error querying receiver ID: %w", err) + return nil, fmt.Errorf("querying receiver ID: %w", err) } } @@ -184,7 +229,7 @@ func (r *ReceiverModel) Count(ctx context.Context, sqlExec db.SQLExecuter, query err := sqlExec.GetContext(ctx, &count, query, params...) if err != nil { - return 0, fmt.Errorf("error counting payments: %w", err) + return 0, fmt.Errorf("counting payments: %w", err) } return count, nil @@ -236,7 +281,7 @@ func (r *ReceiverModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, quer distinct(r.id), r.external_id, COALESCE(r.email, '') as email, - r.phone_number, + COALESCE(r.phone_number, '') as phone_number, r.created_at, r.updated_at, COALESCE(total_payments, 0) as total_payments, @@ -251,13 +296,12 @@ func (r *ReceiverModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, quer LEFT JOIN receiver_wallets rw ON rw.receiver_id = r.id LEFT JOIN registered_receiver_wallets_count_cte rrwc ON rrwc.receiver_id = r.id ` - receiverQuery := ` SELECT r.id, - r.email, + COALESCE(r.phone_number, '') as phone_number, + COALESCE(r.email, '') as email, r.external_id, - r.phone_number, r.created_at, r.updated_at FROM @@ -269,7 +313,7 @@ func (r *ReceiverModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, quer err := sqlExec.SelectContext(ctx, &receivers, query, params...) if err != nil { - return nil, fmt.Errorf("error querying receivers: %w", err) + return nil, fmt.Errorf("querying receivers: %w", err) } return receivers, nil @@ -318,22 +362,25 @@ func (r *ReceiverModel) Insert(ctx context.Context, sqlExec db.SQLExecuter, inse query := ` INSERT INTO receivers ( phone_number, + email, external_id ) VALUES ( $1, - $2 + $2, + $3 ) RETURNING id, - phone_number, + COALESCE(phone_number, '') as phone_number, + COALESCE(email, '') as email, external_id, created_at, updated_at ` var receiver Receiver - err := sqlExec.GetContext(ctx, &receiver, query, insert.PhoneNumber, insert.ExternalId) + err := sqlExec.GetContext(ctx, &receiver, query, insert.PhoneNumber, insert.Email, insert.ExternalId) if err != nil { - return nil, fmt.Errorf("error inserting receiver: %w", err) + return nil, fmt.Errorf("inserting receiver: %w", err) } return &receiver, nil @@ -341,24 +388,29 @@ func (r *ReceiverModel) Insert(ctx context.Context, sqlExec db.SQLExecuter, inse // Update updates the receiver Email and/or External ID. func (r *ReceiverModel) Update(ctx context.Context, sqlExec db.SQLExecuter, ID string, receiverUpdate ReceiverUpdate) error { - if receiverUpdate.Email == "" && receiverUpdate.ExternalId == "" { - return fmt.Errorf("provide at least one of these values: Email or ExternalID") + if err := receiverUpdate.Validate(); err != nil { + return fmt.Errorf("validating receiver update: %w", err) } args := []interface{}{} fields := []string{} - if receiverUpdate.Email != "" { - if err := utils.ValidateEmail(receiverUpdate.Email); err != nil { - return fmt.Errorf("error validating email: %w", err) - } + if receiverUpdate.PhoneNumber != nil { + phoneNumber := *receiverUpdate.PhoneNumber + fields = append(fields, "phone_number = ?") + args = append(args, phoneNumber) + } + + if receiverUpdate.Email != nil { + email := *receiverUpdate.Email fields = append(fields, "email = ?") - args = append(args, receiverUpdate.Email) + args = append(args, email) } - if receiverUpdate.ExternalId != "" { + if receiverUpdate.ExternalId != nil { + externalID := *receiverUpdate.ExternalId fields = append(fields, "external_id = ?") - args = append(args, receiverUpdate.ExternalId) + args = append(args, externalID) } args = append(args, ID) @@ -376,46 +428,52 @@ func (r *ReceiverModel) Update(ctx context.Context, sqlExec db.SQLExecuter, ID s _, err := sqlExec.ExecContext(ctx, query, args...) if err != nil { - return fmt.Errorf("error updating receiver: %w", err) + return fmt.Errorf("updating receiver: %w", err) } return nil } -// GetByPhoneNumbers search for receivers by phone numbers -func (r *ReceiverModel) GetByPhoneNumbers(ctx context.Context, sqlExec db.SQLExecuter, ids []string) ([]*Receiver, error) { +// GetByContacts search for receivers by phone numbers and email. +func (r *ReceiverModel) GetByContacts(ctx context.Context, sqlExec db.SQLExecuter, contacts ...string) ([]*Receiver, error) { receivers := []*Receiver{} + if len(contacts) == 0 { + return receivers, nil + } + query := ` SELECT r.id, - r.phone_number, + COALESCE(r.phone_number, '') as phone_number, + COALESCE(r.email, '') as email, r.external_id, r.created_at, r.updated_at FROM receivers r - WHERE r.phone_number = ANY($1) + WHERE r.phone_number = ANY($1) OR r.email = ANY($1) ` - err := sqlExec.SelectContext(ctx, &receivers, query, pq.Array(ids)) + err := sqlExec.SelectContext(ctx, &receivers, query, pq.Array(contacts)) if err != nil { - return nil, fmt.Errorf("error fetching receiver ids by phone numbers: %w", err) + return nil, fmt.Errorf("fetching receivers by phone numbers or email: %w", err) } return receivers, nil } -// DeleteByPhoneNumber deletes a receiver by phone number. It also deletes the associated entries in other tables: -// messages, payments, receiver_verifications, receiver_wallets, receivers, disbursements, submitter_transactions -func (r *ReceiverModel) DeleteByPhoneNumber(ctx context.Context, dbConnectionPool db.DBConnectionPool, phoneNumber string) error { +// DeleteByContactInfo deletes a receiver by phone number or email. It also deletes the associated entries in other +// tables: messages, payments, receiver_verifications, receiver_wallets, receivers, disbursements, +// submitter_transactions. +func (r *ReceiverModel) DeleteByContactInfo(ctx context.Context, dbConnectionPool db.DBConnectionPool, contactInfo string) error { return db.RunInTransaction(ctx, dbConnectionPool, nil, func(dbTx db.DBTransaction) error { - query := "SELECT id FROM receivers WHERE phone_number = $1" + query := "SELECT id FROM receivers WHERE phone_number = $1 OR email = $1" var receiverID string - err := dbTx.GetContext(ctx, &receiverID, query, phoneNumber) + err := dbTx.GetContext(ctx, &receiverID, query, contactInfo) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrRecordNotFound } - return fmt.Errorf("error fetching receiver by phone number %s: %w", phoneNumber, err) + return fmt.Errorf("fetching receiver by contact info %s: %w", contactInfo, err) } type QueryWithParams struct { @@ -436,7 +494,7 @@ func (r *ReceiverModel) DeleteByPhoneNumber(ctx context.Context, dbConnectionPoo for _, qwp := range queries { _, err = dbTx.ExecContext(ctx, qwp.Query, qwp.Params...) if err != nil { - return fmt.Errorf("error executing query %q: %w", qwp.Query, err) + return fmt.Errorf("executing query %q: %w", qwp.Query, err) } } diff --git a/internal/data/receivers_test.go b/internal/data/receivers_test.go index 9467807c2..3acf6f51c 100644 --- a/internal/data/receivers_test.go +++ b/internal/data/receivers_test.go @@ -17,7 +17,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) -func Test_ReceiversModelGet(t *testing.T) { +func Test_ReceiversModel_Get(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -28,7 +28,6 @@ func Test_ReceiversModelGet(t *testing.T) { ctx := context.Background() asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) @@ -36,10 +35,9 @@ func Test_ReceiversModelGet(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} disbursement := Disbursement{ - Status: DraftDisbursementStatus, - Asset: asset, - Country: country, - Wallet: wallet, + Status: DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, } stellarTransactionID, err := utils.RandomString(64) @@ -338,7 +336,7 @@ func Test_ReceiversModelGet(t *testing.T) { }) } -func Test_ReceiversModelCount(t *testing.T) { +func Test_ReceiversModel_Count(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -428,7 +426,7 @@ func Test_ReceiversModelCount(t *testing.T) { }) } -func Test_ReceiversModelGetAll(t *testing.T) { +func Test_ReceiversModel_GetAll(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -462,7 +460,7 @@ func Test_ReceiversModelGetAll(t *testing.T) { date := time.Date(2023, 1, 10, 23, 40, 20, 1431, time.UTC) receiver1Email := "receiver1@mock.com" receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ - Email: &receiver1Email, + Email: receiver1Email, PhoneNumber: "+99991111", ExternalID: "external-id-1", CreatedAt: &date, @@ -472,7 +470,7 @@ func Test_ReceiversModelGetAll(t *testing.T) { date = time.Date(2023, 3, 10, 23, 40, 20, 1431, time.UTC) receiver2Email := "receiver2@mock.com" receiver2 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ - Email: &receiver2Email, + Email: receiver2Email, PhoneNumber: "+99992222", ExternalID: "external-id-2", CreatedAt: &date, @@ -853,9 +851,8 @@ func Test_ReceiversModel_GetAll_makeSureReceiversWithMultipleWalletsWillReturnAS wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") wallet2 := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet2", "https://www.wallet2.com", "www.wallet2.com", "wallet2://") - receiver1Email := "receiver1@mock.com" receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ - Email: &receiver1Email, + Email: "receiver1@mock.com", PhoneNumber: "+99991111", ExternalID: "external-id-1", }) @@ -902,7 +899,7 @@ func Test_ReceiversModel_ParseReceiverIDs(t *testing.T) { require.NoError(t, err) } -func Test_DeleteByPhoneNumber(t *testing.T) { +func Test_DeleteByContactInfo(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -913,186 +910,195 @@ func Test_DeleteByPhoneNumber(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) - // 0. returns ErrNotFound for users that don't exist: - t.Run("User does not exist", func(t *testing.T) { - err = models.Receiver.DeleteByPhoneNumber(ctx, dbConnectionPool, "+14152222222") - require.ErrorIs(t, err, ErrRecordNotFound) - }) - - // 1. Create country, asset, and wallet (won't be deleted) - country := CreateCountryFixture(t, ctx, dbConnectionPool, "ATL", "Atlantis") - asset := CreateAssetFixture(t, ctx, dbConnectionPool, "FOO1", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "walletA", "https://www.a.com", "www.a.com", "a://") - - // 2. Create receiverX (that will be deleted) and all receiverX dependent resources that will also be deleted: - receiverX := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - receiverWalletX := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverX.ID, wallet.ID, DraftReceiversWalletStatus) - _ = CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ - ReceiverID: receiverX.ID, - VerificationField: VerificationFieldDateOfBirth, - VerificationValue: "1990-01-01", - }) - messageX := CreateMessageFixture(t, ctx, dbConnectionPool, &Message{ - Type: message.MessengerTypeTwilioSMS, - AssetID: nil, - ReceiverID: receiverX.ID, - WalletID: wallet.ID, - ReceiverWalletID: &receiverWalletX.ID, - Status: SuccessMessageStatus, - CreatedAt: time.Date(2023, 1, 10, 23, 40, 20, 1000, time.UTC), - }) - disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, - }) - paymentX1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ - ReceiverWallet: receiverWalletX, - Disbursement: disbursement1, - Asset: *asset, - Status: ReadyPaymentStatus, - Amount: "1", - }) - - // 3. Create receiverY (that will not be deleted) and all receiverY dependent resources that will not be deleted: - receiverY := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - receiverWalletY := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverY.ID, wallet.ID, DraftReceiversWalletStatus) - _ = CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ - ReceiverID: receiverY.ID, - VerificationField: VerificationFieldDateOfBirth, - VerificationValue: "1990-01-01", - }) - messageY := CreateMessageFixture(t, ctx, dbConnectionPool, &Message{ - Type: message.MessengerTypeTwilioSMS, - AssetID: nil, - ReceiverID: receiverY.ID, - WalletID: wallet.ID, - ReceiverWalletID: &receiverWalletY.ID, - Status: SuccessMessageStatus, - CreatedAt: time.Date(2023, 1, 10, 23, 40, 20, 1000, time.UTC), - }) - disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: wallet, - Status: ReadyDisbursementStatus, - Asset: asset, - }) - paymentY2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ - ReceiverWallet: receiverWalletY, - Disbursement: disbursement2, - Asset: *asset, - Status: ReadyPaymentStatus, - Amount: "1", - }) - - paymentX2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ - ReceiverWallet: receiverWalletX, - Disbursement: disbursement2, - Asset: *asset, - Status: ReadyPaymentStatus, - Amount: "1", - }) // This payment will be deleted along with the remaining receiverX-related data - - // 4. Delete receiverX - err = models.Receiver.DeleteByPhoneNumber(ctx, dbConnectionPool, receiverX.PhoneNumber) - require.NoError(t, err) + for _, contactType := range GetAllReceiverContactTypes() { + t.Run(string(contactType), func(t *testing.T) { + defer func() { + err = db.RunInTransaction(ctx, dbConnectionPool, nil, func(dbTx db.DBTransaction) error { + DeleteAllFixtures(t, ctx, dbTx) + return nil + }) + require.NoError(t, err) + }() + + // 0. returns ErrNotFound for users that don't exist: + t.Run("User does not exist", func(t *testing.T) { + err = models.Receiver.DeleteByContactInfo(ctx, dbConnectionPool, "+14152222222") + require.ErrorIs(t, err, ErrRecordNotFound) + }) + + // 1. Create asset, and wallet (won't be deleted) + asset := CreateAssetFixture(t, ctx, dbConnectionPool, "FOO1", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "walletA", "https://www.a.com", "www.a.com", "a://") + + // 2. Create receiverX (that will be deleted) and all receiverX dependent resources that will also be deleted: + receiverX := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + receiverWalletX := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverX.ID, wallet.ID, DraftReceiversWalletStatus) + _ = CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiverX.ID, + VerificationField: VerificationTypeDateOfBirth, + VerificationValue: "1990-01-01", + }) + messageX := CreateMessageFixture(t, ctx, dbConnectionPool, &Message{ + Type: message.MessengerTypeTwilioSMS, + AssetID: nil, + ReceiverID: receiverX.ID, + WalletID: wallet.ID, + ReceiverWalletID: &receiverWalletX.ID, + Status: SuccessMessageStatus, + CreatedAt: time.Date(2023, 1, 10, 23, 40, 20, 1000, time.UTC), + }) + disbursement1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, + }) + paymentX1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: receiverWalletX, + Disbursement: disbursement1, + Asset: *asset, + Status: ReadyPaymentStatus, + Amount: "1", + }) + + // 3. Create receiverY (that will not be deleted) and all receiverY dependent resources that will not be deleted: + receiverY := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + receiverWalletY := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverY.ID, wallet.ID, DraftReceiversWalletStatus) + _ = CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiverY.ID, + VerificationField: VerificationTypeDateOfBirth, + VerificationValue: "1990-01-01", + }) + messageY := CreateMessageFixture(t, ctx, dbConnectionPool, &Message{ + Type: message.MessengerTypeTwilioSMS, + AssetID: nil, + ReceiverID: receiverY.ID, + WalletID: wallet.ID, + ReceiverWalletID: &receiverWalletY.ID, + Status: SuccessMessageStatus, + CreatedAt: time.Date(2023, 1, 10, 23, 40, 20, 1000, time.UTC), + }) + disbursement2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, + }) + paymentY2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: receiverWalletY, + Disbursement: disbursement2, + Asset: *asset, + Status: ReadyPaymentStatus, + Amount: "1", + }) + + paymentX2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: receiverWalletX, + Disbursement: disbursement2, + Asset: *asset, + Status: ReadyPaymentStatus, + Amount: "1", + }) // This payment will be deleted along with the remaining receiverX-related data + + // 4. Delete receiverX + err = models.Receiver.DeleteByContactInfo(ctx, dbConnectionPool, receiverX.ContactByType(contactType)) + require.NoError(t, err) - type testCase struct { - name string - query string - args []interface{} - wantExists bool - } + type testCase struct { + name string + query string + args []interface{} + wantExists bool + } - // 5. Prepare assertions to make sure `DeleteByPhoneNumber` DID DELETE receiverX-related data: - didDeleteTestCases := []testCase{ - { - name: "DID DELETE: receiverX", - query: "SELECT EXISTS(SELECT 1 FROM receivers WHERE id = $1)", - args: []interface{}{receiverX.ID}, - wantExists: false, - }, - { - name: "DID DELETE: receiverWalletX", - query: "SELECT EXISTS(SELECT 1 FROM receiver_wallets WHERE id = $1)", - args: []interface{}{receiverWalletX.ID}, - wantExists: false, - }, - { - name: "DID DELETE: receiverVerificationX", - query: "SELECT EXISTS(SELECT 1 FROM receiver_verifications WHERE receiver_id = $1)", - args: []interface{}{receiverX.ID}, - wantExists: false, - }, - { - name: "DID DELETE: messageX", - query: "SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1)", - args: []interface{}{messageX.ID}, - wantExists: false, - }, - { - name: "DID DELETE: paymentX", - query: "SELECT EXISTS(SELECT 1 FROM payments WHERE id = ANY($1))", - args: []interface{}{pq.Array([]string{paymentX1.ID, paymentX2.ID})}, - wantExists: false, - }, - { - name: "DID DELETE: disbursement1", - query: "SELECT EXISTS(SELECT 1 FROM disbursements WHERE id = $1)", - args: []interface{}{disbursement1.ID}, - wantExists: false, - }, - } + // 5. Prepare assertions to make sure `DeleteByContactInfo` DID DELETE receiverX-related data: + didDeleteTestCases := []testCase{ + { + name: "DID DELETE: receiverX", + query: "SELECT EXISTS(SELECT 1 FROM receivers WHERE id = $1)", + args: []interface{}{receiverX.ID}, + wantExists: false, + }, + { + name: "DID DELETE: receiverWalletX", + query: "SELECT EXISTS(SELECT 1 FROM receiver_wallets WHERE id = $1)", + args: []interface{}{receiverWalletX.ID}, + wantExists: false, + }, + { + name: "DID DELETE: receiverVerificationX", + query: "SELECT EXISTS(SELECT 1 FROM receiver_verifications WHERE receiver_id = $1)", + args: []interface{}{receiverX.ID}, + wantExists: false, + }, + { + name: "DID DELETE: messageX", + query: "SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1)", + args: []interface{}{messageX.ID}, + wantExists: false, + }, + { + name: "DID DELETE: paymentX", + query: "SELECT EXISTS(SELECT 1 FROM payments WHERE id = ANY($1))", + args: []interface{}{pq.Array([]string{paymentX1.ID, paymentX2.ID})}, + wantExists: false, + }, + { + name: "DID DELETE: disbursement1", + query: "SELECT EXISTS(SELECT 1 FROM disbursements WHERE id = $1)", + args: []interface{}{disbursement1.ID}, + wantExists: false, + }, + } - // 6. Prepare assertions to make sure `DeleteByPhoneNumber` DID NOT DELETE receiverY-related data: - didNotDeleteTestCases := []testCase{ - { - name: "DID NOT DELETE: receiverY", - query: "SELECT EXISTS(SELECT 1 FROM receivers WHERE id = $1)", - args: []interface{}{receiverY.ID}, - wantExists: true, - }, - { - name: "DID NOT DELETE: receiverWalletY", - query: "SELECT EXISTS(SELECT 1 FROM receiver_wallets WHERE id = $1)", - args: []interface{}{receiverWalletY.ID}, - wantExists: true, - }, - { - name: "DID NOT DELETE: receiverVerificationY", - query: "SELECT EXISTS(SELECT 1 FROM receiver_verifications WHERE receiver_id = $1)", - args: []interface{}{receiverY.ID}, - wantExists: true, - }, - { - name: "DID NOT DELETE: messageY", - query: "SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1)", - args: []interface{}{messageY.ID}, - wantExists: true, - }, - { - name: "DID NOT DELETE: paymentY2", - query: "SELECT EXISTS(SELECT 1 FROM payments WHERE id = $1)", - args: []interface{}{paymentY2.ID}, - wantExists: true, - }, - { - name: "DID NOT DELETE: paymentX2", - query: "SELECT EXISTS(SELECT 1 FROM disbursements WHERE id = $1)", - args: []interface{}{disbursement2.ID}, - wantExists: true, - }, - } + // 6. Prepare assertions to make sure `DeleteByContactInfo` DID NOT DELETE receiverY-related data: + didNotDeleteTestCases := []testCase{ + { + name: "DID NOT DELETE: receiverY", + query: "SELECT EXISTS(SELECT 1 FROM receivers WHERE id = $1)", + args: []interface{}{receiverY.ID}, + wantExists: true, + }, + { + name: "DID NOT DELETE: receiverWalletY", + query: "SELECT EXISTS(SELECT 1 FROM receiver_wallets WHERE id = $1)", + args: []interface{}{receiverWalletY.ID}, + wantExists: true, + }, + { + name: "DID NOT DELETE: receiverVerificationY", + query: "SELECT EXISTS(SELECT 1 FROM receiver_verifications WHERE receiver_id = $1)", + args: []interface{}{receiverY.ID}, + wantExists: true, + }, + { + name: "DID NOT DELETE: messageY", + query: "SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1)", + args: []interface{}{messageY.ID}, + wantExists: true, + }, + { + name: "DID NOT DELETE: paymentY2", + query: "SELECT EXISTS(SELECT 1 FROM payments WHERE id = $1)", + args: []interface{}{paymentY2.ID}, + wantExists: true, + }, + { + name: "DID NOT DELETE: paymentX2", + query: "SELECT EXISTS(SELECT 1 FROM disbursements WHERE id = $1)", + args: []interface{}{disbursement2.ID}, + wantExists: true, + }, + } - // 7. Run assertions - testCases := append(didDeleteTestCases, didNotDeleteTestCases...) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - var exists bool - err = dbConnectionPool.QueryRowxContext(ctx, tc.query, tc.args...).Scan(&exists) - require.NoError(t, err) - require.Equal(t, tc.wantExists, exists) + // 7. Run assertions + testCases := append(didDeleteTestCases, didNotDeleteTestCases...) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var exists bool + err = dbConnectionPool.QueryRowxContext(ctx, tc.query, tc.args...).Scan(&exists) + require.NoError(t, err) + require.Equal(t, tc.wantExists, exists) + }) + } }) } } @@ -1110,7 +1116,7 @@ func Test_ReceiversModel_Update(t *testing.T) { receiverModel := ReceiverModel{} email, externalID := "receiver@email.com", "externalID" - receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{Email: &email, ExternalID: externalID}) + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{Email: email, ExternalID: externalID}) resetReceiver := func(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, receiverID string) { q := ` @@ -1123,21 +1129,18 @@ func Test_ReceiversModel_Update(t *testing.T) { t.Run("returns error when no value is provided", func(t *testing.T) { resetReceiver(t, ctx, dbConnectionPool, receiver.ID) - err = receiverModel.Update(ctx, dbConnectionPool, receiver.ID, ReceiverUpdate{ - Email: "", - ExternalId: "", - }) - assert.EqualError(t, err, "provide at least one of these values: Email or ExternalID") + err = receiverModel.Update(ctx, dbConnectionPool, receiver.ID, ReceiverUpdate{}) + assert.EqualError(t, err, "validating receiver update: no values provided to update receiver") }) t.Run("returns error when email is invalid", func(t *testing.T) { resetReceiver(t, ctx, dbConnectionPool, receiver.ID) err = receiverModel.Update(ctx, dbConnectionPool, receiver.ID, ReceiverUpdate{ - Email: "invalid", - ExternalId: "", + Email: utils.StringPtr("invalid"), + ExternalId: utils.StringPtr(""), }) - assert.EqualError(t, err, `error validating email: the provided email is not valid`) + assert.EqualError(t, err, `validating receiver update: validating email: the provided email is not valid`) }) t.Run("updates email name successfully", func(t *testing.T) { @@ -1145,19 +1148,18 @@ func Test_ReceiversModel_Update(t *testing.T) { receiver, err = receiverModel.Get(ctx, dbConnectionPool, receiver.ID) require.NoError(t, err) - assert.Equal(t, email, *receiver.Email) + assert.Equal(t, email, receiver.Email) assert.Equal(t, externalID, receiver.ExternalID) err = receiverModel.Update(ctx, dbConnectionPool, receiver.ID, ReceiverUpdate{ - Email: "updated_email@email.com", - ExternalId: "", + Email: utils.StringPtr("updated_email@email.com"), }) require.NoError(t, err) receiver, err = receiverModel.Get(ctx, dbConnectionPool, receiver.ID) require.NoError(t, err) - assert.NotEqual(t, email, *receiver.Email) - assert.Equal(t, "updated_email@email.com", *receiver.Email) + assert.NotEqual(t, email, receiver.Email) + assert.Equal(t, "updated_email@email.com", receiver.Email) assert.Equal(t, externalID, receiver.ExternalID) }) @@ -1166,20 +1168,90 @@ func Test_ReceiversModel_Update(t *testing.T) { receiver, err = receiverModel.Get(ctx, dbConnectionPool, receiver.ID) require.NoError(t, err) - assert.Equal(t, email, *receiver.Email) + assert.Equal(t, email, receiver.Email) assert.Equal(t, externalID, receiver.ExternalID) err := receiverModel.Update(ctx, dbConnectionPool, receiver.ID, ReceiverUpdate{ - Email: "updated_email@email.com", - ExternalId: "newExternalID", + Email: utils.StringPtr("updated_email@email.com"), + ExternalId: utils.StringPtr("newExternalID"), }) require.NoError(t, err) receiver, err = receiverModel.Get(ctx, dbConnectionPool, receiver.ID) require.NoError(t, err) - assert.NotEqual(t, email, *receiver.Email) - assert.Equal(t, "updated_email@email.com", *receiver.Email) + assert.NotEqual(t, email, receiver.Email) + assert.Equal(t, "updated_email@email.com", receiver.Email) assert.NotEqual(t, externalID, receiver.ExternalID) assert.Equal(t, "newExternalID", receiver.ExternalID) }) } + +func Test_ReceiversModel_GetByContacts(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + + receiverModel := ReceiverModel{} + + receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ + Email: "receiver1@stellar.org", + PhoneNumber: "+99991111", + ExternalID: "external-id-1", + }) + + receiver2 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ + Email: "receiver2@stellar.org", + ExternalID: "external-id-2", + }) + + receiver3 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ + PhoneNumber: "+99992222", + ExternalID: "external-id-3", + }) + + testCases := []struct { + name string + contacts []string + want []*Receiver + wantErr error + }{ + { + name: "list of contacts is empty", + contacts: []string{}, + want: []*Receiver{}, + wantErr: nil, + }, + { + name: "successfully get receivers by email and phone", + contacts: []string{receiver1.PhoneNumber, receiver2.Email, receiver3.PhoneNumber}, + want: []*Receiver{receiver1, receiver2, receiver3}, + }, + { + name: "successfully get receivers by email", + contacts: []string{receiver1.Email, receiver2.Email}, + want: []*Receiver{receiver1, receiver2}, + }, + { + name: "successfully get receivers by phone", + contacts: []string{receiver1.PhoneNumber, receiver3.PhoneNumber}, + want: []*Receiver{receiver1, receiver3}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + receivers, err := receiverModel.GetByContacts(ctx, dbConnectionPool, tc.contacts...) + if tc.wantErr != nil { + assert.EqualError(t, err, tc.wantErr.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.want, receivers) + } + }) + } +} diff --git a/internal/data/receivers_wallet.go b/internal/data/receivers_wallet.go index 555e699f2..b20455a40 100644 --- a/internal/data/receivers_wallet.go +++ b/internal/data/receivers_wallet.go @@ -7,13 +7,16 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/lib/pq" "github.com/stellar/go/network" + "github.com/stellar/go/strkey" "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) const OTPExpirationTimeMinutes = 30 @@ -63,24 +66,25 @@ func (rwsh *ReceiversWalletStatusHistory) Scan(src interface{}) error { var _ sql.Scanner = (*ReceiversWalletStatusHistory)(nil) type ReceiverWallet struct { - ID string `json:"id" db:"id"` - Receiver Receiver `json:"receiver" db:"receiver"` - Wallet Wallet `json:"wallet" db:"wallet"` - StellarAddress string `json:"stellar_address,omitempty" db:"stellar_address"` - StellarMemo string `json:"stellar_memo,omitempty" db:"stellar_memo"` - StellarMemoType string `json:"stellar_memo_type,omitempty" db:"stellar_memo_type"` - Status ReceiversWalletStatus `json:"status" db:"status"` - StatusHistory ReceiversWalletStatusHistory `json:"status_history,omitempty" db:"status_history"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - OTP string `json:"-" db:"otp"` - OTPCreatedAt *time.Time `json:"-" db:"otp_created_at"` - OTPConfirmedAt *time.Time `json:"otp_confirmed_at,omitempty" db:"otp_confirmed_at"` + ID string `json:"id" db:"id"` + Receiver Receiver `json:"receiver" db:"receiver"` + Wallet Wallet `json:"wallet" db:"wallet"` + StellarAddress string `json:"stellar_address,omitempty" db:"stellar_address"` + StellarMemo string `json:"stellar_memo,omitempty" db:"stellar_memo"` + StellarMemoType string `json:"stellar_memo_type,omitempty" db:"stellar_memo_type"` + Status ReceiversWalletStatus `json:"status" db:"status"` + StatusHistory ReceiversWalletStatusHistory `json:"status_history,omitempty" db:"status_history"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + OTP string `json:"-" db:"otp"` + OTPCreatedAt *time.Time `json:"-" db:"otp_created_at"` + OTPConfirmedAt *time.Time `json:"otp_confirmed_at,omitempty" db:"otp_confirmed_at"` + OTPConfirmedWith string `json:"otp_confirmed_with,omitempty" db:"otp_confirmed_with"` // AnchorPlatformAccountID is the ID of the SEP24 transaction initiated by the Anchor Platform where the receiver wallet was registered. AnchorPlatformTransactionID string `json:"anchor_platform_transaction_id,omitempty" db:"anchor_platform_transaction_id"` AnchorPlatformTransactionSyncedAt *time.Time `json:"anchor_platform_transaction_synced_at,omitempty" db:"anchor_platform_transaction_synced_at"` InvitedAt *time.Time `json:"invited_at,omitempty" db:"invited_at"` - LastSmsSent *time.Time `json:"last_sms_sent,omitempty" db:"last_sms_sent"` + LastMessageSentAt *time.Time `json:"last_message_sent_at,omitempty" db:"last_message_sent_at"` InvitationSentAt *time.Time `json:"invitation_sent_at" db:"invitation_sent_at"` ReceiverWalletStats } @@ -92,9 +96,9 @@ type ReceiverWalletStats struct { CanceledPayments string `json:"canceled_payments,omitempty" db:"canceled_payments"` RemainingPayments string `json:"remaining_payments,omitempty" db:"remaining_payments"` ReceivedAmounts ReceivedAmounts `json:"received_amounts,omitempty" db:"received_amounts"` - // TotalInvitationSMSResentAttempts holds how many times were resent the Invitation SMS to the receiver + // TotalInvitationResentAttempts holds how many times were resent the Invitation SMS to the receiver // since the last invitation has been sent. - TotalInvitationSMSResentAttempts int64 `json:"-" db:"total_invitation_sms_resent_attempts"` + TotalInvitationResentAttempts int64 `json:"-" db:"total_invitation_sms_resent_attempts"` } type ReceiverWalletModel struct { @@ -159,7 +163,7 @@ func (rw *ReceiverWalletModel) GetWithReceiverIds(ctx context.Context, sqlExec d SELECT rwc.id as receiver_wallet_id, MIN(m.created_at) as invited_at, - MAX(m.created_at) as last_sms_sent + MAX(m.created_at) as last_message_sent_at FROM receiver_wallets_cte rwc LEFT JOIN messages m ON rwc.id = m.receiver_wallet_id WHERE m.status = 'SUCCESS' @@ -187,7 +191,7 @@ func (rw *ReceiverWalletModel) GetWithReceiverIds(ctx context.Context, sqlExec d COALESCE(rws.remaining_payments, '0') as remaining_payments, rws.received_amounts, rwm.invited_at as invited_at, - rwm.last_sms_sent as last_sms_sent + rwm.last_message_sent_at as last_message_sent_at FROM receiver_wallets_cte rwc LEFT JOIN receiver_wallets_stats_aggregate rws ON rws.receiver_wallet_id = rwc.id LEFT JOIN receiver_wallets_messages rwm ON rwm.receiver_wallet_id = rwc.id @@ -202,6 +206,45 @@ func (rw *ReceiverWalletModel) GetWithReceiverIds(ctx context.Context, sqlExec d return receiverWallets, nil } +const selectReceiverWalletQuery = ` + SELECT + rw.id, + rw.receiver_id as "receiver.id", + rw.status, + COALESCE(rw.anchor_platform_transaction_id, '') as anchor_platform_transaction_id, + COALESCE(rw.stellar_address, '') as stellar_address, + COALESCE(rw.stellar_memo, '') as stellar_memo, + COALESCE(rw.stellar_memo_type, '') as stellar_memo_type, + COALESCE(rw.otp, '') as otp, + rw.otp_created_at, + rw.otp_confirmed_at, + COALESCE(rw.otp_confirmed_with, '') as otp_confirmed_with, + w.id as "wallet.id", + w.name as "wallet.name", + w.sep_10_client_domain as "wallet.sep_10_client_domain", + w.homepage as "wallet.homepage" + FROM + receiver_wallets rw + JOIN + wallets w ON rw.wallet_id = w.id + ` + +// GetByIDs returns a receiver wallet by IDs +func (rw *ReceiverWalletModel) GetByIDs(ctx context.Context, sqlExec db.SQLExecuter, ids ...string) ([]ReceiverWallet, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("no receiver wallet IDs provided") + } + + query := fmt.Sprintf("%s WHERE rw.id = ANY($1)", selectReceiverWalletQuery) + + receiverWallets := make([]ReceiverWallet, len(ids)) + err := sqlExec.SelectContext(ctx, &receiverWallets, query, pq.Array(ids)) + if err != nil { + return nil, fmt.Errorf("querying receiver wallet: %w", err) + } + return receiverWallets, nil +} + // GetByReceiverIDsAndWalletID returns a list of receiver wallets by receiver IDs and wallet ID. func (rw *ReceiverWalletModel) GetByReceiverIDsAndWalletID(ctx context.Context, sqlExec db.SQLExecuter, receiverIds []string, walletId string) ([]*ReceiverWallet, error) { receiverWallets := []*ReceiverWallet{} @@ -229,8 +272,8 @@ const getPendingRegistrationReceiverWalletsBaseQuery = ` rw.id, rw.invitation_sent_at, r.id AS "receiver.id", - r.phone_number AS "receiver.phone_number", - r.email AS "receiver.email", + COALESCE(r.phone_number, '') as "receiver.phone_number", + COALESCE(r.email, '') as "receiver.email", w.id AS "wallet.id", w.name AS "wallet.name" FROM @@ -288,20 +331,22 @@ func (rw *ReceiverWalletModel) GetAllPendingRegistrationByDisbursementID(ctx con return receiverWallets, nil } -// UpdateOTPByReceiverPhoneNumberAndWalletDomain updates receiver wallet OTP if its not verified yet, -// and returns the number of updated rows. -func (rw *ReceiverWalletModel) UpdateOTPByReceiverPhoneNumberAndWalletDomain(ctx context.Context, receiverPhoneNumber, sep10ClientDomain, otp string) (numberOfUpdatedRows int, err error) { +// UpdateOTPByReceiverContactInfoAndWalletDomain updates receiver wallet OTP if its not verified yet, and returns the +// number of updated rows. +func (rw *ReceiverWalletModel) UpdateOTPByReceiverContactInfoAndWalletDomain(ctx context.Context, receiverContactInfo, sep10ClientDomain, otp string) (numberOfUpdatedRows int, err error) { query := ` WITH rw_cte AS ( SELECT rw.id, rw.otp_confirmed_at - FROM receiver_wallets rw - INNER JOIN receivers r ON rw.receiver_id = r.id - INNER JOIN wallets w ON rw.wallet_id = w.id - WHERE r.phone_number = $1 - AND w.sep_10_client_domain = $2 - AND rw.otp_confirmed_at IS NULL + FROM + receiver_wallets rw + INNER JOIN receivers r ON rw.receiver_id = r.id + INNER JOIN wallets w ON rw.wallet_id = w.id + WHERE + (r.phone_number = $1 OR r.email = $1) + AND w.sep_10_client_domain = $2 + AND rw.otp_confirmed_at IS NULL ) UPDATE receiver_wallets @@ -313,14 +358,14 @@ func (rw *ReceiverWalletModel) UpdateOTPByReceiverPhoneNumberAndWalletDomain(ctx receiver_wallets.id = rw_cte.id ` - rows, err := rw.dbConnectionPool.ExecContext(ctx, query, receiverPhoneNumber, sep10ClientDomain, otp) + rows, err := rw.dbConnectionPool.ExecContext(ctx, query, receiverContactInfo, sep10ClientDomain, otp) if err != nil { - return 0, fmt.Errorf("error updating receiver wallets otp: %w", err) + return 0, fmt.Errorf("updating receiver wallets otp: %w", err) } updatedRowsAffected, err := rows.RowsAffected() if err != nil { - return 0, fmt.Errorf("error getting updated rows of receiver wallets otp: %w", err) + return 0, fmt.Errorf("getting updated rows of receiver wallets otp: %w", err) } return int(updatedRowsAffected), nil @@ -344,32 +389,9 @@ func (rw *ReceiverWalletModel) Insert(ctx context.Context, sqlExec db.SQLExecute // GetByReceiverIDAndWalletDomain returns a receiver wallet that match the receiver ID and wallet domain. func (rw *ReceiverWalletModel) GetByReceiverIDAndWalletDomain(ctx context.Context, receiverId string, walletDomain string, sqlExec db.SQLExecuter) (*ReceiverWallet, error) { - var receiverWallet ReceiverWallet - query := ` - SELECT - rw.id, - rw.receiver_id as "receiver.id", - rw.status, - COALESCE(rw.anchor_platform_transaction_id, '') as anchor_platform_transaction_id, - COALESCE(rw.stellar_address, '') as stellar_address, - COALESCE(rw.stellar_memo, '') as stellar_memo, - COALESCE(rw.stellar_memo_type, '') as stellar_memo_type, - COALESCE(rw.otp, '') as otp, - rw.otp_created_at, - rw.otp_confirmed_at, - w.id as "wallet.id", - w.name as "wallet.name", - w.sep_10_client_domain as "wallet.sep_10_client_domain" - FROM - receiver_wallets rw - JOIN - wallets w ON rw.wallet_id = w.id - WHERE - rw.receiver_id = $1 - AND - w.sep_10_client_domain = $2 - ` + query := fmt.Sprintf("%s %s", selectReceiverWalletQuery, "WHERE rw.receiver_id = $1 AND w.sep_10_client_domain = $2") + var receiverWallet ReceiverWallet err := sqlExec.GetContext(ctx, &receiverWallet, query, receiverId, walletDomain) if err != nil { return nil, fmt.Errorf("error querying receiver wallet: %w", err) @@ -378,46 +400,6 @@ func (rw *ReceiverWalletModel) GetByReceiverIDAndWalletDomain(ctx context.Contex return &receiverWallet, nil } -// UpdateReceiverWallet updates the status, address, OTP confirmation time, and anchor platform transaction ID of a -// receiver wallet. -func (rw *ReceiverWalletModel) UpdateReceiverWallet(ctx context.Context, receiverWallet ReceiverWallet, sqlExec db.SQLExecuter) error { - query := ` - UPDATE - receiver_wallets rw - SET - status = $1, - anchor_platform_transaction_id = $2, - stellar_address = $3, - stellar_memo = $4, - stellar_memo_type = $5, - otp_confirmed_at = $6 - WHERE rw.id = $7 - ` - - result, err := sqlExec.ExecContext(ctx, query, - receiverWallet.Status, - sql.NullString{String: receiverWallet.AnchorPlatformTransactionID, Valid: receiverWallet.AnchorPlatformTransactionID != ""}, - receiverWallet.StellarAddress, - sql.NullString{String: receiverWallet.StellarMemo, Valid: receiverWallet.StellarMemo != ""}, - sql.NullString{String: receiverWallet.StellarMemoType, Valid: receiverWallet.StellarMemoType != ""}, - receiverWallet.OTPConfirmedAt, - receiverWallet.ID) - if err != nil { - return fmt.Errorf("updating receiver wallet: %w", err) - } - - numRowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("getting number of rows affected: %w", err) - } - - if numRowsAffected == 0 { - return fmt.Errorf("no receiver wallet could be found in UpdateReceiverWallet: %w", ErrRecordNotFound) - } - - return nil -} - // VerifyReceiverWalletOTP validates the receiver wallet OTP. func (rw *ReceiverWalletModel) VerifyReceiverWalletOTP(ctx context.Context, networkPassphrase string, receiverWallet ReceiverWallet, otp string) error { if networkPassphrase == network.TestNetworkPassphrase { @@ -481,24 +463,7 @@ func (rw *ReceiverWalletModel) UpdateStatusByDisbursementID(ctx context.Context, func (rw *ReceiverWalletModel) GetByStellarAccountAndMemo(ctx context.Context, stellarAccount, stellarMemo, clientDomain string) (*ReceiverWallet, error) { // build query var receiverWallets ReceiverWallet - query := ` - SELECT - rw.id, - rw.receiver_id as "receiver.id", - rw.status, - COALESCE(rw.anchor_platform_transaction_id, '') as anchor_platform_transaction_id, - COALESCE(rw.stellar_address, '') as stellar_address, - COALESCE(rw.stellar_memo, '') as stellar_memo, - COALESCE(rw.stellar_memo_type, '') as stellar_memo_type, - COALESCE(rw.otp, '') as otp, - rw.otp_created_at, - w.id as "wallet.id", - w.name as "wallet.name", - w.homepage as "wallet.homepage" - FROM receiver_wallets rw - JOIN wallets w ON rw.wallet_id = w.id - WHERE rw.stellar_address = ? - ` + query := fmt.Sprintf("%s %s", selectReceiverWalletQuery, "WHERE rw.stellar_address = ?") // append memo to query if it is not empty args := []interface{}{stellarAccount} @@ -554,8 +519,8 @@ func (rw *ReceiverWalletModel) UpdateAnchorPlatformTransactionSyncedAt(ctx conte return receiverWallets, nil } -// RetryInvitationSMS sets null the invitation_sent_at of a receiver wallet. -func (rw *ReceiverWalletModel) RetryInvitationSMS(ctx context.Context, sqlExec db.SQLExecuter, receiverWalletId string) (*ReceiverWallet, error) { +// RetryInvitationMessage sets null the invitation_sent_at of a receiver wallet. +func (rw *ReceiverWalletModel) RetryInvitationMessage(ctx context.Context, sqlExec db.SQLExecuter, receiverWalletId string) (*ReceiverWallet, error) { var receiverWallet ReceiverWallet query := ` UPDATE @@ -609,3 +574,105 @@ func (rw *ReceiverWalletModel) UpdateInvitationSentAt(ctx context.Context, sqlEx return receiverWallets, nil } + +type ReceiverWalletUpdate struct { + Status ReceiversWalletStatus `db:"status"` + AnchorPlatformTransactionID string `db:"anchor_platform_transaction_id"` + StellarAddress string `db:"stellar_address"` + StellarMemo string `db:"stellar_memo"` + StellarMemoType string `db:"stellar_memo_type"` + OTPConfirmedAt time.Time `db:"otp_confirmed_at"` + OTPConfirmedWith string `db:"otp_confirmed_with"` +} + +func (rwu ReceiverWalletUpdate) Validate() error { + if utils.IsEmpty(rwu) { + return fmt.Errorf("no values provided to update receiver wallet") + } + + if rwu.Status != "" { + if err := rwu.Status.Validate(); err != nil { + return fmt.Errorf("validating status: %w", err) + } + } + + if rwu.StellarAddress != "" { + if !strkey.IsValidEd25519PublicKey(rwu.StellarAddress) { + return fmt.Errorf("invalid stellar address") + } + } + + if !time.Time.IsZero(rwu.OTPConfirmedAt) && rwu.OTPConfirmedWith == "" { + return fmt.Errorf("OTPConfirmedWith is required when OTPConfirmedAt is provided") + } + + if rwu.OTPConfirmedWith != "" && time.Time.IsZero(rwu.OTPConfirmedAt) { + return fmt.Errorf("OTPConfirmedAt is required when OTPConfirmedWith is provided") + } + + return nil +} + +func (rw *ReceiverWalletModel) Update(ctx context.Context, id string, update ReceiverWalletUpdate, sqlExec db.SQLExecuter) error { + if err := update.Validate(); err != nil { + return fmt.Errorf("validating receiver wallet update: %w", err) + } + + fields := []string{} + args := []interface{}{} + + if update.Status != "" { + fields = append(fields, "status = ?") + args = append(args, update.Status) + fields = append(fields, "status_history = array_prepend(create_receiver_wallet_status_history(NOW(), ?), status_history)") + args = append(args, update.Status) + } + if update.AnchorPlatformTransactionID != "" { + fields = append(fields, "anchor_platform_transaction_id = ?") + args = append(args, update.AnchorPlatformTransactionID) + } + if update.StellarAddress != "" { + fields = append(fields, "stellar_address = ?") + args = append(args, update.StellarAddress) + } + if update.StellarMemo != "" { + fields = append(fields, "stellar_memo = ?") + args = append(args, update.StellarMemo) + } + if update.StellarMemoType != "" { + fields = append(fields, "stellar_memo_type = ?") + args = append(args, update.StellarMemoType) + } + if !time.Time.IsZero(update.OTPConfirmedAt) { + fields = append(fields, "otp_confirmed_at = ?") + args = append(args, update.OTPConfirmedAt) + } + if update.OTPConfirmedWith != "" { + fields = append(fields, "otp_confirmed_with = ?") + args = append(args, update.OTPConfirmedWith) + } + + args = append(args, id) + query := fmt.Sprintf(` + UPDATE receiver_wallets + SET %s + WHERE id = ? + `, strings.Join(fields, ", ")) + + query = sqlExec.Rebind(query) + result, err := sqlExec.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("updating receiver wallet: %w", err) + } + + numRowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting number of rows affected: %w", err) + } + + if numRowsAffected == 0 { + return fmt.Errorf("no receiver wallet could be found in UpdateReceiverWallet: %w", ErrRecordNotFound) + } + + return nil +} diff --git a/internal/data/receivers_wallet_test.go b/internal/data/receivers_wallet_test.go index ba229a8a6..b66f5898d 100644 --- a/internal/data/receivers_wallet_test.go +++ b/internal/data/receivers_wallet_test.go @@ -65,7 +65,6 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { }) asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiverWallet1 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet1.ID, DraftReceiversWalletStatus) @@ -92,9 +91,8 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { disbursementModel := DisbursementModel{dbConnectionPool: dbConnectionPool} disbursement := Disbursement{ - Status: DraftDisbursementStatus, - Asset: asset, - Country: country, + Status: DraftDisbursementStatus, + Asset: asset, } stellarTransactionID, err := utils.RandomString(64) @@ -132,14 +130,14 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { SEP10ClientDomain: wallet1.SEP10ClientDomain, Enabled: true, }, - StellarAddress: receiverWallet1.StellarAddress, - StellarMemo: receiverWallet1.StellarMemo, - StellarMemoType: receiverWallet1.StellarMemoType, - Status: receiverWallet1.Status, - CreatedAt: receiverWallet1.CreatedAt, - UpdatedAt: receiverWallet1.CreatedAt, - InvitedAt: &message1.CreatedAt, - LastSmsSent: &message2.CreatedAt, + StellarAddress: receiverWallet1.StellarAddress, + StellarMemo: receiverWallet1.StellarMemo, + StellarMemoType: receiverWallet1.StellarMemoType, + Status: receiverWallet1.Status, + CreatedAt: receiverWallet1.CreatedAt, + UpdatedAt: receiverWallet1.CreatedAt, + InvitedAt: &message1.CreatedAt, + LastMessageSentAt: &message2.CreatedAt, ReceiverWalletStats: ReceiverWalletStats{ TotalPayments: "0", PaymentsReceived: "0", @@ -197,14 +195,14 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { SEP10ClientDomain: wallet1.SEP10ClientDomain, Enabled: true, }, - StellarAddress: receiverWallet1.StellarAddress, - StellarMemo: receiverWallet1.StellarMemo, - StellarMemoType: receiverWallet1.StellarMemoType, - Status: receiverWallet1.Status, - CreatedAt: receiverWallet1.CreatedAt, - UpdatedAt: receiverWallet1.CreatedAt, - InvitedAt: &message1.CreatedAt, - LastSmsSent: &message2.CreatedAt, + StellarAddress: receiverWallet1.StellarAddress, + StellarMemo: receiverWallet1.StellarMemo, + StellarMemoType: receiverWallet1.StellarMemoType, + Status: receiverWallet1.Status, + CreatedAt: receiverWallet1.CreatedAt, + UpdatedAt: receiverWallet1.CreatedAt, + InvitedAt: &message1.CreatedAt, + LastMessageSentAt: &message2.CreatedAt, ReceiverWalletStats: ReceiverWalletStats{ TotalPayments: "2", PaymentsReceived: "1", @@ -303,14 +301,14 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { SEP10ClientDomain: wallet1.SEP10ClientDomain, Enabled: true, }, - StellarAddress: receiverWallet1.StellarAddress, - StellarMemo: receiverWallet1.StellarMemo, - StellarMemoType: receiverWallet1.StellarMemoType, - Status: receiverWallet1.Status, - CreatedAt: receiverWallet1.CreatedAt, - UpdatedAt: receiverWallet1.CreatedAt, - InvitedAt: &message1.CreatedAt, - LastSmsSent: &message2.CreatedAt, + StellarAddress: receiverWallet1.StellarAddress, + StellarMemo: receiverWallet1.StellarMemo, + StellarMemoType: receiverWallet1.StellarMemoType, + Status: receiverWallet1.Status, + CreatedAt: receiverWallet1.CreatedAt, + UpdatedAt: receiverWallet1.CreatedAt, + InvitedAt: &message1.CreatedAt, + LastMessageSentAt: &message2.CreatedAt, ReceiverWalletStats: ReceiverWalletStats{ TotalPayments: "2", PaymentsReceived: "1", @@ -337,14 +335,14 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { SEP10ClientDomain: wallet2.SEP10ClientDomain, Enabled: true, }, - StellarAddress: receiverWallet2.StellarAddress, - StellarMemo: receiverWallet2.StellarMemo, - StellarMemoType: receiverWallet2.StellarMemoType, - Status: receiverWallet2.Status, - CreatedAt: receiverWallet2.CreatedAt, - UpdatedAt: receiverWallet2.CreatedAt, - InvitedAt: &message3.CreatedAt, - LastSmsSent: &message4.CreatedAt, + StellarAddress: receiverWallet2.StellarAddress, + StellarMemo: receiverWallet2.StellarMemo, + StellarMemoType: receiverWallet2.StellarMemoType, + Status: receiverWallet2.Status, + CreatedAt: receiverWallet2.CreatedAt, + UpdatedAt: receiverWallet2.CreatedAt, + InvitedAt: &message3.CreatedAt, + LastMessageSentAt: &message4.CreatedAt, ReceiverWalletStats: ReceiverWalletStats{ TotalPayments: "1", PaymentsReceived: "0", @@ -418,14 +416,14 @@ func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { SEP10ClientDomain: wallet1.SEP10ClientDomain, Enabled: true, }, - StellarAddress: receiverWallet.StellarAddress, - StellarMemo: receiverWallet.StellarMemo, - StellarMemoType: receiverWallet.StellarMemoType, - Status: receiverWallet.Status, - CreatedAt: receiverWallet.CreatedAt, - UpdatedAt: receiverWallet.CreatedAt, - InvitedAt: &message1.CreatedAt, - LastSmsSent: &message2.CreatedAt, + StellarAddress: receiverWallet.StellarAddress, + StellarMemo: receiverWallet.StellarMemo, + StellarMemoType: receiverWallet.StellarMemoType, + Status: receiverWallet.Status, + CreatedAt: receiverWallet.CreatedAt, + UpdatedAt: receiverWallet.CreatedAt, + InvitedAt: &message1.CreatedAt, + LastMessageSentAt: &message2.CreatedAt, ReceiverWalletStats: ReceiverWalletStats{ TotalPayments: "0", PaymentsReceived: "0", @@ -504,6 +502,7 @@ func Test_GetByReceiverIDAndWalletDomain(t *testing.T) { Wallet: Wallet{ ID: wallet.ID, Name: wallet.Name, + Homepage: wallet.Homepage, SEP10ClientDomain: wallet.SEP10ClientDomain, }, Status: receiverWallet.Status, @@ -520,146 +519,135 @@ func Test_GetByReceiverIDAndWalletDomain(t *testing.T) { }) } -func Test_UpdateReceiverWallet(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - ctx := context.Background() - receiverWalletModel := ReceiverWalletModel{dbConnectionPool: dbConnectionPool} - - t.Run("returns error when receiver wallet does not exist", func(t *testing.T) { - err := receiverWalletModel.UpdateReceiverWallet(ctx, ReceiverWallet{ID: "invalid_id", Status: DraftReceiversWalletStatus}, dbConnectionPool) - require.ErrorIs(t, err, ErrRecordNotFound) - }) - - receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") - receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, DraftReceiversWalletStatus) - - t.Run("returns error when status is not valid", func(t *testing.T) { - receiverWallet.Status = "invalid_status" - err := receiverWalletModel.UpdateReceiverWallet(ctx, *receiverWallet, dbConnectionPool) - require.Error(t, err, "querying receiver wallet: sql: no rows in result set") - }) - - t.Run("successfuly update receiver wallet", func(t *testing.T) { - receiverWallet.AnchorPlatformTransactionID = "test-anchor-tx-platform-id" - receiverWallet.StellarAddress = "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444" - receiverWallet.StellarMemo = "123456" - receiverWallet.StellarMemoType = "id" - receiverWallet.Status = RegisteredReceiversWalletStatus - now := time.Now() - receiverWallet.OTPConfirmedAt = &now - - err := receiverWalletModel.UpdateReceiverWallet(ctx, *receiverWallet, dbConnectionPool) - require.NoError(t, err) - - // validate if the receiver wallet has been updated - query := ` - SELECT - rw.status, - rw.anchor_platform_transaction_id, - rw.stellar_address, - rw.stellar_memo, - rw.stellar_memo_type, - otp_confirmed_at - FROM - receiver_wallets rw - WHERE - rw.id = $1 - ` - receiverWalletUpdated := ReceiverWallet{} - err = dbConnectionPool.GetContext(ctx, &receiverWalletUpdated, query, receiverWallet.ID) - require.NoError(t, err) - - assert.Equal(t, RegisteredReceiversWalletStatus, receiverWalletUpdated.Status) - assert.Equal(t, "test-anchor-tx-platform-id", receiverWalletUpdated.AnchorPlatformTransactionID) - assert.Equal(t, "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444", receiverWalletUpdated.StellarAddress) - assert.Equal(t, "123456", receiverWalletUpdated.StellarMemo) - assert.Equal(t, "id", receiverWalletUpdated.StellarMemoType) - assert.WithinDuration(t, now, *receiverWalletUpdated.OTPConfirmedAt, 100*time.Millisecond) - }) -} - -func Test_ReceiverWallet_UpdateOTPByReceiverPhoneNumberAndWalletHomePage(t *testing.T) { +func Test_ReceiverWallet_UpdateOTPByReceiverContactInfoAndWalletDomain(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() ctx := context.Background() - receiverWalletModel := ReceiverWalletModel{dbConnectionPool: dbConnectionPool} + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "http://home.test", "home.test", "wallet123://") - t.Run("returns 1 updated row when the receiver wallet has not confirmed yet", func(t *testing.T) { - receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "testWallet", "http://home.page", "home.page", "wallet1://") - _ = CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, RegisteredReceiversWalletStatus) - - testingOTP := "123456" - - rowsUpdated, err := receiverWalletModel.UpdateOTPByReceiverPhoneNumberAndWalletDomain(ctx, receiver1.PhoneNumber, wallet1.SEP10ClientDomain, testingOTP) - require.NoError(t, err) - assert.Equal(t, 1, rowsUpdated) - }) - - t.Run("returns 1 updated row when trying to renew an OTP with an unconfirmed receiver wallet", func(t *testing.T) { - receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - receiver2 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "testWalletC", "http://home3.page", "home3.page", "wallet3://") - - rw1 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, RegisteredReceiversWalletStatus) - rw2 := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet1.ID, RegisteredReceiversWalletStatus) - - testingOTP := "222333" - - q := ` - UPDATE - receiver_wallets - SET - otp_confirmed_at = NOW() - WHERE - id = $1 - ` - _, err := dbConnectionPool.ExecContext(ctx, q, rw1.ID) - require.NoError(t, err) - - rowsUpdated, err := receiverWalletModel.UpdateOTPByReceiverPhoneNumberAndWalletDomain(ctx, receiver2.PhoneNumber, wallet1.SEP10ClientDomain, testingOTP) - require.NoError(t, err) - assert.Equal(t, 1, rowsUpdated) - - q = `SELECT otp FROM receiver_wallets WHERE id = $1` - var dbOTP string - err = dbConnectionPool.QueryRowxContext(ctx, q, rw2.ID).Scan(&dbOTP) - require.NoError(t, err) - assert.Equal(t, testingOTP, dbOTP) - }) - - t.Run("returns 0 updated rows when the receiver wallet is confirmed", func(t *testing.T) { - receiver1 := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) - wallet1 := CreateWalletFixture(t, ctx, dbConnectionPool, "testWalletD", "http://home4.page", "home4.page", "wallet4://") - _ = CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, RegisteredReceiversWalletStatus) - - testingOTP := "123456" + // Define test cases + testCases := []struct { + name string + setupReceiverWallet func(t *testing.T, receiver Receiver) + contactInfo func(r Receiver, contactType ReceiverContactType) string + clientDomain string + expectedRows int + }{ + { + name: "does not update OTP for a receiver wallet with a different contact info", + setupReceiverWallet: func(t *testing.T, receiver Receiver) { + _ = CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) + }, + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return "invalid_contact_info" + }, + clientDomain: wallet.SEP10ClientDomain, + expectedRows: 0, + }, + { + name: "does not update OTP for a receiver wallet with a different client domain", + setupReceiverWallet: func(t *testing.T, receiver Receiver) { + _ = CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) + }, + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return r.ContactByType(contactType) + }, + clientDomain: "foo-bar", + expectedRows: 0, + }, + { + name: "does not update OTP for a confirmed receiver wallet", + setupReceiverWallet: func(t *testing.T, receiver Receiver) { + rw := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) + // Confirm OTP + q := `UPDATE receiver_wallets SET otp_confirmed_at = NOW() WHERE id = $1` + _, err := dbConnectionPool.ExecContext(ctx, q, rw.ID) + require.NoError(t, err) + }, + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return r.ContactByType(contactType) + }, + clientDomain: wallet.SEP10ClientDomain, + expectedRows: 0, + }, + { + name: "🎉 successfully updates OTP for an unconfirmed receiver wallet", + setupReceiverWallet: func(t *testing.T, receiver Receiver) { + _ = CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) + }, + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return r.ContactByType(contactType) + }, + clientDomain: wallet.SEP10ClientDomain, + expectedRows: 1, + }, + { + name: "🎉 successfully renews OTP for an unconfirmed receiver wallet", + setupReceiverWallet: func(t *testing.T, receiver Receiver) { + // Create a receiver with a different contact info toi make sure they will not be picked by the query + receiverNoOp := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{PhoneNumber: "+141555550000", Email: "zoopbar@test.com"}) + rwNoOp := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverNoOp.ID, wallet.ID, RegisteredReceiversWalletStatus) + + // Confirm OTP for the first receiver + q := `UPDATE receiver_wallets SET otp_confirmed_at = NOW() WHERE id = $1` + _, err := dbConnectionPool.ExecContext(ctx, q, rwNoOp.ID) + require.NoError(t, err) + + _ = CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) + }, + contactInfo: func(r Receiver, contactType ReceiverContactType) string { + return r.ContactByType(contactType) + }, + clientDomain: wallet.SEP10ClientDomain, + expectedRows: 1, + }, + } - q := ` - UPDATE - receiver_wallets - SET - otp_confirmed_at = NOW() - ` - _, err := dbConnectionPool.ExecContext(ctx, q) - require.NoError(t, err) + // Prepare test data + phoneNumber := "+141555555555" + email := "test@example.com" + + // Run test cases + for _, contactType := range GetAllReceiverContactTypes() { + receiverInsert := &Receiver{} + switch contactType { + case ReceiverContactTypeSMS: + receiverInsert.PhoneNumber = phoneNumber + case ReceiverContactTypeEmail: + receiverInsert.Email = email + } - rowsUpdated, err := receiverWalletModel.UpdateOTPByReceiverPhoneNumberAndWalletDomain(ctx, receiver1.PhoneNumber, wallet1.SEP10ClientDomain, testingOTP) - require.NoError(t, err) - assert.Equal(t, 0, rowsUpdated) - }) + for _, tc := range testCases { + t.Run(fmt.Sprintf("%s/%s", contactType, tc.name), func(t *testing.T) { + defer DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + defer DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, receiverInsert) + tc.setupReceiverWallet(t, *receiver) + + otp, err := utils.RandomString(6, utils.NumberBytes) + require.NoError(t, err) + + contactInfo := tc.contactInfo(*receiver, contactType) + rowsUpdated, err := receiverWalletModel.UpdateOTPByReceiverContactInfoAndWalletDomain(ctx, contactInfo, tc.clientDomain, otp) + require.NoError(t, err) + assert.Equal(t, tc.expectedRows, rowsUpdated) + + if tc.expectedRows > 0 { + q := `SELECT otp FROM receiver_wallets WHERE receiver_id = $1 AND wallet_id = $2` + var dbOTP string + err := dbConnectionPool.GetContext(ctx, &dbOTP, q, receiver.ID, wallet.ID) + require.NoError(t, err) + assert.Equal(t, otp, dbOTP) + } + }) + } + } } func Test_VerifyReceiverWalletOTP(t *testing.T) { @@ -1245,8 +1233,8 @@ func Test_GetByStellarAccountAndMemo(t *testing.T) { require.Empty(t, actual) }) - receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, DraftReceiversWalletStatus) - results, err := receiverWalletModel.UpdateOTPByReceiverPhoneNumberAndWalletDomain(ctx, receiver.PhoneNumber, wallet.SEP10ClientDomain, "123456") + receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) + results, err := receiverWalletModel.UpdateOTPByReceiverContactInfoAndWalletDomain(ctx, receiver.PhoneNumber, wallet.SEP10ClientDomain, "123456") require.NoError(t, err) require.Equal(t, 1, results) @@ -1270,9 +1258,10 @@ func Test_GetByStellarAccountAndMemo(t *testing.T) { ID: receiverWallet.ID, Receiver: Receiver{ID: receiver.ID}, Wallet: Wallet{ - ID: wallet.ID, - Name: wallet.Name, - Homepage: wallet.Homepage, + ID: wallet.ID, + Name: wallet.Name, + Homepage: wallet.Homepage, + SEP10ClientDomain: wallet.SEP10ClientDomain, }, Status: receiverWallet.Status, OTP: "123456", @@ -1298,9 +1287,10 @@ func Test_GetByStellarAccountAndMemo(t *testing.T) { ID: receiverWallet.ID, Receiver: Receiver{ID: receiver.ID}, Wallet: Wallet{ - ID: wallet.ID, - Name: wallet.Name, - Homepage: wallet.Homepage, + ID: wallet.ID, + Name: wallet.Name, + Homepage: wallet.Homepage, + SEP10ClientDomain: wallet.SEP10ClientDomain, }, Status: receiverWallet.Status, OTP: "123456", @@ -1400,7 +1390,7 @@ func Test_RetryInvitationSMS(t *testing.T) { receiverWalletModel := ReceiverWalletModel{dbConnectionPool: dbConnectionPool} t.Run("returns error when receiver wallet does not exist", func(t *testing.T) { - receiverWallet, err := receiverWalletModel.RetryInvitationSMS(ctx, dbConnectionPool, "invalid_id") + receiverWallet, err := receiverWalletModel.RetryInvitationMessage(ctx, dbConnectionPool, "invalid_id") require.Error(t, err) require.ErrorIs(t, err, ErrRecordNotFound) require.Empty(t, receiverWallet) @@ -1411,7 +1401,7 @@ func Test_RetryInvitationSMS(t *testing.T) { wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") rw := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, RegisteredReceiversWalletStatus) - receiverWallet, err := receiverWalletModel.RetryInvitationSMS(ctx, dbConnectionPool, rw.ID) + receiverWallet, err := receiverWalletModel.RetryInvitationMessage(ctx, dbConnectionPool, rw.ID) require.Error(t, err) require.ErrorIs(t, err, ErrRecordNotFound) require.Empty(t, receiverWallet) @@ -1422,7 +1412,7 @@ func Test_RetryInvitationSMS(t *testing.T) { wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") rw := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, ReadyReceiversWalletStatus) - receiverWallet, err := receiverWalletModel.RetryInvitationSMS(ctx, dbConnectionPool, rw.ID) + receiverWallet, err := receiverWalletModel.RetryInvitationMessage(ctx, dbConnectionPool, rw.ID) require.NoError(t, err) assert.Nil(t, receiverWallet.InvitationSentAt) }) @@ -1512,3 +1502,189 @@ func Test_ReceiverWalletModelUpdateInvitationSentAt(t *testing.T) { assert.True(t, invitationSentAt.Before(*receiverWallets[0].InvitationSentAt)) }) } + +func Test_ReceiverWalletUpdate_Validate(t *testing.T) { + testCases := []struct { + name string + update ReceiverWalletUpdate + err string + }{ + { + name: "empty update", + update: ReceiverWalletUpdate{}, + err: "no values provided to update receiver wallet", + }, + { + name: "invalid stellar address", + update: ReceiverWalletUpdate{ + StellarAddress: "invalid", + }, + err: "invalid stellar address", + }, + { + name: "invalid status", + update: ReceiverWalletUpdate{ + Status: "invalid", + }, + err: "validating status: invalid receiver wallet status \"invalid\"", + }, + { + name: "OTPConfirmedAt set without OTPConfirmedWith", + update: ReceiverWalletUpdate{ + OTPConfirmedAt: time.Now(), + }, + err: "OTPConfirmedWith is required when OTPConfirmedAt is provided", + }, + { + name: "OTPConfirmedWith set without OTPConfirmedAt", + update: ReceiverWalletUpdate{ + OTPConfirmedWith: "test@email.com", + }, + err: "OTPConfirmedAt is required when OTPConfirmedWith is provided", + }, + { + name: "valid update", + update: ReceiverWalletUpdate{ + Status: RegisteredReceiversWalletStatus, + StellarAddress: "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444", + OTPConfirmedAt: time.Now(), + OTPConfirmedWith: "test@email.com", + }, + err: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.update.Validate() + if tc.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.err) + } + }) + } +} + +func Test_ReceiverWalletModel_Update(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + receiverWalletModel := ReceiverWalletModel{dbConnectionPool: dbConnectionPool} + + t.Run("returns error when update is empty", func(t *testing.T) { + err := receiverWalletModel.Update(ctx, "some-id", ReceiverWalletUpdate{}, dbConnectionPool) + assert.EqualError(t, err, "validating receiver wallet update: no values provided to update receiver wallet") + }) + + t.Run("returns error when receiver wallet does not exist", func(t *testing.T) { + update := ReceiverWalletUpdate{ + Status: RegisteredReceiversWalletStatus, + } + err := receiverWalletModel.Update(ctx, "invalid_id", update, dbConnectionPool) + require.ErrorIs(t, err, ErrRecordNotFound) + }) + + t.Run("returns error when receiver wallet status is not valid", func(t *testing.T) { + update := ReceiverWalletUpdate{ + Status: "invalid", + } + err := receiverWalletModel.Update(ctx, "some id", update, dbConnectionPool) + require.Error(t, err) + assert.Contains(t, err.Error(), "validating receiver wallet update: validating status: invalid receiver wallet status \"invalid\"") + }) + + t.Run("successfully updates receiver wallet", func(t *testing.T) { + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") + receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, DraftReceiversWalletStatus) + + now := time.Now() + + update := ReceiverWalletUpdate{ + Status: RegisteredReceiversWalletStatus, + AnchorPlatformTransactionID: "test-tx-id", + StellarAddress: "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444", + StellarMemo: "123456", + StellarMemoType: "id", + OTPConfirmedAt: now, + OTPConfirmedWith: "test@stellar.org", + } + + err := receiverWalletModel.Update(ctx, receiverWallet.ID, update, dbConnectionPool) + require.NoError(t, err) + + // Verify the update + query := ` + SELECT + rw.status, + rw.anchor_platform_transaction_id, + rw.stellar_address, + rw.stellar_memo, + rw.stellar_memo_type, + rw.otp_confirmed_at, + rw.otp_confirmed_with + FROM + receiver_wallets rw + WHERE + rw.id = $1 + ` + var updated ReceiverWallet + err = dbConnectionPool.GetContext(ctx, &updated, query, receiverWallet.ID) + require.NoError(t, err) + + assert.Equal(t, RegisteredReceiversWalletStatus, updated.Status) + assert.Equal(t, "test-tx-id", updated.AnchorPlatformTransactionID) + assert.Equal(t, "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444", updated.StellarAddress) + assert.Equal(t, "123456", updated.StellarMemo) + assert.Equal(t, "id", updated.StellarMemoType) + assert.WithinDuration(t, now.UTC(), updated.OTPConfirmedAt.UTC(), time.Microsecond) + assert.Equal(t, "test@stellar.org", updated.OTPConfirmedWith) + + // Verify status history was updated + var statusHistory ReceiversWalletStatusHistory + err = dbConnectionPool.GetContext(ctx, &statusHistory, "SELECT status_history FROM receiver_wallets WHERE id = $1", receiverWallet.ID) + require.NoError(t, err) + assert.Equal(t, RegisteredReceiversWalletStatus, statusHistory[0].Status) + }) +} + +func Test_ReceiverWalletModel_GetByIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + receiverWalletModel := ReceiverWalletModel{dbConnectionPool: dbConnectionPool} + + t.Run("returns error when no receiver wallet IDs are provided", func(t *testing.T) { + rws, err := receiverWalletModel.GetByIDs(ctx, dbConnectionPool) + assert.EqualError(t, err, "no receiver wallet IDs provided") + assert.Empty(t, rws) + }) + + t.Run("returns no receiver wallets when IDs are invalid", func(t *testing.T) { + rws, err := receiverWalletModel.GetByIDs(ctx, dbConnectionPool, "invalid_id") + require.NoError(t, err) + assert.Empty(t, rws) + }) + + t.Run("🎉successfully return receiver wallet when it exists", func(t *testing.T) { + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") + receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, DraftReceiversWalletStatus) + + rws, err := receiverWalletModel.GetByIDs(ctx, dbConnectionPool, receiverWallet.ID) + require.NoError(t, err) + require.Len(t, rws, 1) + assert.Equal(t, receiverWallet.ID, rws[0].ID) + }) +} diff --git a/internal/data/registration_contact_type.go b/internal/data/registration_contact_type.go new file mode 100644 index 000000000..63037f049 --- /dev/null +++ b/internal/data/registration_contact_type.go @@ -0,0 +1,97 @@ +package data + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "slices" + "strings" +) + +// RegistrationContactType represents the type of contact information to be used when creating and validating a disbursement. +type RegistrationContactType struct { + ReceiverContactType ReceiverContactType `json:"registration_contact_type"` + IncludesWalletAddress bool `json:"includes_wallet_address"` +} + +var ( + RegistrationContactTypeEmail = RegistrationContactType{ReceiverContactTypeEmail, false} + RegistrationContactTypePhone = RegistrationContactType{ReceiverContactTypeSMS, false} + RegistrationContactTypeEmailAndWalletAddress = RegistrationContactType{ReceiverContactTypeEmail, true} + RegistrationContactTypePhoneAndWalletAddress = RegistrationContactType{ReceiverContactTypeSMS, true} +) + +func (rct RegistrationContactType) String() string { + if rct.IncludesWalletAddress { + return fmt.Sprintf("%s_AND_WALLET_ADDRESS", rct.ReceiverContactType) + } + return string(rct.ReceiverContactType) +} + +// ParseFromString parses the string, setting ReceiverContactType and IncludesWalletAddress based on suffix. +func (rct *RegistrationContactType) ParseFromString(input string) error { + input = strings.ToUpper(strings.TrimSpace(input)) + rct.IncludesWalletAddress = strings.HasSuffix(input, "_AND_WALLET_ADDRESS") + rct.ReceiverContactType = ReceiverContactType(strings.TrimSuffix(input, "_AND_WALLET_ADDRESS")) + + if !slices.Contains(GetAllReceiverContactTypes(), rct.ReceiverContactType) { + return fmt.Errorf("unknown ReceiverContactType %q", rct.ReceiverContactType) + } + + return nil +} + +func AllRegistrationContactTypes() []RegistrationContactType { + return []RegistrationContactType{ + RegistrationContactTypeEmail, + RegistrationContactTypePhone, + RegistrationContactTypeEmailAndWalletAddress, + RegistrationContactTypePhoneAndWalletAddress, + } +} + +func (rct RegistrationContactType) MarshalJSON() ([]byte, error) { + return json.Marshal(rct.String()) +} + +func (rct *RegistrationContactType) UnmarshalJSON(data []byte) error { + var strValue string + if err := json.Unmarshal(data, &strValue); err != nil { + return err + } + + if strValue == "" { + return nil + } + return rct.ParseFromString(strValue) +} + +func (rct RegistrationContactType) Value() (driver.Value, error) { + return rct.String(), nil +} + +func (rct *RegistrationContactType) Scan(value interface{}) error { + if value == nil { + return nil + } + + byteValue, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type for RegistrationContactType %T", value) + } + + strValue := string(byteValue) + if strValue == "" { + return nil + } + + return rct.ParseFromString(strValue) +} + +var ( + _ json.Marshaler = (*RegistrationContactType)(nil) + _ json.Unmarshaler = (*RegistrationContactType)(nil) + _ driver.Valuer = (*RegistrationContactType)(nil) + _ sql.Scanner = (*RegistrationContactType)(nil) +) diff --git a/internal/data/roles.go b/internal/data/roles.go index b24414690..d8f03f736 100644 --- a/internal/data/roles.go +++ b/internal/data/roles.go @@ -20,7 +20,7 @@ const ( OwnerUserRole UserRole = "owner" // FinancialControllerUserRole has the same permissions as the OwnerUserRole except for user management. FinancialControllerUserRole UserRole = "financial_controller" - // DeveloperUserRole has only configuration permissions. (wallets, assets, countries management. Also, statistics access permission) + // DeveloperUserRole has only configuration permissions. (wallets, assets management. Also, statistics access permission) DeveloperUserRole UserRole = "developer" // BusinessUserRole has read-only permissions - except for user management that they can't read any data. BusinessUserRole UserRole = "business" diff --git a/internal/data/verification_type.go b/internal/data/verification_type.go new file mode 100644 index 000000000..a5d725d59 --- /dev/null +++ b/internal/data/verification_type.go @@ -0,0 +1,20 @@ +package data + +type VerificationType string + +const ( + VerificationTypeDateOfBirth VerificationType = "DATE_OF_BIRTH" + VerificationTypeYearMonth VerificationType = "YEAR_MONTH" + VerificationTypePin VerificationType = "PIN" + VerificationTypeNationalID VerificationType = "NATIONAL_ID_NUMBER" +) + +// GetAllVerificationTypes returns all the available verification types. +func GetAllVerificationTypes() []VerificationType { + return []VerificationType{ + VerificationTypeDateOfBirth, + VerificationTypeYearMonth, + VerificationTypePin, + VerificationTypeNationalID, + } +} diff --git a/internal/data/wallets.go b/internal/data/wallets.go index 002a04653..f10b2bd00 100644 --- a/internal/data/wallets.go +++ b/internal/data/wallets.go @@ -27,6 +27,7 @@ type Wallet struct { SEP10ClientDomain string `json:"sep_10_client_domain,omitempty" db:"sep_10_client_domain"` DeepLinkSchema string `json:"deep_link_schema,omitempty" db:"deep_link_schema"` Enabled bool `json:"enabled" db:"enabled"` + UserManaged bool `json:"user_managed,omitempty" db:"user_managed"` Assets WalletAssets `json:"assets,omitempty" db:"assets"` CreatedAt *time.Time `json:"created_at,omitempty" db:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty" db:"updated_at"` @@ -103,29 +104,32 @@ func (wm *WalletModel) GetByWalletName(ctx context.Context, name string) (*Walle return &wallet, nil } -// FindWallets returns wallets filtering by enabled status. -func (wm *WalletModel) FindWallets(ctx context.Context, enabledFilter *bool) ([]Wallet, error) { - wallets := []Wallet{} - var whereClause string - var args []interface{} +const ( + FilterEnabledWallets FilterKey = "enabled" + FilterUserManaged FilterKey = "user_managed" +) - if enabledFilter != nil { - whereClause = "WHERE w.enabled = $1 " - args = append(args, *enabledFilter) +// FindWallets returns wallets filtering by enabled status. +func (wm *WalletModel) FindWallets(ctx context.Context, filters ...Filter) ([]Wallet, error) { + qb := NewQueryBuilder(getQuery) + for _, filter := range filters { + qb.AddCondition(filter.Key.Equals(), filter.Value) } + qb.AddGroupBy("w.id") + qb.AddSorting(SortFieldName, SortOrderASC, "w") + query, args := qb.BuildAndRebind(wm.dbConnectionPool) - query := fmt.Sprintf("%s %s %s", getQuery, whereClause, " GROUP BY w.id ORDER BY w.name") - + wallets := []Wallet{} err := wm.dbConnectionPool.SelectContext(ctx, &wallets, query, args...) if err != nil { - return nil, fmt.Errorf("error querying wallets: %w", err) + return nil, fmt.Errorf("querying wallets: %w", err) } return wallets, nil } // GetAll returns all wallets in the database func (wm *WalletModel) GetAll(ctx context.Context) ([]Wallet, error) { - return wm.FindWallets(ctx, nil) + return wm.FindWallets(ctx) } func (wm *WalletModel) Insert(ctx context.Context, newWallet WalletInsert) (*Wallet, error) { @@ -161,10 +165,10 @@ func (wm *WalletModel) Insert(ctx context.Context, newWallet WalletInsert) (*Wal if err != nil { if pqError, ok := err.(*pq.Error); ok { constraintErrMap := map[string]error{ + "wallets_assets_asset_id_fkey": ErrInvalidAssetID, "wallets_name_key": ErrWalletNameAlreadyExists, "wallets_homepage_key": ErrWalletHomepageAlreadyExists, "wallets_deep_link_schema_key": ErrWalletDeepLinkSchemaAlreadyExists, - "wallets_assets_asset_id_fkey": ErrInvalidAssetID, } errConstraint, ok := constraintErrMap[pqError.Constraint] diff --git a/internal/data/wallets_test.go b/internal/data/wallets_test.go index 7ffc34360..eae18dbe3 100644 --- a/internal/data/wallets_test.go +++ b/internal/data/wallets_test.go @@ -154,8 +154,7 @@ func Test_WalletModelFindWallets(t *testing.T) { EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, wallets[0].ID) EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, true, wallets[1].ID) - findEnabled := true - actual, err := walletModel.FindWallets(ctx, &findEnabled) + actual, err := walletModel.FindWallets(ctx, NewFilter(FilterEnabledWallets, true)) require.NoError(t, err) require.Len(t, actual, 1) @@ -168,8 +167,33 @@ func Test_WalletModelFindWallets(t *testing.T) { EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, wallets[0].ID) EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, true, wallets[1].ID) - findDisabled := false - actual, err := walletModel.FindWallets(ctx, &findDisabled) + actual, err := walletModel.FindWallets(ctx, NewFilter(FilterEnabledWallets, false)) + require.NoError(t, err) + + require.Len(t, actual, 1) + require.Equal(t, wallets[0].ID, actual[0].ID) + }) + + t.Run("returns user_managed wallet", func(t *testing.T) { + wallets := ClearAndCreateWalletFixtures(t, ctx, dbConnectionPool) + + MakeWalletUserManaged(t, ctx, dbConnectionPool, wallets[0].ID) + + actual, err := walletModel.FindWallets(ctx, NewFilter(FilterUserManaged, true)) + require.NoError(t, err) + + require.Len(t, actual, 1) + require.Equal(t, wallets[0].ID, actual[0].ID) + }) + + t.Run("returns user_managed and enabled wallet", func(t *testing.T) { + wallets := ClearAndCreateWalletFixtures(t, ctx, dbConnectionPool) + + MakeWalletUserManaged(t, ctx, dbConnectionPool, wallets[0].ID) + EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, true, wallets[0].ID) + EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, wallets[1].ID) + + actual, err := walletModel.FindWallets(ctx, NewFilter(FilterUserManaged, true), NewFilter(FilterEnabledWallets, true)) require.NoError(t, err) require.Len(t, actual, 1) @@ -178,7 +202,7 @@ func Test_WalletModelFindWallets(t *testing.T) { t.Run("returns empty array when no wallets", func(t *testing.T) { DeleteAllWalletFixtures(t, ctx, dbConnectionPool) - actual, err := walletModel.FindWallets(ctx, nil) + actual, err := walletModel.FindWallets(ctx) require.NoError(t, err) require.Equal(t, []Wallet{}, actual) @@ -251,7 +275,7 @@ func Test_WalletModelInsert(t *testing.T) { }) // Ensure that only insert one of each entry - t.Run("duplicated countries codes and assets IDs", func(t *testing.T) { + t.Run("duplicated assets IDs", func(t *testing.T) { DeleteAllWalletFixtures(t, ctx, dbConnectionPool) name := "test_wallet" diff --git a/internal/dependencyinjection/circle_service_test.go b/internal/dependencyinjection/circle_service_test.go index e020b609c..1b1b43368 100644 --- a/internal/dependencyinjection/circle_service_test.go +++ b/internal/dependencyinjection/circle_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + monitorMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -21,6 +22,7 @@ func Test_NewCircleService(t *testing.T) { TenantManager: &tenant.TenantManagerMock{}, NetworkType: utils.TestnetNetworkType, EncryptionPassphrase: keypair.MustRandom().Seed(), + MonitorService: monitorMocks.NewMockMonitorService(t), } t.Run("should create and return the same instance on the second call", func(t *testing.T) { diff --git a/internal/dependencyinjection/message_dispatcher.go b/internal/dependencyinjection/message_dispatcher.go new file mode 100644 index 000000000..09d82629a --- /dev/null +++ b/internal/dependencyinjection/message_dispatcher.go @@ -0,0 +1,45 @@ +package dependencyinjection + +import ( + "context" + "fmt" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" +) + +const MessageDispatcherInstanceName = "message_dispatcher_instance" + +type MessageDispatcherOpts struct { + EmailOpts *EmailClientOptions + SMSOpts *SMSClientOptions +} + +func NewMessageDispatcher(ctx context.Context, opts MessageDispatcherOpts) (*message.MessageDispatcher, error) { + if instance, ok := GetInstance(MessageDispatcherInstanceName); ok { + if dispatcherInstance, ok := instance.(*message.MessageDispatcher); ok { + return dispatcherInstance, nil + } + return nil, fmt.Errorf("trying to cast pre-existing MessageDispatcher for dependency injection") + } + + dispatcher := message.NewMessageDispatcher() + + if opts.EmailOpts != nil { + emailClient, err := NewEmailClient(*opts.EmailOpts) + if err != nil { + return nil, fmt.Errorf("creating email client: %w", err) + } + dispatcher.RegisterClient(ctx, message.MessageChannelEmail, emailClient) + } + + if opts.SMSOpts != nil { + smsClient, err := NewSMSClient(*opts.SMSOpts) + if err != nil { + return nil, fmt.Errorf("creating SMS client: %w", err) + } + dispatcher.RegisterClient(ctx, message.MessageChannelSMS, smsClient) + } + + SetInstance(MessageDispatcherInstanceName, dispatcher) + return dispatcher, nil +} diff --git a/internal/dependencyinjection/message_dispatcher_test.go b/internal/dependencyinjection/message_dispatcher_test.go new file mode 100644 index 000000000..e60c02741 --- /dev/null +++ b/internal/dependencyinjection/message_dispatcher_test.go @@ -0,0 +1,116 @@ +package dependencyinjection + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" +) + +func Test_NewMessageDispatcher(t *testing.T) { + ctx := context.Background() + t.Run("should return the same instance when called twice", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + opts := MessageDispatcherOpts{ + EmailOpts: &EmailClientOptions{ + EmailType: message.MessengerTypeDryRun, + }, + SMSOpts: &SMSClientOptions{ + SMSType: message.MessengerTypeDryRun, + }, + } + + dispatcher1, err := NewMessageDispatcher(ctx, opts) + require.NoError(t, err) + dispatcher2, err := NewMessageDispatcher(ctx, opts) + require.NoError(t, err) + assert.Equal(t, dispatcher1, dispatcher2) + }) + + t.Run("should create dispatcher with email client only", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + opts := MessageDispatcherOpts{ + EmailOpts: &EmailClientOptions{ + EmailType: message.MessengerTypeDryRun, + }, + } + + dispatcher, err := NewMessageDispatcher(ctx, opts) + require.NoError(t, err) + + emailClient, err := dispatcher.GetClient(message.MessageChannelEmail) + require.NoError(t, err) + assert.NotNil(t, emailClient) + + smsClient, err := dispatcher.GetClient(message.MessageChannelSMS) + assert.EqualError(t, err, "no client registered for channel \"SMS\"") + assert.Nil(t, smsClient) + }) + + t.Run("should create dispatcher with SMS client only", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + opts := MessageDispatcherOpts{ + SMSOpts: &SMSClientOptions{ + SMSType: message.MessengerTypeDryRun, + }, + } + + dispatcher, err := NewMessageDispatcher(ctx, opts) + require.NoError(t, err) + + smsClient, err := dispatcher.GetClient(message.MessageChannelSMS) + require.NoError(t, err) + assert.NotNil(t, smsClient) + + emailClient, err := dispatcher.GetClient(message.MessageChannelEmail) + assert.EqualError(t, err, "no client registered for channel \"EMAIL\"") + assert.Nil(t, emailClient) + }) + + t.Run("should return an error on invalid email client creation", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + opts := MessageDispatcherOpts{ + EmailOpts: &EmailClientOptions{ + EmailType: "invalid-type", + }, + } + + dispatcher, err := NewMessageDispatcher(ctx, opts) + assert.ErrorContains(t, err, `trying to create a Email client with a non-supported Email type: "invalid-type"`) + assert.Nil(t, dispatcher) + }) + + t.Run("should return an error on invalid SMS client creation", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + opts := MessageDispatcherOpts{ + SMSOpts: &SMSClientOptions{ + SMSType: "invalid-type", + }, + } + + dispatcher, err := NewMessageDispatcher(ctx, opts) + assert.ErrorContains(t, err, `trying to create a SMS client with a non-supported SMS type: "invalid-type"`) + assert.Nil(t, dispatcher) + }) + + t.Run("should return an error on invalid pre-existing instance", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + preExistingDispatcherWithInvalidType := struct{}{} + SetInstance(MessageDispatcherInstanceName, preExistingDispatcherWithInvalidType) + + opts := MessageDispatcherOpts{} + + gotDispatcher, err := NewMessageDispatcher(ctx, opts) + assert.Nil(t, gotDispatcher) + assert.EqualError(t, err, "trying to cast pre-existing MessageDispatcher for dependency injection") + }) +} diff --git a/internal/events/eventhandlers/send_receiver_wallets_sms_invitation_event_handler.go b/internal/events/eventhandlers/send_receiver_wallets_invitation_event_handler.go similarity index 62% rename from internal/events/eventhandlers/send_receiver_wallets_sms_invitation_event_handler.go rename to internal/events/eventhandlers/send_receiver_wallets_invitation_event_handler.go index 854e5801b..37d475a89 100644 --- a/internal/events/eventhandlers/send_receiver_wallets_sms_invitation_event_handler.go +++ b/internal/events/eventhandlers/send_receiver_wallets_invitation_event_handler.go @@ -17,25 +17,25 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) -type SendReceiverWalletsSMSInvitationEventHandlerOptions struct { - AdminDBConnectionPool db.DBConnectionPool - MtnDBConnectionPool db.DBConnectionPool - AnchorPlatformBaseSepURL string - MessengerClient message.MessengerClient - MaxInvitationSMSResendAttempts int64 - Sep10SigningPrivateKey string - CrashTrackerClient crashtracker.CrashTrackerClient +type SendReceiverWalletsInvitationEventHandlerOptions struct { + AdminDBConnectionPool db.DBConnectionPool + MtnDBConnectionPool db.DBConnectionPool + AnchorPlatformBaseSepURL string + MessageDispatcher message.MessageDispatcherInterface + MaxInvitationResendAttempts int64 + Sep10SigningPrivateKey string + CrashTrackerClient crashtracker.CrashTrackerClient } -type SendReceiverWalletsSMSInvitationEventHandler struct { +type SendReceiverWalletsInvitationEventHandler struct { tenantManager tenant.ManagerInterface mtnDBConnectionPool db.DBConnectionPool service services.SendReceiverWalletInviteServiceInterface } -var _ events.EventHandler = new(SendReceiverWalletsSMSInvitationEventHandler) +var _ events.EventHandler = new(SendReceiverWalletsInvitationEventHandler) -func NewSendReceiverWalletsSMSInvitationEventHandler(options SendReceiverWalletsSMSInvitationEventHandlerOptions) *SendReceiverWalletsSMSInvitationEventHandler { +func NewSendReceiverWalletsInvitationEventHandler(options SendReceiverWalletsInvitationEventHandlerOptions) *SendReceiverWalletsInvitationEventHandler { tm := tenant.NewManager(tenant.WithDatabase(options.AdminDBConnectionPool)) models, err := data.NewModels(options.MtnDBConnectionPool) @@ -45,34 +45,34 @@ func NewSendReceiverWalletsSMSInvitationEventHandler(options SendReceiverWallets s, err := services.NewSendReceiverWalletInviteService( models, - options.MessengerClient, + options.MessageDispatcher, options.Sep10SigningPrivateKey, - options.MaxInvitationSMSResendAttempts, + options.MaxInvitationResendAttempts, options.CrashTrackerClient, ) if err != nil { log.Fatalf("error instantiating service: %s", err.Error()) } - return &SendReceiverWalletsSMSInvitationEventHandler{ + return &SendReceiverWalletsInvitationEventHandler{ tenantManager: tm, mtnDBConnectionPool: options.MtnDBConnectionPool, service: s, } } -func (h *SendReceiverWalletsSMSInvitationEventHandler) Name() string { +func (h *SendReceiverWalletsInvitationEventHandler) Name() string { return utils.GetTypeName(h) } -func (h *SendReceiverWalletsSMSInvitationEventHandler) CanHandleMessage(ctx context.Context, message *events.Message) bool { +func (h *SendReceiverWalletsInvitationEventHandler) CanHandleMessage(ctx context.Context, message *events.Message) bool { return message.Topic == events.ReceiverWalletNewInvitationTopic } -func (h *SendReceiverWalletsSMSInvitationEventHandler) Handle(ctx context.Context, message *events.Message) error { - receiverWalletInvitationData, err := utils.ConvertType[any, []schemas.EventReceiverWalletSMSInvitationData](message.Data) +func (h *SendReceiverWalletsInvitationEventHandler) Handle(ctx context.Context, message *events.Message) error { + receiverWalletInvitationData, err := utils.ConvertType[any, []schemas.EventReceiverWalletInvitationData](message.Data) if err != nil { - return fmt.Errorf("could not convert message data to %T: %w", []schemas.EventReceiverWalletSMSInvitationData{}, err) + return fmt.Errorf("could not convert message data to %T: %w", []schemas.EventReceiverWalletInvitationData{}, err) } t, err := h.tenantManager.GetTenantByID(ctx, message.TenantID) diff --git a/internal/events/eventhandlers/send_receiver_wallets_sms_invitation_event_handler_test.go b/internal/events/eventhandlers/send_receiver_wallets_invitation_event_handler_test.go similarity index 90% rename from internal/events/eventhandlers/send_receiver_wallets_sms_invitation_event_handler_test.go rename to internal/events/eventhandlers/send_receiver_wallets_invitation_event_handler_test.go index 577d16fe7..89d10f906 100644 --- a/internal/events/eventhandlers/send_receiver_wallets_sms_invitation_event_handler_test.go +++ b/internal/events/eventhandlers/send_receiver_wallets_invitation_event_handler_test.go @@ -31,7 +31,7 @@ func Test_SendReceiverWalletsSMSInvitationEventHandler_Handle(t *testing.T) { service := servicesMocks.MockSendReceiverWalletInviteService{} - handler := SendReceiverWalletsSMSInvitationEventHandler{ + handler := SendReceiverWalletsInvitationEventHandler{ tenantManager: tenantManager, mtnDBConnectionPool: mtnDBConnectionPool, service: &service, @@ -40,13 +40,13 @@ func Test_SendReceiverWalletsSMSInvitationEventHandler_Handle(t *testing.T) { ctx := context.Background() t.Run("logs and report error when message Data is invalid", func(t *testing.T) { handleErr := handler.Handle(ctx, &events.Message{Data: "invalid"}) - assert.ErrorContains(t, handleErr, "could not convert message data to []schemas.EventReceiverWalletSMSInvitationData") + assert.ErrorContains(t, handleErr, "could not convert message data to []schemas.EventReceiverWalletInvitationData") }) t.Run("logs and report error when fails getting tenant by ID", func(t *testing.T) { handleErr := handler.Handle(ctx, &events.Message{ TenantID: "tenant-id", - Data: []schemas.EventReceiverWalletSMSInvitationData{ + Data: []schemas.EventReceiverWalletInvitationData{ {ReceiverWalletID: "rw-id-1"}, {ReceiverWalletID: "rw-id-2"}, }, @@ -60,7 +60,7 @@ func Test_SendReceiverWalletsSMSInvitationEventHandler_Handle(t *testing.T) { tnt, err := tenantManager.AddTenant(ctx, "myorg1") require.NoError(t, err) - reqs := []schemas.EventReceiverWalletSMSInvitationData{ + reqs := []schemas.EventReceiverWalletInvitationData{ {ReceiverWalletID: "rw-id-1"}, {ReceiverWalletID: "rw-id-2"}, } @@ -85,7 +85,7 @@ func Test_SendReceiverWalletsSMSInvitationEventHandler_Handle(t *testing.T) { tnt, err := tenantManager.AddTenant(ctx, "myorg1") require.NoError(t, err) - reqs := []schemas.EventReceiverWalletSMSInvitationData{ + reqs := []schemas.EventReceiverWalletInvitationData{ {ReceiverWalletID: "rw-id-1"}, {ReceiverWalletID: "rw-id-2"}, } @@ -109,7 +109,7 @@ func Test_SendReceiverWalletsSMSInvitationEventHandler_Handle(t *testing.T) { func Test_SendReceiverWalletsSMSInvitationEventHandler_CanHandleMessage(t *testing.T) { ctx := context.Background() - handler := SendReceiverWalletsSMSInvitationEventHandler{} + handler := SendReceiverWalletsInvitationEventHandler{} assert.False(t, handler.CanHandleMessage(ctx, &events.Message{Topic: "some-topic"})) assert.True(t, handler.CanHandleMessage(ctx, &events.Message{Topic: events.ReceiverWalletNewInvitationTopic})) diff --git a/internal/events/handler.go b/internal/events/handler.go index c704e048f..ad596f088 100644 --- a/internal/events/handler.go +++ b/internal/events/handler.go @@ -18,8 +18,8 @@ const ( // Type Names const ( - RetryReceiverWalletSMSInvitationType = "retry-receiver-wallet-sms-invitation" - BatchReceiverWalletSMSInvitationType = "batch-receiver-wallet-sms-invitation" + RetryReceiverWalletInvitationType = "retry-receiver-wallet-sms-invitation" + BatchReceiverWalletInvitationType = "batch-receiver-wallet-sms-invitation" PaymentCompletedSuccessType = "payment-completed-success" PaymentCompletedErrorType = "payment-completed-error" PaymentReadyToPayDisbursementStarted = "payment-ready-to-pay-disbursement-started" diff --git a/internal/events/schemas/schemas.go b/internal/events/schemas/schemas.go index bba41123b..6ed8d361f 100644 --- a/internal/events/schemas/schemas.go +++ b/internal/events/schemas/schemas.go @@ -2,7 +2,7 @@ package schemas import "time" -type EventReceiverWalletSMSInvitationData struct { +type EventReceiverWalletInvitationData struct { ReceiverWalletID string `json:"receiver_wallet_id"` } diff --git a/internal/htmltemplate/htmltemplate.go b/internal/htmltemplate/htmltemplate.go index f6589573c..baf555a1c 100644 --- a/internal/htmltemplate/htmltemplate.go +++ b/internal/htmltemplate/htmltemplate.go @@ -4,14 +4,22 @@ import ( "bytes" "embed" "fmt" - "text/template" + "html/template" ) -//go:embed tmpl/*.tmpl +//go:embed tmpl/**/*.tmpl "tmpl/*.tmpl" var Tmpl embed.FS func ExecuteHTMLTemplate(templateName string, data interface{}) (string, error) { - t, err := template.ParseFS(Tmpl, "tmpl/*.tmpl") + // Define the function map that will be available inside the templates + funcMap := template.FuncMap{ + "EmailStyle": func() template.HTML { + return emailStyle + }, + } + + // Parse the templates with the function map + t, err := template.New("").Funcs(funcMap).ParseFS(Tmpl, "tmpl/*.tmpl", "tmpl/**/*.tmpl") if err != nil { return "", fmt.Errorf("error parsing embedded template files: %w", err) } @@ -26,39 +34,72 @@ func ExecuteHTMLTemplate(templateName string, data interface{}) (string, error) } type EmptyBodyEmailTemplate struct { - Body string + Body template.HTML } func ExecuteHTMLTemplateForEmailEmptyBody(data EmptyBodyEmailTemplate) (string, error) { return ExecuteHTMLTemplate("empty_body.tmpl", data) } -type InvitationMessageTemplate struct { +type StaffInvitationEmailMessageTemplate struct { FirstName string Role string ForgotPasswordLink string OrganizationName string } -func ExecuteHTMLTemplateForInvitationMessage(data InvitationMessageTemplate) (string, error) { - return ExecuteHTMLTemplate("invitation_message.tmpl", data) +func ExecuteHTMLTemplateForStaffInvitationEmailMessage(data StaffInvitationEmailMessageTemplate) (string, error) { + return ExecuteHTMLTemplate("staff_invitation_message.tmpl", data) } -type ForgotPasswordMessageTemplate struct { +type StaffForgotPasswordEmailMessageTemplate struct { ResetToken string ResetPasswordLink string OrganizationName string } -func ExecuteHTMLTemplateForForgotPasswordMessage(data ForgotPasswordMessageTemplate) (string, error) { - return ExecuteHTMLTemplate("forgot_password_message.tmpl", data) +func ExecuteHTMLTemplateForStaffForgotPasswordEmailMessage(data StaffForgotPasswordEmailMessageTemplate) (string, error) { + return ExecuteHTMLTemplate("staff_forgot_password_message.tmpl", data) } -type MFAMessageTemplate struct { +type StaffMFAEmailMessageTemplate struct { MFACode string OrganizationName string } -func ExecuteHTMLTemplateForMFAMessage(data MFAMessageTemplate) (string, error) { - return ExecuteHTMLTemplate("mfa_message.tmpl", data) +func ExecuteHTMLTemplateForStaffMFAEmailMessage(data StaffMFAEmailMessageTemplate) (string, error) { + return ExecuteHTMLTemplate("staff_mfa_message.tmpl", data) } + +// emailStyle is the CSS style that will be included in the email templates. +const emailStyle = template.HTML(` + +`) diff --git a/internal/htmltemplate/htmltemplate_test.go b/internal/htmltemplate/htmltemplate_test.go index 7fe0303ff..bc9e33657 100644 --- a/internal/htmltemplate/htmltemplate_test.go +++ b/internal/htmltemplate/htmltemplate_test.go @@ -3,6 +3,7 @@ package htmltemplate import ( "crypto/rand" "fmt" + "html/template" "testing" "github.com/stretchr/testify/assert" @@ -14,7 +15,7 @@ func Test_ExecuteHTMLTemplate(t *testing.T) { var inputData interface{} templateStr, err := ExecuteHTMLTemplate("non-existing-file.html", inputData) require.Empty(t, templateStr) - require.EqualError(t, err, `executing html template: template: no template "non-existing-file.html" associated with template "empty_body.tmpl"`) + require.EqualError(t, err, `executing html template: html/template: "non-existing-file.html" is undefined`) // handle invalid struct body inputData = struct { @@ -43,22 +44,22 @@ func Test_ExecuteHTMLTemplateForEmailEmptyBody(t *testing.T) { randomStr := fmt.Sprintf("%x", b)[:10] // check if the random string is imprinted in the template - inputData := EmptyBodyEmailTemplate{Body: randomStr} + inputData := EmptyBodyEmailTemplate{Body: template.HTML(randomStr)} templateStr, err := ExecuteHTMLTemplateForEmailEmptyBody(inputData) require.NoError(t, err) require.Contains(t, templateStr, randomStr) } -func Test_ExecuteHTMLTemplateForInvitationMessage(t *testing.T) { +func Test_ExecuteHTMLTemplateForStaffInvitationEmailMessage(t *testing.T) { forgotPasswordLink := "https://sdp.com/forgot-password" - data := InvitationMessageTemplate{ + data := StaffInvitationEmailMessageTemplate{ FirstName: "First", Role: "developer", ForgotPasswordLink: forgotPasswordLink, OrganizationName: "Organization Name", } - content, err := ExecuteHTMLTemplateForInvitationMessage(data) + content, err := ExecuteHTMLTemplateForStaffInvitationEmailMessage(data) require.NoError(t, err) assert.Contains(t, content, "Hello, First!") @@ -67,13 +68,31 @@ func Test_ExecuteHTMLTemplateForInvitationMessage(t *testing.T) { assert.Contains(t, content, "Organization Name") } -func Test_ExecuteHTMLTemplateForForgotPasswordMessage(t *testing.T) { - data := ForgotPasswordMessageTemplate{ +func Test_ExecuteHTMLTemplateForStaffInvitationEmailMessage_HTMLInjectionAttack(t *testing.T) { + forgotPasswordLink := "https://sdp.com/forgot-password" + + data := StaffInvitationEmailMessageTemplate{ + FirstName: "First", + Role: "developer", + ForgotPasswordLink: forgotPasswordLink, + OrganizationName: "Redeem funds", + } + content, err := ExecuteHTMLTemplateForStaffInvitationEmailMessage(data) + require.NoError(t, err) + + assert.Contains(t, content, "Hello, First!") + assert.Contains(t, content, "as a developer.") + assert.Contains(t, content, forgotPasswordLink) + assert.Contains(t, content, "<a href='evil.com'>Redeem funds</a>") +} + +func Test_ExecuteHTMLTemplateForStaffForgotPasswordEmailMessage(t *testing.T) { + data := StaffForgotPasswordEmailMessageTemplate{ ResetToken: "resetToken", ResetPasswordLink: "https://sdp.com/reset-password", OrganizationName: "Organization Name", } - content, err := ExecuteHTMLTemplateForForgotPasswordMessage(data) + content, err := ExecuteHTMLTemplateForStaffForgotPasswordEmailMessage(data) require.NoError(t, err) assert.Contains(t, content, "resetToken") diff --git a/internal/htmltemplate/tmpl/email/staff_forgot_password_message.tmpl b/internal/htmltemplate/tmpl/email/staff_forgot_password_message.tmpl new file mode 100644 index 000000000..faf38e8fa --- /dev/null +++ b/internal/htmltemplate/tmpl/email/staff_forgot_password_message.tmpl @@ -0,0 +1,16 @@ + + + + + + Password Reset + {{EmailStyle}} + + +

Hello,

+

We received a request to reset your {{.OrganizationName}} account password. Please use the confirmation token {{.ResetToken}} on the reset password page to create a new password.

+

If you did not request a password reset, please ignore this message or contact your organization administrator with any questions or concerns.

+

Best regards,

+

The {{.OrganizationName}} Team

+ + diff --git a/internal/htmltemplate/tmpl/email/staff_invitation_message.tmpl b/internal/htmltemplate/tmpl/email/staff_invitation_message.tmpl new file mode 100644 index 000000000..fd8e0f755 --- /dev/null +++ b/internal/htmltemplate/tmpl/email/staff_invitation_message.tmpl @@ -0,0 +1,18 @@ + + + + + + Welcome to Stellar Disbursement Platform + {{EmailStyle}} + + +

Hello, {{.FirstName}}!

+

You have been added to your organization's Stellar Disbursement Platform as a {{.Role}}. Please click the button below to set up your password. If you have any questions, feel free to contact your organization administrator.

+

+ Set up my password +

+

Best regards,

+

The {{.OrganizationName}} Team

+ + diff --git a/internal/htmltemplate/tmpl/mfa_message.tmpl b/internal/htmltemplate/tmpl/email/staff_mfa_message.tmpl similarity index 54% rename from internal/htmltemplate/tmpl/mfa_message.tmpl rename to internal/htmltemplate/tmpl/email/staff_mfa_message.tmpl index cc66a1855..98418a1b8 100644 --- a/internal/htmltemplate/tmpl/mfa_message.tmpl +++ b/internal/htmltemplate/tmpl/email/staff_mfa_message.tmpl @@ -2,22 +2,14 @@ + Your verification code - + {{EmailStyle}} -

Here is the 6-digit verification code you requested. Please enter it into the two-factor authentication prompt to sign-in.

+

Here is the 6-digit verification code you requested. Please enter it into the two-factor authentication prompt to sign-in:

Your verification code is: {{.MFACode}}.

-

If you did not request this code, please ignore this message and consider changing your password to ensure the security of your account. If you have any questions or concerns, please reach out to your organization's administrator.

+

If you did not request this code, please ignore this message and consider updating your password to ensure account security. Contact your organization's administrator if you have any questions or concerns.

Best regards,

The {{.OrganizationName}} Team

diff --git a/internal/htmltemplate/tmpl/forgot_password_message.tmpl b/internal/htmltemplate/tmpl/forgot_password_message.tmpl deleted file mode 100644 index e27d2a571..000000000 --- a/internal/htmltemplate/tmpl/forgot_password_message.tmpl +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Password Reset - - - -

Hello,

-

We received a request to reset your Stellar Disbursement Platform account password. Please use the confirmation token {{.ResetToken}} on the reset password page to create a new password.

-

If you did not request a password reset, please ignore this message or reach out to your organization's administrator with any questions or concerns.

-

Best regards,

-

The {{.OrganizationName}} Team

- - diff --git a/internal/htmltemplate/tmpl/invitation_message.tmpl b/internal/htmltemplate/tmpl/invitation_message.tmpl deleted file mode 100644 index 3d106c831..000000000 --- a/internal/htmltemplate/tmpl/invitation_message.tmpl +++ /dev/null @@ -1,41 +0,0 @@ - - - - - Welcome to Stellar Disbursement Platform - - - -

Hello, {{.FirstName}}!

-

You have been added to your organization's Stellar Disbursement Platform as a {{.Role}}. Please click the link below to set up your password and let your organization administrator know if you have any questions.

-

- Set up my password -

-

Best regards,

-

The {{.OrganizationName}} Team

- - diff --git a/internal/htmltemplate/tmpl/pages/receiver_register.tmpl b/internal/htmltemplate/tmpl/pages/receiver_register.tmpl new file mode 100644 index 000000000..e242d59b5 --- /dev/null +++ b/internal/htmltemplate/tmpl/pages/receiver_register.tmpl @@ -0,0 +1,278 @@ + + + + + + Wallet Registration + + + + + + + + + + + + + + + + + +
+ +
+
+

Select Verification Method

+ +

Please choose how you would like to receive your verification code.

+

⚠️ Attention, it needs to match the form of contact where you received your invitation to receive this payment.

+ +
+
+ + +
+ +
+ +
+
+
+
+ + +
+
+

Enter your email address to get verified

+ +

+ Enter your email address below. If you are pre-approved, you will + receive a one-time passcode. +

+ +
+
+ + +
+ +
+ +
+ +
+
+
+
+ + +
+
+

Enter your phone number to get verified

+ +

+ Enter your phone number below. If you are pre-approved, you will + receive a one-time passcode. +

+ +
+
+ + +
+ +
+ +
+ +
+
+
+ + +
+ + +
+
+

Enter passcode

+ +

+ If you are pre-approved, you will receive a one-time passcode, enter + it below to continue. +

+ +

+ + Do not share your OTP or verification data with anyone. People who + ask for this information could be trying to access your account. + +

+ +
+
+ + +
+
+ + + +
+ +
+ + +
+ +
+ + + +
+ + + + + + + +
+ + + + + + + + diff --git a/internal/htmltemplate/tmpl/receiver_registered_successfully.tmpl b/internal/htmltemplate/tmpl/pages/receiver_registered_successfully.tmpl similarity index 92% rename from internal/htmltemplate/tmpl/receiver_registered_successfully.tmpl rename to internal/htmltemplate/tmpl/pages/receiver_registered_successfully.tmpl index c3c26ab3e..c09704008 100644 --- a/internal/htmltemplate/tmpl/receiver_registered_successfully.tmpl +++ b/internal/htmltemplate/tmpl/pages/receiver_registered_successfully.tmpl @@ -23,6 +23,10 @@

Your information has been successfully verified!

+

+ Your account was verified using the following contact information: + {{.TruncatedContactInfo}} +

Click the button below to be taken back to home and receive your disbursement. diff --git a/internal/htmltemplate/tmpl/receiver_register.tmpl b/internal/htmltemplate/tmpl/receiver_register.tmpl deleted file mode 100644 index b92c4140b..000000000 --- a/internal/htmltemplate/tmpl/receiver_register.tmpl +++ /dev/null @@ -1,246 +0,0 @@ - - - - - - Wallet Registration - - - - - - - - - - - - - - - - - -

- -
-
-

Enter your phone number to get verified

- -

- Enter your phone number below. If you are pre-approved, you will - receive a one-time passcode. -

- -
-
- - -
- -
- -
- -
-
-
- - - - - -
- - -
-
-

Enter passcode

- -

- If you are pre-approved, you will receive a one-time passcode, enter - it below to continue. -

- -

- - Do not share your OTP or verification data with anyone. People who - ask for this information could be trying to access your account. - -

- -
-
- - -
-
- - - -
- -
- - -
- -
- - - - - - - -
- - - {{.JWTToken}} -
- - - - - - - - diff --git a/internal/integrationtests/.env.example b/internal/integrationtests/.env.example index 3ca6d30a5..9d5a24003 100644 --- a/internal/integrationtests/.env.example +++ b/internal/integrationtests/.env.example @@ -4,4 +4,8 @@ SEP10_SIGNING_PRIVATE_KEY= # Generate a new keypair for the distribution account DISTRIBUTION_PUBLIC_KEY= -DISTRIBUTION_SEED= \ No newline at end of file +DISTRIBUTION_SEED= + +# Circle API key +CIRCLE_API_KEY= +CIRCLE_USDC_WALLET_ID= \ No newline at end of file diff --git a/internal/integrationtests/docker/docker-compose-e2e-tests.yml b/internal/integrationtests/docker/docker-compose-e2e-tests.yml index 11f5e81c8..1c2f9e592 100644 --- a/internal/integrationtests/docker/docker-compose-e2e-tests.yml +++ b/internal/integrationtests/docker/docker-compose-e2e-tests.yml @@ -36,7 +36,6 @@ services: SEP10_SIGNING_PUBLIC_KEY: ${SEP10_SIGNING_PUBLIC_KEY} ANCHOR_PLATFORM_BASE_SEP_URL: http://anchor-platform:8080 ANCHOR_PLATFORM_BASE_PLATFORM_URL: http://anchor-platform:8085 - DISTRIBUTION_ACCOUNT_TYPE: ${DISTRIBUTION_ACCOUNT_TYPE} DISTRIBUTION_PUBLIC_KEY: ${DISTRIBUTION_PUBLIC_KEY} RECAPTCHA_SITE_KEY: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI CORS_ALLOWED_ORIGINS: "*" @@ -65,8 +64,10 @@ services: DISBURSED_ASSET_ISSUER: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 RECEIVER_ACCOUNT_PUBLIC_KEY: GCDYFAJSZPH3RCXL6NWMMOY54CXNUBYFTDCBW7GGG6VPBW3WSDKSB2NU RECEIVER_ACCOUNT_PRIVATE_KEY: SDSAVUWVNOFG2JEHKIWEUHAYIA6PLGEHLMHX2TMVKEQGZKOFQ7XXKDFE + DISTRIBUTION_ACCOUNT_TYPE: ${DISTRIBUTION_ACCOUNT_TYPE} DISBURSEMENT_CSV_FILE_PATH: resources - DISBURSEMENT_CSV_FILE_NAME: disbursement_integration_tests.csv + DISBURSEMENT_CSV_FILE_NAME: ${DISBURSEMENT_CSV_FILE_NAME} + REGISTRATION_CONTACT_TYPE: ${REGISTRATION_CONTACT_TYPE} SERVER_API_BASE_URL: http://localhost:8000 ADMIN_SERVER_BASE_URL: http://localhost:8003 ADMIN_SERVER_ACCOUNT_ID: SDP-admin @@ -209,7 +210,7 @@ services: "sep24_enabled": true, "schema": "stellar", "code": "USDC", - "issuer": "GDKLFXO3FL25I7ST632KMMBP5D72QGTDV55TOWUB2XG2O67NNQDKYMLG", + "issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", "distribution_account": "${DISTRIBUTION_PUBLIC_KEY}", "significant_decimals": 7, "deposit": { diff --git a/internal/integrationtests/integration_tests.go b/internal/integrationtests/integration_tests.go index db209500c..6f0806b8e 100644 --- a/internal/integrationtests/integration_tests.go +++ b/internal/integrationtests/integration_tests.go @@ -14,14 +14,11 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httphandler" - tss "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) -const ( - paymentProcessTimeSeconds = 30 -) +const paymentProcessTimeSeconds = 30 type IntegrationTestsInterface interface { StartIntegrationTests(ctx context.Context, opts IntegrationTestsOpts) error @@ -33,6 +30,7 @@ type IntegrationTestsOpts struct { TenantName string UserEmail string UserPassword string + RegistrationContactType data.RegistrationContactType DistributionAccountType string DisbursedAssetCode string DisbursetAssetIssuer string @@ -116,7 +114,7 @@ func NewIntegrationTestsService(opts IntegrationTestsOpts) (*IntegrationTestsSer return it, nil } -func (it *IntegrationTestsService) initServices(ctx context.Context, opts IntegrationTestsOpts) { +func (it *IntegrationTestsService) initServices(_ context.Context, opts IntegrationTestsOpts) { // initialize default testnet horizon client it.horizonClient = horizonclient.DefaultTestNetClient @@ -144,7 +142,7 @@ func (it *IntegrationTestsService) initServices(ctx context.Context, opts Integr } func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, opts IntegrationTestsOpts) error { - log.Ctx(ctx).Info("Starting integration tests ......") + log.Ctx(ctx).Info("Starting integration tests......") it.initServices(context.Background(), opts) log.Ctx(ctx).Infof("Resolving tenant %s from database and adding it to context", opts.TenantName) @@ -159,92 +157,114 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op if err != nil { return fmt.Errorf("trying to login in server API: %w", err) } - log.Ctx(ctx).Info("User logged in") - log.Ctx(ctx).Info(authToken) + log.Ctx(ctx).Infof("User logged in with server API auth token %q", authToken) - log.Ctx(ctx).Info("Getting test asset in database") asset, err := it.models.Assets.GetByCodeAndIssuer(ctx, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer) if err != nil { return fmt.Errorf("getting test asset: %w", err) } - log.Ctx(ctx).Info("Getting test wallet in database") - wallet, err := it.models.Wallets.GetByWalletName(ctx, opts.WalletName) + disbursement, err := it.createAndValidateDisbursement(ctx, opts, authToken, asset) if err != nil { - return fmt.Errorf("getting test wallet: %w", err) + return fmt.Errorf("creating and validating disbursement: %w", err) + } + + if err = it.registerReceiverWalletIfNeeded(ctx, opts, disbursement); err != nil { + return fmt.Errorf("registering receiver wallet if needed: %w", err) + } + + if err = it.ensureTransactionCompletion(ctx, opts, disbursement); err != nil { + return err } - log.Ctx(ctx).Info("Creating disbursement using server API") + log.Ctx(ctx).Info("🎉🎉🎉 Successfully finished integration tests! The disbursement was delivered to the recipient! 🎉🎉🎉") + + return nil +} + +// createAndValidateDisbursement is a function that creates a disbursement and validates it. +func (it *IntegrationTestsService) createAndValidateDisbursement(ctx context.Context, opts IntegrationTestsOpts, authToken *ServerApiAuthToken, asset *data.Asset) (*data.Disbursement, error) { + var ( + verificationField data.VerificationType + walletID string + ) + if !opts.RegistrationContactType.IncludesWalletAddress { + log.Ctx(ctx).Infof("Getting test wallet in database...") + verificationField = data.VerificationTypeDateOfBirth + wallet, err := it.models.Wallets.GetByWalletName(ctx, opts.WalletName) + if err != nil { + return nil, fmt.Errorf("getting test wallet: %w", err) + } + walletID = wallet.ID + } + + log.Ctx(ctx).Info("Creating disbursement using server API...") disbursement, err := it.serverAPI.CreateDisbursement(ctx, authToken, &httphandler.PostDisbursementRequest{ - Name: opts.DisbursementName, - CountryCode: "USA", - WalletID: wallet.ID, - AssetID: asset.ID, - VerificationField: data.VerificationFieldDateOfBirth, + Name: opts.DisbursementName, + WalletID: walletID, + AssetID: asset.ID, + VerificationField: verificationField, + RegistrationContactType: opts.RegistrationContactType, }) if err != nil { - return fmt.Errorf("creating disbursement: %w", err) + return nil, fmt.Errorf("creating disbursement: %w", err) } - log.Ctx(ctx).Info("Disbursement created") - log.Ctx(ctx).Info("Processing disbursement CSV file using server API") - err = it.serverAPI.ProcessDisbursement(ctx, authToken, disbursement.ID) - if err != nil { - return fmt.Errorf("processing disbursement: %w", err) + log.Ctx(ctx).Info("Processing disbursement CSV file using server API...") + if err = it.serverAPI.ProcessDisbursement(ctx, authToken, disbursement.ID); err != nil { + return nil, fmt.Errorf("processing disbursement: %w", err) } - log.Ctx(ctx).Info("CSV disbursement file processed") - log.Ctx(ctx).Info("Validating disbursement data after processing the disbursement file") - err = validateExpectationsAfterProcessDisbursement(ctx, disbursement.ID, it.models, it.mtnDbConnectionPool) - if err != nil { - return fmt.Errorf("validating data after process disbursement: %w", err) + log.Ctx(ctx).Info("Validating disbursement data after processing the disbursement file...") + if err = validateExpectationsAfterProcessDisbursement(ctx, disbursement.ID, it.models, it.mtnDbConnectionPool); err != nil { + return nil, fmt.Errorf("validating data after process disbursement: %w", err) } - log.Ctx(ctx).Info("Disbursement data validated") - log.Ctx(ctx).Info("Starting disbursement using server API") - err = it.serverAPI.StartDisbursement(ctx, authToken, disbursement.ID, &httphandler.PatchDisbursementStatusRequest{Status: "STARTED"}) - if err != nil { - return fmt.Errorf("starting disbursement: %w", err) + log.Ctx(ctx).Info("Starting disbursement using server API...") + if err = it.serverAPI.StartDisbursement(ctx, authToken, disbursement.ID, &httphandler.PatchDisbursementStatusRequest{Status: "STARTED"}); err != nil { + return nil, fmt.Errorf("starting disbursement: %w", err) } - log.Ctx(ctx).Info("Disbursement started") - log.Ctx(ctx).Info("Validating disbursement data after starting disbursement using server API") - err = validateExpectationsAfterStartDisbursement(ctx, disbursement.ID, it.models, it.mtnDbConnectionPool) - if err != nil { - return fmt.Errorf("validating data after process disbursement: %w", err) + log.Ctx(ctx).Info("Validating disbursement data after starting disbursement using server API...") + if err = validateExpectationsAfterStartDisbursement(ctx, disbursement.ID, it.models, it.mtnDbConnectionPool); err != nil { + return nil, fmt.Errorf("validating data after process disbursement: %w", err) + } + return disbursement, nil +} + +// registerReceiverWalletIfNeeded is a function that registers the receiver wallet through the SEP-24 flow if needed, +// i.e. if the registration contact type does not include the wallet address. +func (it *IntegrationTestsService) registerReceiverWalletIfNeeded(ctx context.Context, opts IntegrationTestsOpts, disbursement *data.Disbursement) error { + if disbursement.RegistrationContactType.IncludesWalletAddress { + log.Ctx(ctx).Infof("⏭ Skipping SEP-24 flow because registrationContactType=%q", disbursement.RegistrationContactType) + return nil } - log.Ctx(ctx).Info("Disbursement data validated") - log.Ctx(ctx).Info("Starting anchor platform integration ......") log.Ctx(ctx).Info("Starting challenge transaction on anchor platform") challengeTx, err := it.anchorPlatform.StartChallengeTransaction() if err != nil { return fmt.Errorf("creating SEP10 challenge transaction: %w", err) } - log.Ctx(ctx).Info("Challenge transaction created") log.Ctx(ctx).Info("Signing challenge transaction with Sep10SigningKey") signedTx, err := it.anchorPlatform.SignChallengeTransaction(challengeTx) if err != nil { return fmt.Errorf("signing SEP10 challenge transaction: %w", err) } - log.Ctx(ctx).Info("Challenge transaction signed") log.Ctx(ctx).Info("Sending challenge transaction to anchor platform") authSEP10Token, err := it.anchorPlatform.SendSignedChallengeTransaction(signedTx) if err != nil { return fmt.Errorf("sending SEP10 challenge transaction: %w", err) } - log.Ctx(ctx).Info("Received authSEP10Token") log.Ctx(ctx).Info("Creating SEP24 deposit transaction on anchor platform") authSEP24Token, _, err := it.anchorPlatform.CreateSep24DepositTransaction(authSEP10Token) if err != nil { return fmt.Errorf("creating SEP24 deposit transaction: %w", err) } - log.Ctx(ctx).Info("Received authSEP24Token") - disbursementData, err := readDisbursementCSV(opts.DisbursementCSVFilePath, opts.DisbursementCSVFileName) + disbursementInstructions, err := readDisbursementCSV(opts.DisbursementCSVFilePath, opts.DisbursementCSVFileName) if err != nil { return fmt.Errorf("reading disbursement CSV: %w", err) } @@ -252,61 +272,63 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op log.Ctx(ctx).Info("Completing receiver registration using server API") err = it.serverAPI.ReceiverRegistration(ctx, authSEP24Token, &data.ReceiverRegistrationRequest{ OTP: data.TestnetAlwaysValidOTP, - PhoneNumber: disbursementData[0].Phone, - VerificationValue: disbursementData[0].VerificationValue, - VerificationType: disbursement.VerificationField, + PhoneNumber: disbursementInstructions[0].Phone, + Email: disbursementInstructions[0].Email, + VerificationValue: disbursementInstructions[0].VerificationValue, + VerificationField: disbursement.VerificationField, ReCAPTCHAToken: opts.RecaptchaSiteKey, }) if err != nil { return fmt.Errorf("registring receiver: %w", err) } - log.Ctx(ctx).Info("Receiver OTP obtained") log.Ctx(ctx).Info("Validating receiver data after completing registration") err = validateExpectationsAfterReceiverRegistration(ctx, it.models, opts.ReceiverAccountPublicKey, opts.ReceiverAccountStellarMemo, opts.WalletSEP10Domain) if err != nil { return fmt.Errorf("validating receiver after registration: %w", err) } - log.Ctx(ctx).Info("Receiver data validated") - log.Ctx(ctx).Info("Waiting for payment to be processed by TSS") - time.Sleep(paymentProcessTimeSeconds * time.Second) + return nil +} + +// ensureTransactionCompletion is a function that ensures the transaction completion by waiting for the payment to be +// processed by TSS or Circle, and then ensuring the transaction is present on the Stellar network. +func (it *IntegrationTestsService) ensureTransactionCompletion(ctx context.Context, opts IntegrationTestsOpts, disbursement *data.Disbursement) error { + log.Ctx(ctx).Info("Waiting for payment to be processed...") + time.Sleep(paymentProcessTimeSeconds * time.Second) // wait for payment to be processed by TSS or Circle - log.Ctx(ctx).Info("Querying database to get disbursement receiver with payment data") receivers, err := it.models.DisbursementReceivers.GetAll(ctx, it.mtnDbConnectionPool, &data.QueryParams{}, disbursement.ID) if err != nil { return fmt.Errorf("getting receivers: %w", err) } - if schema.AccountType(opts.DistributionAccountType).IsStellar() { - payment := receivers[0].Payment - q := `SELECT * FROM submitter_transactions WHERE external_id = $1` - var tx tss.Transaction - err = it.tssDbConnectionPool.GetContext(ctx, &tx, q, payment.ID) - if err != nil { - return fmt.Errorf("getting TSS transaction from database: %w", err) - } - log.Ctx(ctx).Infof("TSS transaction: %+v", tx) + payment := receivers[0].Payment - log.Ctx(ctx).Info("Getting payment from disbursement receiver") - if payment.Status != data.SuccessPaymentStatus || payment.StellarTransactionID == "" { - return fmt.Errorf("payment was not processed successfully by TSS: %+v", payment) - } + log.Ctx(ctx).Info("Getting payment from disbursement receiver...") + if payment.Status != data.SuccessPaymentStatus || payment.StellarTransactionID == "" { + return fmt.Errorf("payment was not processed successfully by TSS: %+v", payment) + } - log.Ctx(ctx).Info("Payment was successfully updated by the TSS") - log.Ctx(ctx).Info("Validating transaction on Horizon Network") - ph, getPaymentErr := getTransactionOnHorizon(it.horizonClient, payment.StellarTransactionID) - if getPaymentErr != nil { - return fmt.Errorf("getting transaction on horizon network: %w", getPaymentErr) - } - err = validateStellarTransaction(ph, opts.ReceiverAccountPublicKey, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer, receivers[0].Payment.Amount) + log.Ctx(ctx).Info("Validating transaction on Stellar network...") + hPayment, getPaymentErr := getTransactionOnHorizon(it.horizonClient, payment.StellarTransactionID) + if getPaymentErr != nil { + return fmt.Errorf("getting transaction on Stellar network: %w", getPaymentErr) + } + + intendedPaymentDestination := opts.ReceiverAccountPublicKey + if disbursement.RegistrationContactType.IncludesWalletAddress { + var disbursementInstructions []*data.DisbursementInstruction + disbursementInstructions, err = readDisbursementCSV(opts.DisbursementCSVFilePath, opts.DisbursementCSVFileName) if err != nil { - return fmt.Errorf("validating stellar transaction: %w", err) + return fmt.Errorf("reading disbursement CSV in ensureTransactionCompletion: %w", err) } - log.Ctx(ctx).Info("Transaction validated") + intendedPaymentDestination = disbursementInstructions[0].WalletAddress } - - log.Ctx(ctx).Info("🎉🎉🎉Finishing integration tests, the receiver was successfully funded 🎉🎉🎉") + err = validateStellarTransaction(hPayment, intendedPaymentDestination, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer, receivers[0].Payment.Amount) + if err != nil { + return fmt.Errorf("validating stellar transaction: %w", err) + } + log.Ctx(ctx).Info("Transaction validated") return nil } diff --git a/internal/integrationtests/resources/disbursement_instructions_email.csv b/internal/integrationtests/resources/disbursement_instructions_email.csv new file mode 100644 index 000000000..e42b1f17c --- /dev/null +++ b/internal/integrationtests/resources/disbursement_instructions_email.csv @@ -0,0 +1,2 @@ +email,id,amount,verification +foobar@test.com,1,0.1,1999-03-30 \ No newline at end of file diff --git a/internal/integrationtests/resources/disbursement_integration_tests.csv b/internal/integrationtests/resources/disbursement_instructions_phone.csv similarity index 100% rename from internal/integrationtests/resources/disbursement_integration_tests.csv rename to internal/integrationtests/resources/disbursement_instructions_phone.csv diff --git a/internal/integrationtests/resources/disbursement_instructions_phone_with_wallet.csv b/internal/integrationtests/resources/disbursement_instructions_phone_with_wallet.csv new file mode 100644 index 000000000..95560c146 --- /dev/null +++ b/internal/integrationtests/resources/disbursement_instructions_phone_with_wallet.csv @@ -0,0 +1,2 @@ +phone,id,amount,walletAddress ++12025550191,1,0.1,GATPASVBX7M45OFONIJ657JWNRSYS3ZYK3AICSALNKM7ENHDHT4MFTGZ \ No newline at end of file diff --git a/internal/integrationtests/scripts/e2e_integration_test.sh b/internal/integrationtests/scripts/e2e_integration_test.sh index 444d7decf..2907aa460 100755 --- a/internal/integrationtests/scripts/e2e_integration_test.sh +++ b/internal/integrationtests/scripts/e2e_integration_test.sh @@ -21,17 +21,31 @@ wait_for_server() { echo "Server at $endpoint is up." } -accountTypes=("DISTRIBUTION_ACCOUNT.STELLAR.ENV" "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT") -for accountType in "${accountTypes[@]}"; do - export DISTRIBUTION_ACCOUNT_TYPE=$accountType - if [ $accountType="DISTRIBUTION_ACCOUNT.STELLAR.ENV" ] - then - platform="Stellar" - else - platform="Circle" - fi +options=( + "platform=Stellar;DISTRIBUTION_ACCOUNT_TYPE=DISTRIBUTION_ACCOUNT.STELLAR.ENV;DISBURSEMENT_CSV_FILE_NAME=disbursement_instructions_phone.csv;REGISTRATION_CONTACT_TYPE=PHONE_NUMBER" + "platform=Circle;DISTRIBUTION_ACCOUNT_TYPE=DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT;DISBURSEMENT_CSV_FILE_NAME=disbursement_instructions_phone.csv;REGISTRATION_CONTACT_TYPE=PHONE_NUMBER" + "platform=Stellar;DISTRIBUTION_ACCOUNT_TYPE=DISTRIBUTION_ACCOUNT.STELLAR.ENV;DISBURSEMENT_CSV_FILE_NAME=disbursement_instructions_email.csv;REGISTRATION_CONTACT_TYPE=EMAIL" + "platform=Stellar;DISTRIBUTION_ACCOUNT_TYPE=DISTRIBUTION_ACCOUNT.STELLAR.ENV;DISBURSEMENT_CSV_FILE_NAME=disbursement_instructions_phone_with_wallet.csv;REGISTRATION_CONTACT_TYPE=PHONE_NUMBER_AND_WALLET_ADDRESS" +) + +for option in "${options[@]}"; do + # Parse the properties in the option + IFS=';' read -r -a properties <<< "$option" + + for property in "${properties[@]}"; do + # Split each property into key and value + IFS='=' read -r key value <<< "$property" + export "$key"="$value" + done + + # Example of using the exported variables + export DESCRIPTION="$platform - $DISTRIBUTION_ACCOUNT_TYPE - $REGISTRATION_CONTACT_TYPE" + echo -e "\n====> 👀Starting e2e setup and integration test ($DESCRIPTION)" + echo -e "\t- Platform: $platform" + echo -e "\t- DISTRIBUTION_ACCOUNT_TYPE: $DISTRIBUTION_ACCOUNT_TYPE" + echo -e "\t- DISBURSEMENT_CSV_FILE_NAME: $DISBURSEMENT_CSV_FILE_NAME" + echo -e "\t- REGISTRATION_CONTACT_TYPE: $REGISTRATION_CONTACT_TYPE" - echo "====> 👀Starting e2e setup and integration test ($platform)" echo $DIVIDER echo "====> 👀Step 1: start preparation" docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && @@ -40,7 +54,7 @@ for accountType in "${accountTypes[@]}"; do # Run docker compose echo $DIVIDER - echo "====> 👀Step 2: build sdp-api, anchor-platform and tss" + echo "====> 👀Step 2: build sdp-api, anchor-platform and tss ($DESCRIPTION)" docker compose -f ../docker/docker-compose-e2e-tests.yml up --build -d wait_for_server "http://localhost:8000/health" 20 echo "====> ✅Step 2: finishing build" @@ -49,11 +63,11 @@ for accountType in "${accountTypes[@]}"; do echo $DIVIDER echo "====> 👀Step 3: provision new tenant and populate new asset and test wallet on database" docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests create-data" - echo "====> ✅Step 3: finish creating integration test data ($platform)" + echo "====> ✅Step 3: finish creating integration test data ($DESCRIPTION)" # Restart anchor platform container echo $DIVIDER - echo "====> 👀Step 4: restart anchor platform container to get the new created asset" + echo "====> 👀Step 4: restart anchor platform container so the new created asset shows up in the toml file" docker restart e2e-anchor-platform echo "waiting for anchor platform to initialize" wait_for_server "http://localhost:8080/health" 120 @@ -64,7 +78,7 @@ for accountType in "${accountTypes[@]}"; do echo $DIVIDER echo "====> 👀Step 5: run integration tests command" docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests start" - echo "====> ✅Step 5: finish running integration test data ($platform)" + echo "====> ✅Step 5: finish running integration test data ($DESCRIPTION)" # Cleanup container and volumes echo $DIVIDER diff --git a/internal/integrationtests/server_api.go b/internal/integrationtests/server_api.go index f36ab72a5..88083db89 100644 --- a/internal/integrationtests/server_api.go +++ b/internal/integrationtests/server_api.go @@ -118,7 +118,7 @@ func (sa *ServerApiIntegrationTests) CreateDisbursement(ctx context.Context, aut if resp.StatusCode/100 != 2 { logErrorResponses(ctx, resp.Body) - return nil, fmt.Errorf("error trying to create a new disbursement on the server API") + return nil, fmt.Errorf("trying to create a new disbursement on the server API") } disbursement := &data.Disbursement{} diff --git a/internal/integrationtests/server_api_test.go b/internal/integrationtests/server_api_test.go index 75815ac82..495878680 100644 --- a/internal/integrationtests/server_api_test.go +++ b/internal/integrationtests/server_api_test.go @@ -103,10 +103,9 @@ func Test_CreateDisbursement(t *testing.T) { } reqBody := &httphandler.PostDisbursementRequest{ - Name: "mockDisbursement", - CountryCode: "USA", - WalletID: "123", - AssetID: "890", + Name: "mockDisbursement", + WalletID: "123", + AssetID: "890", } t.Run("error calling httpClient.Do", func(t *testing.T) { @@ -127,7 +126,7 @@ func Test_CreateDisbursement(t *testing.T) { httpClientMock.On("Do", mock.AnythingOfType("*http.Request")).Return(response, nil).Once() d, err := sa.CreateDisbursement(ctx, authToken, reqBody) - require.EqualError(t, err, "error trying to create a new disbursement on the server API") + require.EqualError(t, err, "trying to create a new disbursement on the server API") assert.Empty(t, d) httpClientMock.AssertExpectations(t) @@ -179,7 +178,7 @@ func Test_ProcessDisbursement(t *testing.T) { HttpClient: &httpClientMock, ServerApiBaseURL: "http://mock_server.com/", DisbursementCSVFilePath: "resources", - DisbursementCSVFileName: "disbursement_integration_tests.csv", + DisbursementCSVFileName: "disbursement_instructions_phone.csv", } ctx := context.Background() @@ -307,7 +306,7 @@ func Test_ReceiverRegistration(t *testing.T) { PhoneNumber: "+18554212274", OTP: "123456", VerificationValue: "1999-01-30", - VerificationType: "date_of_birth", + VerificationField: "date_of_birth", ReCAPTCHAToken: "captchtoken", } diff --git a/internal/integrationtests/utils.go b/internal/integrationtests/utils.go index 4be1232f3..e5c748148 100644 --- a/internal/integrationtests/utils.go +++ b/internal/integrationtests/utils.go @@ -13,27 +13,34 @@ import ( "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) // logErrorResponses logs the response body for requests with error status. func logErrorResponses(ctx context.Context, body io.ReadCloser) { respBody, err := io.ReadAll(body) + defer body.Close() if err == nil { log.Ctx(ctx).Infof("error message response: %s", string(respBody)) } } func readDisbursementCSV(disbursementFilePath string, disbursementFileName string) ([]*data.DisbursementInstruction, error) { + err := utils.ValidatePathIsNotTraversal(disbursementFileName) + if err != nil { + return nil, fmt.Errorf("validating file path: %w", err) + } + filePath := path.Join(disbursementFilePath, disbursementFileName) csvBytes, err := fs.ReadFile(DisbursementCSVFiles, filePath) if err != nil { - return nil, fmt.Errorf("error reading csv file: %w", err) + return nil, fmt.Errorf("reading csv file: %w", err) } instructions := []*data.DisbursementInstruction{} if err = gocsv.UnmarshalBytes(csvBytes, &instructions); err != nil { - return nil, fmt.Errorf("error parsing csv file: %w", err) + return nil, fmt.Errorf("parsing csv file: %w", err) } return instructions, nil diff --git a/internal/integrationtests/utils_test.go b/internal/integrationtests/utils_test.go index 2c5c97a18..784f046ac 100644 --- a/internal/integrationtests/utils_test.go +++ b/internal/integrationtests/utils_test.go @@ -36,9 +36,17 @@ func Test_logErrorResponses(t *testing.T) { } func Test_readDisbursementCSV(t *testing.T) { + t.Run("error if file path is traversal", func(t *testing.T) { + expectedError := "validating file path: path cannot contain path traversal" + + data, err := readDisbursementCSV("resources", "../invalid_traversal_path.csv") + require.EqualError(t, err, expectedError) + assert.Empty(t, data) + }) + t.Run("error trying read csv file", func(t *testing.T) { filePath := path.Join("resources", "invalid_file.csv") - expectedError := fmt.Sprintf("error reading csv file: open %s: file does not exist", filePath) + expectedError := fmt.Sprintf("reading csv file: open %s: file does not exist", filePath) data, err := readDisbursementCSV("resources", "invalid_file.csv") require.EqualError(t, err, expectedError) @@ -47,17 +55,18 @@ func Test_readDisbursementCSV(t *testing.T) { t.Run("error opening empty csv file", func(t *testing.T) { data, err := readDisbursementCSV("resources", "empty_csv_file.csv") - require.EqualError(t, err, "error parsing csv file: empty csv file given") + require.EqualError(t, err, "parsing csv file: empty csv file given") assert.Empty(t, data) }) t.Run("reading csv file", func(t *testing.T) { - data, err := readDisbursementCSV("resources", "disbursement_integration_tests.csv") + data, err := readDisbursementCSV("resources", "disbursement_instructions_phone.csv") require.NoError(t, err) - assert.Equal(t, data[0].Amount, "0.1") - assert.Equal(t, data[0].Phone, "+12025550191") - assert.Equal(t, data[0].ID, "1") - assert.Equal(t, data[0].VerificationValue, "1999-03-30") + assert.Equal(t, "0.1", data[0].Amount) + assert.NotNil(t, data[0].Phone) + assert.Equal(t, "+12025550191", data[0].Phone) + assert.Equal(t, "1", data[0].ID) + assert.Equal(t, "1999-03-30", data[0].VerificationValue) }) } diff --git a/internal/integrationtests/validations.go b/internal/integrationtests/validations.go index 1034c90de..ff376bfbf 100644 --- a/internal/integrationtests/validations.go +++ b/internal/integrationtests/validations.go @@ -26,15 +26,23 @@ func validateExpectationsAfterProcessDisbursement(ctx context.Context, disbursem if len(receivers) <= 0 { return fmt.Errorf("error getting receivers from disbursement: receivers not found") } - receiver := receivers[0] - // TODO upgrade this function to validate multiples receiver wallets and payments. - if receiver.ReceiverWallet.Status != data.DraftReceiversWalletStatus { - return fmt.Errorf("invalid status for receiver_wallet after process disbursement") - } + for _, receiver := range receivers { + // Validate receiver_wallet status + expectedStatusByRegistrationContactType := map[data.RegistrationContactType]data.ReceiversWalletStatus{ + data.RegistrationContactTypePhone: data.DraftReceiversWalletStatus, + data.RegistrationContactTypeEmail: data.DraftReceiversWalletStatus, + data.RegistrationContactTypePhoneAndWalletAddress: data.RegisteredReceiversWalletStatus, + data.RegistrationContactTypeEmailAndWalletAddress: data.RegisteredReceiversWalletStatus, + } + if expectedStatus := expectedStatusByRegistrationContactType[disbursement.RegistrationContactType]; expectedStatus != receiver.ReceiverWallet.Status { + return fmt.Errorf("receiver_wallet should be in %s status for registrationContactType %s", expectedStatus, disbursement.RegistrationContactType) + } - if receiver.Payment.Status != data.DraftPaymentStatus { - return fmt.Errorf("invalid status for payment after process disbursement") + // Validate payment status + if receiver.Payment.Status != data.DraftPaymentStatus { + return fmt.Errorf("invalid status for payment after process disbursement") + } } return nil @@ -58,15 +66,23 @@ func validateExpectationsAfterStartDisbursement(ctx context.Context, disbursemen return fmt.Errorf("error getting receivers from disbursement: receivers not found") } - receiver := receivers[0] + for _, receiver := range receivers { - // TODO upgrade this function to validate multiples receiver wallets and payments. - if receiver.ReceiverWallet.Status != data.ReadyReceiversWalletStatus { - return fmt.Errorf("invalid status for receiver_wallet after start disbursement") - } + // Validate receiver_wallet status + expectedStatusByRegistrationContactType := map[data.RegistrationContactType]data.ReceiversWalletStatus{ + data.RegistrationContactTypePhone: data.ReadyReceiversWalletStatus, + data.RegistrationContactTypeEmail: data.ReadyReceiversWalletStatus, + data.RegistrationContactTypePhoneAndWalletAddress: data.RegisteredReceiversWalletStatus, + data.RegistrationContactTypeEmailAndWalletAddress: data.RegisteredReceiversWalletStatus, + } + if expectedStatus := expectedStatusByRegistrationContactType[disbursement.RegistrationContactType]; expectedStatus != receiver.ReceiverWallet.Status { + return fmt.Errorf("receiver_wallet should be in %s status for registrationContactType %s", expectedStatus, disbursement.RegistrationContactType) + } - if receiver.Payment.Status != data.ReadyPaymentStatus { - return fmt.Errorf("invalid status for payment after start disbursement") + // Validate payment status + if receiver.Payment.Status != data.ReadyPaymentStatus { + return fmt.Errorf("invalid status for payment after start disbursement") + } } return nil diff --git a/internal/integrationtests/validations_test.go b/internal/integrationtests/validations_test.go index c94e76f2a..39d141503 100644 --- a/internal/integrationtests/validations_test.go +++ b/internal/integrationtests/validations_test.go @@ -30,16 +30,15 @@ func Test_validationAfterProcessDisbursement(t *testing.T) { }) asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") t.Run("invalid disbursement status", func(t *testing.T) { invalidDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "Invalid Disbursement", - Status: data.CompletedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "Invalid Disbursement", + Status: data.CompletedDisbursementStatus, + Asset: asset, + Wallet: wallet, + RegistrationContactType: data.RegistrationContactTypePhone, }) err = validateExpectationsAfterProcessDisbursement(ctx, invalidDisbursement.ID, models, dbConnectionPool) @@ -47,11 +46,11 @@ func Test_validationAfterProcessDisbursement(t *testing.T) { }) disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.ReadyDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: data.ReadyDisbursementStatus, + Asset: asset, + Wallet: wallet, + RegistrationContactType: data.RegistrationContactTypePhone, }) t.Run("disbursement receivers not found", func(t *testing.T) { @@ -72,7 +71,7 @@ func Test_validationAfterProcessDisbursement(t *testing.T) { }) err = validateExpectationsAfterProcessDisbursement(ctx, disbursement.ID, models, dbConnectionPool) - require.EqualError(t, err, "invalid status for receiver_wallet after process disbursement") + require.EqualError(t, err, "receiver_wallet should be in DRAFT status for registrationContactType "+data.RegistrationContactTypePhone.String()) }) t.Run("invalid payment status", func(t *testing.T) { @@ -135,16 +134,15 @@ func Test_validationAfterStartDisbursement(t *testing.T) { }) asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") t.Run("invalid disbursement status", func(t *testing.T) { invalidDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "Invalid Disbursement", - Status: data.CompletedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "Invalid Disbursement", + Status: data.CompletedDisbursementStatus, + Asset: asset, + Wallet: wallet, + RegistrationContactType: data.RegistrationContactTypePhone, }) err = validateExpectationsAfterStartDisbursement(ctx, invalidDisbursement.ID, models, dbConnectionPool) @@ -152,11 +150,11 @@ func Test_validationAfterStartDisbursement(t *testing.T) { }) disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.StartedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: data.StartedDisbursementStatus, + Asset: asset, + Wallet: wallet, + RegistrationContactType: data.RegistrationContactTypePhone, }) t.Run("disbursement receivers not found", func(t *testing.T) { @@ -177,7 +175,7 @@ func Test_validationAfterStartDisbursement(t *testing.T) { }) err = validateExpectationsAfterStartDisbursement(ctx, disbursement.ID, models, dbConnectionPool) - require.EqualError(t, err, "invalid status for receiver_wallet after start disbursement") + require.EqualError(t, err, "receiver_wallet should be in READY status for registrationContactType "+data.RegistrationContactTypePhone.String()) }) t.Run("invalid payment status", func(t *testing.T) { diff --git a/internal/message/aws_ses_client.go b/internal/message/aws_ses_client.go index cb0111689..3d127000f 100644 --- a/internal/message/aws_ses_client.go +++ b/internal/message/aws_ses_client.go @@ -2,6 +2,7 @@ package message import ( "fmt" + "html/template" "strings" "github.com/aws/aws-sdk-go/aws" @@ -51,9 +52,14 @@ func (a *awsSESClient) SendMessage(message Message) error { // generateAWSEmail generates the email object to send an email through AWS SES. func generateAWSEmail(message Message, sender string) (*ses.SendEmailInput, error) { - html, err := htmltemplate.ExecuteHTMLTemplateForEmailEmptyBody(htmltemplate.EmptyBodyEmailTemplate{Body: message.Message}) - if err != nil { - return nil, fmt.Errorf("generating html template: %w", err) + emailBody := message.Body + var err error + // If the email body does not contain an HTML tag, then it is considered as a plain text email: + if !strings.Contains(emailBody, "= 400 { + return fmt.Errorf("sendGrid API returned error status code= %d, body= %s", + response.StatusCode, response.Body) + } + + log.Debugf("🎉 SendGrid sent an email to the receiver %q", utils.TruncateString(message.ToEmail, 3)) + return nil +} + +// NewTwilioSendGridClient creates a new SendGrid client that is used to send emails +func NewTwilioSendGridClient(apiKey string, senderAddress string) (MessengerClient, error) { + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return nil, fmt.Errorf("sendGrid API key is empty") + } + + senderAddress = strings.TrimSpace(senderAddress) + if err := utils.ValidateEmail(senderAddress); err != nil { + return nil, fmt.Errorf("sendGrid senderAddress is invalid: %w", err) + } + + return &twilioSendGridClient{ + client: sendgrid.NewSendClient(apiKey), + senderAddress: senderAddress, + }, nil +} + +var _ MessengerClient = (*twilioSendGridClient)(nil) diff --git a/internal/message/twilio_sendgrid_client_test.go b/internal/message/twilio_sendgrid_client_test.go new file mode 100644 index 000000000..507d0831c --- /dev/null +++ b/internal/message/twilio_sendgrid_client_test.go @@ -0,0 +1,203 @@ +package message + +import ( + "fmt" + "strings" + "testing" + + "github.com/sendgrid/rest" + "github.com/sendgrid/sendgrid-go/helpers/mail" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func (m *mockTwilioSendGridClient) Send(email *mail.SGMailV3) (*rest.Response, error) { + args := m.Called(email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*rest.Response), args.Error(1) +} + +func Test_NewTwilioSendGridClient(t *testing.T) { + testCases := []struct { + name string + apiKey string + senderAddress string + wantErr error + }{ + { + name: "apiKey cannot be empty", + wantErr: fmt.Errorf("sendGrid API key is empty"), + }, + { + name: "senderAddress needs to be a valid email", + apiKey: "api-key", + senderAddress: "invalid-email", + wantErr: fmt.Errorf("sendGrid senderAddress is invalid: the provided email is not valid"), + }, + { + name: "all fields are present", + apiKey: "api-key", + senderAddress: "foo@stellar.org", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewTwilioSendGridClient(tc.apiKey, tc.senderAddress) + if tc.wantErr != nil { + assert.EqualError(t, err, tc.wantErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_TwilioSendGridClient_SendMessage_messageIsInvalid(t *testing.T) { + var mSendGrid MessengerClient = &twilioSendGridClient{} + err := mSendGrid.SendMessage(Message{}) + assert.EqualError(t, err, "validating message to send an email through SendGrid: invalid e-mail: invalid email format: email cannot be empty") +} + +func Test_TwilioSendGridClient_SendMessage_errorIsHandledCorrectly(t *testing.T) { + message := Message{ToEmail: "foo@stellar.org", Title: "test title", Body: "foo bar"} + + mSendGrid := newMockTwilioSendGridClient(t) + + // MatchBy is used to match the email that is being sent + mSendGrid.On("Send", mock.MatchedBy(func(email *mail.SGMailV3) bool { + // Verify the email content matches what we expect + return email.From.Address == "sender@stellar.org" && + email.Subject == message.Title && + len(email.Personalizations) == 1 && + len(email.Personalizations[0].To) == 1 && + email.Personalizations[0].To[0].Address == message.ToEmail + })).Return(nil, fmt.Errorf("test SendGrid error")).Once() + + client := &twilioSendGridClient{ + client: mSendGrid, + senderAddress: "sender@stellar.org", + } + + err := client.SendMessage(message) + assert.EqualError(t, err, "sending SendGrid email: test SendGrid error") +} + +func Test_TwilioSendGridClient_SendMessage_handlesAPIError(t *testing.T) { + message := Message{ToEmail: "foo@stellar.org", Title: "test title", Body: "foo bar"} + + mSendGrid := newMockTwilioSendGridClient(t) + + mSendGrid.On("Send", mock.MatchedBy(func(email *mail.SGMailV3) bool { + return email.From.Address == "sender@stellar.org" && + email.Subject == message.Title + })).Return(&rest.Response{ + StatusCode: 400, + Body: "Bad Request", + }, nil).Once() + + client := &twilioSendGridClient{ + client: mSendGrid, + senderAddress: "sender@stellar.org", + } + + err := client.SendMessage(message) + assert.EqualError(t, err, "sendGrid API returned error status code= 400, body= Bad Request") +} + +func Test_TwilioSendGrid_SendMessage_success(t *testing.T) { + message := Message{ToEmail: "foo@stellar.org", Title: "test title", Body: "foo bar"} + + mSendGrid := newMockTwilioSendGridClient(t) + + successResponse := &rest.Response{ + StatusCode: 202, + Body: "Accepted", + } + + mSendGrid.On("Send", mock.MatchedBy(func(email *mail.SGMailV3) bool { + // Verify plain text was converted to HTML + expectedHTML := ` + + + + + + + + +foo bar + + +` + expectedHTML = strings.TrimSpace(expectedHTML) + + gotContent := email.Content[0].Value + gotContent = strings.TrimSpace(gotContent) + + return email.From.Address == "sender@stellar.org" && + email.Subject == message.Title && + len(email.Personalizations) == 1 && + len(email.Personalizations[0].To) == 1 && + email.Personalizations[0].To[0].Address == message.ToEmail && + gotContent == expectedHTML + })).Return(successResponse, nil).Once() + + client := &twilioSendGridClient{ + client: mSendGrid, + senderAddress: "sender@stellar.org", + } + + err := client.SendMessage(message) + assert.NoError(t, err) +} + +func Test_TwilioSendGrid_SendMessage_withHTMLContent(t *testing.T) { + htmlContent := "

Hello

" + message := Message{ToEmail: "foo@stellar.org", Title: "test title", Body: htmlContent} + + mSendGrid := newMockTwilioSendGridClient(t) + + successResponse := &rest.Response{ + StatusCode: 202, + Body: "Accepted", + } + + mSendGrid.On("Send", mock.MatchedBy(func(email *mail.SGMailV3) bool { + gotContent := email.Content[0].Value + gotContent = strings.TrimSpace(gotContent) + + return email.From.Address == "sender@stellar.org" && + email.Subject == message.Title && + gotContent == htmlContent // Should use original HTML content + })).Return(successResponse, nil).Once() + + client := &twilioSendGridClient{ + client: mSendGrid, + senderAddress: "sender@stellar.org", + } + + err := client.SendMessage(message) + assert.NoError(t, err) +} + +// mockTwilioSendGridClient implements twilioSendGridInterface for testing +type mockTwilioSendGridClient struct { + mock.Mock +} + +type testInterface interface { + mock.TestingT + Cleanup(func()) +} + +func newMockTwilioSendGridClient(t testInterface) *mockTwilioSendGridClient { + mock := &mockTwilioSendGridClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/monitor/main.go b/internal/monitor/main.go index 2df87c145..a79f8ce20 100644 --- a/internal/monitor/main.go +++ b/internal/monitor/main.go @@ -34,7 +34,7 @@ type MetricOptions struct { func GetClient(opts MetricOptions) (MonitorClient, error) { switch opts.MetricType { case MetricTypePrometheus: - return NewPrometheusClient() + return newPrometheusClient() case MetricTypeTSSPrometheus: return NewTSSPrometheusClient() default: diff --git a/internal/monitor/metric_tags.go b/internal/monitor/metric_tags.go index 4a71dca93..9d7ff2b6b 100644 --- a/internal/monitor/metric_tags.go +++ b/internal/monitor/metric_tags.go @@ -11,6 +11,9 @@ const ( // AnchorPlatformAuthProtection AnchorPlatformAuthProtectionEnsuredCounterTag MetricTag = "anchor_platform_auth_protection_ensured_counter" AnchorPlatformAuthProtectionMissingCounterTag MetricTag = "anchor_platform_auth_protection_missing_counter" + // Circle API Requests + CircleAPIRequestDurationTag MetricTag = "circle_api_request_duration_seconds" + CircleAPIRequestsTotalTag MetricTag = "circle_api_requests_total" ) func (m MetricTag) ListAll() []MetricTag { @@ -21,5 +24,7 @@ func (m MetricTag) ListAll() []MetricTag { DisbursementsCounterTag, AnchorPlatformAuthProtectionEnsuredCounterTag, AnchorPlatformAuthProtectionMissingCounterTag, + CircleAPIRequestDurationTag, + CircleAPIRequestsTotalTag, } } diff --git a/internal/monitor/monitor_labels.go b/internal/monitor/monitor_labels.go index 32f7c1f3d..f220549a1 100644 --- a/internal/monitor/monitor_labels.go +++ b/internal/monitor/monitor_labels.go @@ -11,15 +11,33 @@ type DBQueryLabels struct { } type DisbursementLabels struct { - Asset string - Country string - Wallet string + Asset string + Wallet string } func (d DisbursementLabels) ToMap() map[string]string { return map[string]string{ - "asset": d.Asset, - "country": d.Country, - "wallet": d.Wallet, + "asset": d.Asset, + "wallet": d.Wallet, } } + +type CircleLabels struct { + Method string + Endpoint string + Status string + StatusCode string + TenantName string +} + +func (c CircleLabels) ToMap() map[string]string { + return map[string]string{ + "method": c.Method, + "endpoint": c.Endpoint, + "status": c.Status, + "status_code": c.StatusCode, + "tenant_name": c.TenantName, + } +} + +var CircleLabelNames = []string{"method", "endpoint", "status", "status_code", "tenant_name"} diff --git a/internal/monitor/prometheus_client.go b/internal/monitor/prometheus_client.go index 9cca6260d..a7847f8aa 100644 --- a/internal/monitor/prometheus_client.go +++ b/internal/monitor/prometheus_client.go @@ -1,11 +1,11 @@ package monitor import ( - "fmt" "net/http" "time" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/stellar/go/support/log" ) @@ -14,7 +14,7 @@ type prometheusClient struct { httpHandler http.Handler } -func (prometheusClient) GetMetricType() MetricType { +func (p *prometheusClient) GetMetricType() MetricType { return MetricTypePrometheus } @@ -63,21 +63,16 @@ func (p *prometheusClient) MonitorHistogram(value float64, tag MetricTag, labels histogram.With(labels).Observe(value) } -func NewPrometheusClient() (*prometheusClient, error) { +func newPrometheusClient() (*prometheusClient, error) { // register Prometheus metrics metricsRegistry := prometheus.NewRegistry() - var metricTag MetricTag - for _, tag := range metricTag.ListAll() { - if summaryVecMetric, ok := SummaryVecMetrics[tag]; ok { - metricsRegistry.MustRegister(summaryVecMetric) - } else if counterMetric, ok := CounterMetrics[tag]; ok { - metricsRegistry.MustRegister(counterMetric) - } else if counterVecMetric, ok := CounterVecMetrics[tag]; ok { - metricsRegistry.MustRegister(counterVecMetric) - } else { - return nil, fmt.Errorf("metric not registered in prometheus metrics: %s", tag) - } + // register default Prometheus metrics + metricsRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + metricsRegistry.MustRegister(collectors.NewGoCollector()) + + for _, metric := range PrometheusMetrics() { + metricsRegistry.MustRegister(metric) } return &prometheusClient{httpHandler: promhttp.HandlerFor(metricsRegistry, promhttp.HandlerOpts{})}, nil diff --git a/internal/monitor/prometheus_client_test.go b/internal/monitor/prometheus_client_test.go index eaf7a2b84..0a78c2935 100644 --- a/internal/monitor/prometheus_client_test.go +++ b/internal/monitor/prometheus_client_test.go @@ -166,9 +166,8 @@ func Test_PrometheusClient_MonitorCounters(t *testing.T) { t.Run("disbursements counter metric", func(t *testing.T) { labels := DisbursementLabels{ - Asset: "USDC", - Country: "UKR", - Wallet: "Mock Wallet", + Asset: "USDC", + Wallet: "Mock Wallet", } mPrometheusClient.MonitorCounters(DisbursementsCounterTag, labels.ToMap()) @@ -185,7 +184,7 @@ func Test_PrometheusClient_MonitorCounters(t *testing.T) { assert.NotEmpty(t, data) body := string(data) - metric := `sdp_bussiness_disbursements_counter{asset="USDC",country="UKR",wallet="Mock Wallet"} 1` + metric := `sdp_business_disbursements_counter{asset="USDC",wallet="Mock Wallet"} 1` assert.Contains(t, body, metric) diff --git a/internal/monitor/prometheus_metrics.go b/internal/monitor/prometheus_metrics.go index 83c482c56..2f7eab541 100644 --- a/internal/monitor/prometheus_metrics.go +++ b/internal/monitor/prometheus_metrics.go @@ -1,6 +1,30 @@ package monitor -import "github.com/prometheus/client_golang/prometheus" +import ( + "github.com/prometheus/client_golang/prometheus" +) + +func PrometheusMetrics() map[MetricTag]prometheus.Collector { + metrics := make(map[MetricTag]prometheus.Collector) + + for tag, summaryVec := range SummaryVecMetrics { + metrics[tag] = summaryVec + } + + for tag, counter := range CounterMetrics { + metrics[tag] = counter + } + + for tag, histogramVec := range HistogramVecMetrics { + metrics[tag] = histogramVec + } + + for tag, counterVec := range CounterVecMetrics { + metrics[tag] = counterVec + } + + return metrics +} var SummaryVecMetrics = map[MetricTag]*prometheus.SummaryVec{ HttpRequestDurationTag: prometheus.NewSummaryVec(prometheus.SummaryOpts{ @@ -34,13 +58,26 @@ var CounterMetrics = map[MetricTag]prometheus.Counter{ }), } -var HistogramVecMetrics map[MetricTag]prometheus.HistogramVec +var HistogramVecMetrics = map[MetricTag]*prometheus.HistogramVec{ + CircleAPIRequestDurationTag: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sdp", Subsystem: "circle", Name: string(CircleAPIRequestDurationTag), + Help: "A histogram of the Circle API request durations", + }, + CircleLabelNames, + ), +} var CounterVecMetrics = map[MetricTag]*prometheus.CounterVec{ DisbursementsCounterTag: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sdp", Subsystem: "bussiness", Name: string(DisbursementsCounterTag), + Namespace: "sdp", Subsystem: "business", Name: string(DisbursementsCounterTag), Help: "Disbursements Counter", }, - []string{"asset", "country", "wallet"}, + []string{"asset", "wallet"}, + ), + CircleAPIRequestsTotalTag: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sdp", Subsystem: "circle", Name: string(CircleAPIRequestsTotalTag), + Help: "A counter of the Circle API requests", + }, + CircleLabelNames, ), } diff --git a/internal/monitor/utils.go b/internal/monitor/utils.go new file mode 100644 index 000000000..bd4e34058 --- /dev/null +++ b/internal/monitor/utils.go @@ -0,0 +1,19 @@ +package monitor + +import ( + "fmt" + "net/http" +) + +const ( + noHTTPStatus = "0" + successStatus = "success" + errorStatus = "error" +) + +func ParseHTTPResponseStatus(resp *http.Response, reqErr error) (status, statusCode string) { + if reqErr != nil { + return errorStatus, noHTTPStatus + } + return successStatus, fmt.Sprint(resp.StatusCode) +} diff --git a/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go b/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go index 2474c1abb..bc6667c2e 100644 --- a/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go +++ b/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go @@ -137,7 +137,6 @@ func Test_PatchAnchorPlatformTransactionsCompletionJob_Execute(t *testing.T) { data.DeleteAllFixtures(t, ctx, dbConnectionPool) - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -145,11 +144,10 @@ func Test_PatchAnchorPlatformTransactionsCompletionJob_Execute(t *testing.T) { receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.RegisteredReceiversWalletStatus) disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: data.StartedDisbursementStatus, - VerificationField: data.VerificationFieldDateOfBirth, + VerificationField: data.VerificationTypeDateOfBirth, }) payment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ diff --git a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go index 1af631718..160be9342 100644 --- a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go +++ b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go @@ -14,64 +14,64 @@ import ( ) const ( - sendReceiverWalletsSMSInvitationJobName = "send_receiver_wallets_sms_invitation_job" + sendReceiverWalletsInvitationJobName = "send_receiver_wallets_invitation_job" ) -type SendReceiverWalletsSMSInvitationJobOptions struct { - Models *data.Models - MessengerClient message.MessengerClient - MaxInvitationSMSResendAttempts int64 - Sep10SigningPrivateKey string - CrashTrackerClient crashtracker.CrashTrackerClient - JobIntervalSeconds int +type SendReceiverWalletsInvitationJobOptions struct { + Models *data.Models + MessageDispatcher message.MessageDispatcherInterface + MaxInvitationResendAttempts int64 + Sep10SigningPrivateKey string + CrashTrackerClient crashtracker.CrashTrackerClient + JobIntervalSeconds int } -// sendReceiverWalletsSMSInvitationJob is a job that periodically sends SMS invitations to receiver wallets. -type sendReceiverWalletsSMSInvitationJob struct { +// sendReceiverWalletsInvitationJob is a job that periodically sends invitations to receiver wallets. +type sendReceiverWalletsInvitationJob struct { service *services.SendReceiverWalletInviteService jobIntervalSeconds int } -func (j sendReceiverWalletsSMSInvitationJob) GetName() string { - return sendReceiverWalletsSMSInvitationJobName +func (j sendReceiverWalletsInvitationJob) GetName() string { + return sendReceiverWalletsInvitationJobName } -func (j sendReceiverWalletsSMSInvitationJob) GetInterval() time.Duration { +func (j sendReceiverWalletsInvitationJob) GetInterval() time.Duration { return time.Duration(j.jobIntervalSeconds) * time.Second } -func (j sendReceiverWalletsSMSInvitationJob) IsJobMultiTenant() bool { +func (j sendReceiverWalletsInvitationJob) IsJobMultiTenant() bool { return true } -func (j sendReceiverWalletsSMSInvitationJob) Execute(ctx context.Context) error { +func (j sendReceiverWalletsInvitationJob) Execute(ctx context.Context) error { if err := j.service.SendInvite(ctx); err != nil { - err = fmt.Errorf("error sending invitation SMS to receiver wallets: %w", err) + err = fmt.Errorf("error sending invitation to receiver wallets: %w", err) log.Ctx(ctx).Error(err) return err } return nil } -func NewSendReceiverWalletsSMSInvitationJob(options SendReceiverWalletsSMSInvitationJobOptions) Job { +func NewSendReceiverWalletsInvitationJob(options SendReceiverWalletsInvitationJobOptions) Job { if options.JobIntervalSeconds < DefaultMinimumJobIntervalSeconds { - log.Fatalf("job interval is not set for %s. Instantiation failed", sendReceiverWalletsSMSInvitationJobName) + log.Fatalf("job interval is not set for %s. Instantiation failed", sendReceiverWalletsInvitationJobName) } s, err := services.NewSendReceiverWalletInviteService( options.Models, - options.MessengerClient, + options.MessageDispatcher, options.Sep10SigningPrivateKey, - options.MaxInvitationSMSResendAttempts, + options.MaxInvitationResendAttempts, options.CrashTrackerClient, ) if err != nil { log.Fatalf("error instantiating service: %s", err.Error()) } - return &sendReceiverWalletsSMSInvitationJob{ + return &sendReceiverWalletsInvitationJob{ service: s, jobIntervalSeconds: options.JobIntervalSeconds, } } -var _ Job = (*sendReceiverWalletsSMSInvitationJob)(nil) +var _ Job = (*sendReceiverWalletsInvitationJob)(nil) diff --git a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go index 07b12be90..2500212d8 100644 --- a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go +++ b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/db" @@ -33,17 +34,22 @@ func Test_NewSendReceiverWalletsSMSInvitationJob(t *testing.T) { models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) + ctx := context.Background() + messageDryRunClient, err := message.NewDryRunClient() require.NoError(t, err) + dryRunDispatcher := message.NewMessageDispatcher() + dryRunDispatcher.RegisterClient(ctx, message.MessageChannelSMS, messageDryRunClient) + dryRunDispatcher.RegisterClient(ctx, message.MessageChannelEmail, messageDryRunClient) t.Run("exits with status 1 when Messenger Client is missing config", func(t *testing.T) { if os.Getenv("TEST_FATAL") == "1" { - o := SendReceiverWalletsSMSInvitationJobOptions{ - Models: models, - MaxInvitationSMSResendAttempts: 3, + o := SendReceiverWalletsInvitationJobOptions{ + Models: models, + MaxInvitationResendAttempts: 3, } - NewSendReceiverWalletsSMSInvitationJob(o) + NewSendReceiverWalletsInvitationJob(o) return } @@ -63,13 +69,13 @@ func Test_NewSendReceiverWalletsSMSInvitationJob(t *testing.T) { t.Run("exits with status 1 when Base URL is empty", func(t *testing.T) { if os.Getenv("TEST_FATAL") == "1" { - o := SendReceiverWalletsSMSInvitationJobOptions{ - Models: models, - MessengerClient: messageDryRunClient, - MaxInvitationSMSResendAttempts: 3, + o := SendReceiverWalletsInvitationJobOptions{ + Models: models, + MessageDispatcher: dryRunDispatcher, + MaxInvitationResendAttempts: 3, } - NewSendReceiverWalletsSMSInvitationJob(o) + NewSendReceiverWalletsInvitationJob(o) return } @@ -88,32 +94,31 @@ func Test_NewSendReceiverWalletsSMSInvitationJob(t *testing.T) { }) t.Run("returns a job instance successfully", func(t *testing.T) { - o := SendReceiverWalletsSMSInvitationJobOptions{ - Models: models, - MessengerClient: messageDryRunClient, - MaxInvitationSMSResendAttempts: 3, - JobIntervalSeconds: DefaultMinimumJobIntervalSeconds, + o := SendReceiverWalletsInvitationJobOptions{ + Models: models, + MessageDispatcher: dryRunDispatcher, + MaxInvitationResendAttempts: 3, + JobIntervalSeconds: DefaultMinimumJobIntervalSeconds, } - j := NewSendReceiverWalletsSMSInvitationJob(o) + j := NewSendReceiverWalletsInvitationJob(o) assert.NotNil(t, j) }) } func Test_SendReceiverWalletsSMSInvitationJob(t *testing.T) { - j := sendReceiverWalletsSMSInvitationJob{ + j := sendReceiverWalletsInvitationJob{ jobIntervalSeconds: 5, } - assert.Equal(t, sendReceiverWalletsSMSInvitationJobName, j.GetName()) + assert.Equal(t, sendReceiverWalletsInvitationJobName, j.GetName()) assert.Equal(t, time.Duration(5)*time.Second, j.GetInterval()) } func Test_SendReceiverWalletsSMSInvitationJob_Execute(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -132,122 +137,113 @@ func Test_SendReceiverWalletsSMSInvitationJob_Execute(t *testing.T) { stellarSecretKey := "SBUSPEKAZKLZSWHRSJ2HWDZUK6I3IVDUWA7JJZSGBLZ2WZIUJI7FPNB5" var maxInvitationSMSResendAttempts int64 = 3 - t.Run("executes the service successfully", func(t *testing.T) { - messengerClientMock := &message.MessengerClientMock{} - crashTrackerClientMock := &crashtracker.MockCrashTrackerClient{} - - s, err := services.NewSendReceiverWalletInviteService( - models, - messengerClientMock, - stellarSecretKey, - maxInvitationSMSResendAttempts, - crashTrackerClientMock, - ) - require.NoError(t, err) - - data.DeleteAllCountryFixtures(t, ctx, dbConnectionPool) - data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) - data.ClearAndCreateWalletFixtures(t, ctx, dbConnectionPool) - data.DeleteAllMessagesFixtures(t, ctx, dbConnectionPool) - data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) - data.DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) - - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "ATL", "Atlantis") - - wallet1 := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet1", "https://wallet1.com", "www.wallet1.com", "wallet1://sdp") - wallet2 := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet2", "https://wallet2.com", "www.wallet2.com", "wallet2://sdp") - - asset1 := data.CreateAssetFixture(t, ctx, dbConnectionPool, "FOO1", "GCKGCKZ2PFSCRQXREJMTHAHDMOZQLS2R4V5LZ6VLU53HONH5FI6ACBSX") - asset2 := data.CreateAssetFixture(t, ctx, dbConnectionPool, "FOO2", "GCKGCKZ2PFSCRQXREJMTHAHDMOZQLS2R4V5LZ6VLU53HONH5FI6ACBSX") - - receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - - disbursement1 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Country: country, - Wallet: wallet1, - Status: data.ReadyDisbursementStatus, - Asset: asset1, - }) - - disbursement2 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Country: country, - Wallet: wallet2, - Status: data.ReadyDisbursementStatus, - Asset: asset2, - }) - - rec1RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, data.ReadyReceiversWalletStatus) - data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet2.ID, data.RegisteredReceiversWalletStatus) - - rec2RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet2.ID, data.ReadyReceiversWalletStatus) - - _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - Status: data.ReadyPaymentStatus, - Disbursement: disbursement1, - Asset: *asset1, - ReceiverWallet: rec1RW, - Amount: "1", - }) - - _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - Status: data.ReadyPaymentStatus, - Disbursement: disbursement2, - Asset: *asset2, - ReceiverWallet: rec2RW, - Amount: "1", - }) - - walletDeepLink1 := services.WalletDeepLink{ - DeepLink: wallet1.DeepLinkSchema, - TenantBaseURL: tenantBaseURL, - OrganizationName: "MyCustomAid", - AssetCode: asset1.Code, - AssetIssuer: asset1.Issuer, - } - deepLink1, err := walletDeepLink1.GetSignedRegistrationLink(stellarSecretKey) - require.NoError(t, err) - contentWallet1 := fmt.Sprintf("You have a payment waiting for you from the MyCustomAid. Click %s to register.", deepLink1) - - walletDeepLink2 := services.WalletDeepLink{ - DeepLink: wallet2.DeepLinkSchema, - TenantBaseURL: tenantBaseURL, - OrganizationName: "MyCustomAid", - AssetCode: asset2.Code, - AssetIssuer: asset2.Issuer, - } - deepLink2, err := walletDeepLink2.GetSignedRegistrationLink(stellarSecretKey) - require.NoError(t, err) - contentWallet2 := fmt.Sprintf("You have a payment waiting for you from the MyCustomAid. Click %s to register.", deepLink2) - - mockErr := errors.New("unexpected error") - messengerClientMock. - On("SendMessage", message.Message{ - ToPhoneNumber: receiver1.PhoneNumber, - Message: contentWallet1, - }). - Return(mockErr). - Once(). - On("SendMessage", message.Message{ - ToPhoneNumber: receiver2.PhoneNumber, - Message: contentWallet2, - }). - Return(nil). - Once(). - On("MessengerType"). - Return(message.MessengerTypeTwilioSMS) - - mockMsg := fmt.Sprintf( - "error sending message to receiver ID %s for receiver wallet ID %s using messenger type %s", - receiver1.ID, rec1RW.ID, message.MessengerTypeTwilioSMS, - ) - crashTrackerClientMock.On("LogAndReportErrors", ctx, mockErr, mockMsg).Once() - - err = s.SendInvite(ctx) - require.NoError(t, err) - - q := ` + messageDispatcherMock := message.NewMockMessageDispatcher(t) + crashTrackerClientMock := &crashtracker.MockCrashTrackerClient{} + + s, err := services.NewSendReceiverWalletInviteService( + models, + messageDispatcherMock, + stellarSecretKey, + maxInvitationSMSResendAttempts, + crashTrackerClientMock, + ) + require.NoError(t, err) + + wallet1 := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet1", "https://wallet1.com", "www.wallet1.com", "wallet1://sdp") + wallet2 := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet2", "https://wallet2.com", "www.wallet2.com", "wallet2://sdp") + + asset1 := data.CreateAssetFixture(t, ctx, dbConnectionPool, "FOO1", "GCKGCKZ2PFSCRQXREJMTHAHDMOZQLS2R4V5LZ6VLU53HONH5FI6ACBSX") + asset2 := data.CreateAssetFixture(t, ctx, dbConnectionPool, "FOO2", "GCKGCKZ2PFSCRQXREJMTHAHDMOZQLS2R4V5LZ6VLU53HONH5FI6ACBSX") + + receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + + disbursement1 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Wallet: wallet1, + Status: data.ReadyDisbursementStatus, + Asset: asset1, + }) + + disbursement2 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Wallet: wallet2, + Status: data.ReadyDisbursementStatus, + Asset: asset2, + }) + + rec1RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, data.ReadyReceiversWalletStatus) + data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet2.ID, data.RegisteredReceiversWalletStatus) + + rec2RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet2.ID, data.ReadyReceiversWalletStatus) + + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Status: data.ReadyPaymentStatus, + Disbursement: disbursement1, + Asset: *asset1, + ReceiverWallet: rec1RW, + Amount: "1", + }) + + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Status: data.ReadyPaymentStatus, + Disbursement: disbursement2, + Asset: *asset2, + ReceiverWallet: rec2RW, + Amount: "1", + }) + + walletDeepLink1 := services.WalletDeepLink{ + DeepLink: wallet1.DeepLinkSchema, + TenantBaseURL: tenantBaseURL, + OrganizationName: "MyCustomAid", + AssetCode: asset1.Code, + AssetIssuer: asset1.Issuer, + } + deepLink1, err := walletDeepLink1.GetSignedRegistrationLink(stellarSecretKey) + require.NoError(t, err) + contentWallet1 := fmt.Sprintf("You have a payment waiting for you from the MyCustomAid. Click %s to register.", deepLink1) + titleWallet1 := "You have a payment waiting for you from " + walletDeepLink1.OrganizationName + + walletDeepLink2 := services.WalletDeepLink{ + DeepLink: wallet2.DeepLinkSchema, + TenantBaseURL: tenantBaseURL, + OrganizationName: "MyCustomAid", + AssetCode: asset2.Code, + AssetIssuer: asset2.Issuer, + } + deepLink2, err := walletDeepLink2.GetSignedRegistrationLink(stellarSecretKey) + require.NoError(t, err) + contentWallet2 := fmt.Sprintf("You have a payment waiting for you from the MyCustomAid. Click %s to register.", deepLink2) + titleWallet2 := "You have a payment waiting for you from " + walletDeepLink2.OrganizationName + + mockErr := errors.New("unexpected error") + messageDispatcherMock. + On("SendMessage", mock.Anything, message.Message{ + ToPhoneNumber: receiver1.PhoneNumber, + ToEmail: receiver1.Email, + Body: contentWallet1, + Title: titleWallet1, + }, []message.MessageChannel{message.MessageChannelSMS, message.MessageChannelEmail}). + Return(message.MessengerTypeTwilioSMS, mockErr). + Once(). + On("SendMessage", mock.Anything, message.Message{ + ToPhoneNumber: receiver2.PhoneNumber, + ToEmail: receiver2.Email, + Body: contentWallet2, + Title: titleWallet2, + }, []message.MessageChannel{message.MessageChannelSMS, message.MessageChannelEmail}). + Return(message.MessengerTypeTwilioSMS, nil). + Once() + + mockMsg := fmt.Sprintf( + "error sending message to receiver ID %s for receiver wallet ID %s using messenger type %s", + receiver1.ID, rec1RW.ID, message.MessengerTypeTwilioSMS, + ) + crashTrackerClientMock.On("LogAndReportErrors", ctx, mockErr, mockMsg).Once() + + err = s.SendInvite(ctx) + require.NoError(t, err) + + q := ` SELECT type, status, receiver_id, wallet_id, receiver_wallet_id, title_encrypted, text_encrypted, status_history @@ -256,36 +252,35 @@ func Test_SendReceiverWalletsSMSInvitationJob_Execute(t *testing.T) { WHERE receiver_id = $1 AND wallet_id = $2 AND receiver_wallet_id = $3 ` - var msg data.Message - err = dbConnectionPool.GetContext(ctx, &msg, q, receiver1.ID, wallet1.ID, rec1RW.ID) - require.NoError(t, err) - - assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) - assert.Equal(t, receiver1.ID, msg.ReceiverID) - assert.Equal(t, wallet1.ID, msg.WalletID) - assert.Equal(t, rec1RW.ID, *msg.ReceiverWalletID) - assert.Equal(t, data.FailureMessageStatus, msg.Status) - assert.Empty(t, msg.TitleEncrypted) - assert.Equal(t, contentWallet1, msg.TextEncrypted) - assert.Len(t, msg.StatusHistory, 2) - assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) - assert.Equal(t, data.FailureMessageStatus, msg.StatusHistory[1].Status) - assert.Nil(t, msg.AssetID) - - msg = data.Message{} - err = dbConnectionPool.GetContext(ctx, &msg, q, receiver2.ID, wallet2.ID, rec2RW.ID) - require.NoError(t, err) - - assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) - assert.Equal(t, receiver2.ID, msg.ReceiverID) - assert.Equal(t, wallet2.ID, msg.WalletID) - assert.Equal(t, rec2RW.ID, *msg.ReceiverWalletID) - assert.Equal(t, data.SuccessMessageStatus, msg.Status) - assert.Empty(t, msg.TitleEncrypted) - assert.Equal(t, contentWallet2, msg.TextEncrypted) - assert.Len(t, msg.StatusHistory, 2) - assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) - assert.Equal(t, data.SuccessMessageStatus, msg.StatusHistory[1].Status) - assert.Nil(t, msg.AssetID) - }) + var msg data.Message + err = dbConnectionPool.GetContext(ctx, &msg, q, receiver1.ID, wallet1.ID, rec1RW.ID) + require.NoError(t, err) + + assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) + assert.Equal(t, receiver1.ID, msg.ReceiverID) + assert.Equal(t, wallet1.ID, msg.WalletID) + assert.Equal(t, rec1RW.ID, *msg.ReceiverWalletID) + assert.Equal(t, data.FailureMessageStatus, msg.Status) + assert.Equal(t, titleWallet1, msg.TitleEncrypted) + assert.Equal(t, contentWallet1, msg.TextEncrypted) + assert.Len(t, msg.StatusHistory, 2) + assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) + assert.Equal(t, data.FailureMessageStatus, msg.StatusHistory[1].Status) + assert.Nil(t, msg.AssetID) + + msg = data.Message{} + err = dbConnectionPool.GetContext(ctx, &msg, q, receiver2.ID, wallet2.ID, rec2RW.ID) + require.NoError(t, err) + + assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) + assert.Equal(t, receiver2.ID, msg.ReceiverID) + assert.Equal(t, wallet2.ID, msg.WalletID) + assert.Equal(t, rec2RW.ID, *msg.ReceiverWalletID) + assert.Equal(t, data.SuccessMessageStatus, msg.Status) + assert.Equal(t, titleWallet2, msg.TitleEncrypted) + assert.Equal(t, contentWallet2, msg.TextEncrypted) + assert.Len(t, msg.StatusHistory, 2) + assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) + assert.Equal(t, data.SuccessMessageStatus, msg.StatusHistory[1].Status) + assert.Nil(t, msg.AssetID) } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 790d6a312..ea74af896 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -228,9 +228,9 @@ func WithPaymentFromSubmitterJobOption(paymentJobInterval int, models *data.Mode } } -func WithSendReceiverWalletsSMSInvitationJobOption(o jobs.SendReceiverWalletsSMSInvitationJobOptions) SchedulerJobRegisterOption { +func WithSendReceiverWalletsInvitationJobOption(o jobs.SendReceiverWalletsInvitationJobOptions) SchedulerJobRegisterOption { return func(s *Scheduler) { - j := jobs.NewSendReceiverWalletsSMSInvitationJob(o) + j := jobs.NewSendReceiverWalletsInvitationJob(o) s.addJob(j) } } diff --git a/internal/serve/httphandler/assets_handler_test.go b/internal/serve/httphandler/assets_handler_test.go index 2b2669239..2e7402e39 100644 --- a/internal/serve/httphandler/assets_handler_test.go +++ b/internal/serve/httphandler/assets_handler_test.go @@ -1441,16 +1441,22 @@ func Test_AssetHandler_submitChangeTrustTransaction_makeSurePreconditionsAreSetA // matchPreconditionsTimeboundsFn is a function meant to be used with mock.MatchedBy to check that the preconditions are set as expected. assertExpectedPreconditionsWithTimeboundsTolerance := func(t *testing.T, expectedTx *txnbuild.Transaction, actualTxIndex int) func(args mock.Arguments) { + creationTime := time.Now() return func(args mock.Arguments) { - actualTx, ok := args.Get(int(actualTxIndex)).(*txnbuild.Transaction) + actualTx, ok := args.Get(actualTxIndex).(*txnbuild.Transaction) require.True(t, ok) expectedPreconditions := expectedTx.ToXDR().Preconditions() - expectedTime := time.Unix(int64(expectedPreconditions.TimeBounds.MaxTime), 0).UTC() actualPreconditions := actualTx.ToXDR().Preconditions() - actualTime := time.Unix(int64(actualPreconditions.TimeBounds.MaxTime), 0).UTC() - require.WithinDuration(t, expectedTime, actualTime, 5*time.Second) + require.Equal(t, expectedPreconditions.TimeBounds.MinTime, actualPreconditions.TimeBounds.MinTime) + + expectedMaxTime := time.Unix(int64(expectedPreconditions.TimeBounds.MaxTime), 0).UTC() + actualMaxTime := time.Unix(int64(actualPreconditions.TimeBounds.MaxTime), 0).UTC() + timeSinceCreation := time.Since(creationTime) + + expectedAdjustedMaxTime := expectedMaxTime.Add(timeSinceCreation) + require.WithinDuration(t, expectedAdjustedMaxTime, actualMaxTime, 5*time.Second) } } diff --git a/internal/serve/httphandler/circle_config_handler.go b/internal/serve/httphandler/circle_config_handler.go index c6524d2d6..4f8655b0f 100644 --- a/internal/serve/httphandler/circle_config_handler.go +++ b/internal/serve/httphandler/circle_config_handler.go @@ -10,6 +10,7 @@ import ( "github.com/stellar/go/support/render/httpjson" "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" @@ -26,6 +27,7 @@ type CircleConfigHandler struct { EncryptionPassphrase string CircleClientConfigModel circle.ClientConfigModelInterface DistributionAccountResolver signing.DistributionAccountResolver + MonitorService monitor.MonitorServiceInterface } type PatchCircleConfigRequest struct { @@ -160,7 +162,12 @@ func (h CircleConfigHandler) validateConfigWithCircle(ctx context.Context, patch } } - circleClient := h.CircleFactory(h.NetworkType, apiKey, h.TenantManager) + circleClient := h.CircleFactory(circle.ClientOptions{ + NetworkType: h.NetworkType, + APIKey: apiKey, + TenantManager: h.TenantManager, + MonitorService: h.MonitorService, + }) // validate incoming APIKey if patchRequest.APIKey != nil { diff --git a/internal/serve/httphandler/circle_config_handler_test.go b/internal/serve/httphandler/circle_config_handler_test.go index 9331d17d4..276b0ceb8 100644 --- a/internal/serve/httphandler/circle_config_handler_test.go +++ b/internal/serve/httphandler/circle_config_handler_test.go @@ -194,7 +194,7 @@ func TestCircleConfigHandler_Patch(t *testing.T) { tc.prepareMocksFn(t, mDistributionAccountResolver, mCircleClient, mTenantManager) handler.DistributionAccountResolver = mDistributionAccountResolver - handler.CircleFactory = func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) circle.ClientInterface { + handler.CircleFactory = func(clientOpts circle.ClientOptions) circle.ClientInterface { return mCircleClient } handler.TenantManager = mTenantManager @@ -379,7 +379,7 @@ func Test_CircleConfigHandler_validateConfigWithCircle(t *testing.T) { handler.Encrypter = mEncrypter handler.CircleClientConfigModel = mCircleClientConfigModel - handler.CircleFactory = func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) circle.ClientInterface { + handler.CircleFactory = func(clientOpts circle.ClientOptions) circle.ClientInterface { return mCircleClient } } diff --git a/internal/serve/httphandler/countries_handler.go b/internal/serve/httphandler/countries_handler.go deleted file mode 100644 index 565728d3c..000000000 --- a/internal/serve/httphandler/countries_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package httphandler - -import ( - "net/http" - - "github.com/stellar/go/support/render/httpjson" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" -) - -type CountriesHandler struct { - Models *data.Models -} - -// GetCountries returns a list of countries -func (c CountriesHandler) GetCountries(w http.ResponseWriter, r *http.Request) { - countries, err := c.Models.Countries.GetAll(r.Context()) - if err != nil { - ctx := r.Context() - httperror.InternalError(ctx, "Cannot retrieve countries", err, nil).Render(w) - return - } - httpjson.Render(w, countries, httpjson.JSON) -} diff --git a/internal/serve/httphandler/countries_handler_test.go b/internal/serve/httphandler/countries_handler_test.go deleted file mode 100644 index 96a1e3cf9..000000000 --- a/internal/serve/httphandler/countries_handler_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package httphandler - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" -) - -func Test_CountriesHandlerGetCountries(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - models, err := data.NewModels(dbConnectionPool) - require.NoError(t, err) - - ctx := context.Background() - - handler := &CountriesHandler{ - Models: models, - } - - t.Run("successfully returns a list of countries", func(t *testing.T) { - expected := data.ClearAndCreateCountryFixtures(t, ctx, dbConnectionPool) - expectedJSON, err := json.Marshal(expected) - require.NoError(t, err) - - rr := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/countries", nil) - http.HandlerFunc(handler.GetCountries).ServeHTTP(rr, req) - - resp := rr.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - assert.JSONEq(t, string(expectedJSON), string(respBody)) - }) -} diff --git a/internal/serve/httphandler/delete_phone_number_handler.go b/internal/serve/httphandler/delete_contact_info_handler.go similarity index 61% rename from internal/serve/httphandler/delete_phone_number_handler.go rename to internal/serve/httphandler/delete_contact_info_handler.go index 6a6ead241..f90d0aeaa 100644 --- a/internal/serve/httphandler/delete_phone_number_handler.go +++ b/internal/serve/httphandler/delete_contact_info_handler.go @@ -3,6 +3,7 @@ package httphandler import ( "errors" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/stellar/go/network" @@ -14,27 +15,37 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) -type DeletePhoneNumberHandler struct { +type DeleteContactInfoHandler struct { NetworkPassphrase string Models *data.Models } -func (d DeletePhoneNumberHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (d DeleteContactInfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if d.NetworkPassphrase != network.TestNetworkPassphrase { httperror.NotFound("", nil, nil).Render(w) return } - phoneNumber := chi.URLParam(r, "phone_number") - if err := utils.ValidatePhoneNumber(phoneNumber); err != nil { - extras := map[string]interface{}{"phone_number": "invalid phone number"} + contactInfo := strings.TrimSpace(chi.URLParam(r, "contact_info")) + extras := map[string]interface{}{} + if contactInfo == "" { + extras["contact_info"] = "contact_info is required" + } else { + phoneNumberErr := utils.ValidatePhoneNumber(contactInfo) + emailErr := utils.ValidateEmail(contactInfo) + + if phoneNumberErr != nil && emailErr != nil { + extras["contact_info"] = "not a valid phone number or email" + } + } + if len(extras) > 0 { httperror.BadRequest("", nil, extras).Render(w) return } - log.Ctx(ctx).Warnf("Deleting user with phone number %s", utils.TruncateString(phoneNumber, 3)) - err := d.Models.Receiver.DeleteByPhoneNumber(ctx, d.Models.DBConnectionPool, phoneNumber) + log.Ctx(ctx).Warnf("Deleting user with phone number %s", utils.TruncateString(contactInfo, 3)) + err := d.Models.Receiver.DeleteByContactInfo(ctx, d.Models.DBConnectionPool, contactInfo) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { httperror.NotFound("", err, nil).Render(w) diff --git a/internal/serve/httphandler/delete_contact_info_handler_test.go b/internal/serve/httphandler/delete_contact_info_handler_test.go new file mode 100644 index 000000000..e1fa91ceb --- /dev/null +++ b/internal/serve/httphandler/delete_contact_info_handler_test.go @@ -0,0 +1,104 @@ +package httphandler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stellar/go/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) + +func Test_DeleteContactInfoHandler(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + for _, contactType := range data.GetAllReceiverContactTypes() { + t.Run(string(contactType), func(t *testing.T) { + testCases := []struct { + name string + networkPassphrase string + getContactinfoFn func(t *testing.T, receiver *data.Receiver) string + wantStatusCode int + wantBody string + }{ + { + name: "🔴 return 404 if network passphrase is not testnet", + networkPassphrase: network.PublicNetworkPassphrase, + getContactinfoFn: func(t *testing.T, receiver *data.Receiver) string { return receiver.ContactByType(contactType) }, + wantStatusCode: http.StatusNotFound, + wantBody: `{"error": "Resource not found."}`, + }, + { + name: "🔴 return 400 if the contact info is invalid", + networkPassphrase: network.TestNetworkPassphrase, + getContactinfoFn: func(t *testing.T, receiver *data.Receiver) string { return "foobar" }, + wantStatusCode: http.StatusBadRequest, + wantBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "contact_info": "not a valid phone number or email" + } + }`, + }, + { + name: "🔴 return 404 if the contact info does not exist", + networkPassphrase: network.TestNetworkPassphrase, + getContactinfoFn: func(t *testing.T, receiver *data.Receiver) string { + switch contactType { + case data.ReceiverContactTypeEmail: + return "foobar@test.com" + case data.ReceiverContactTypeSMS: + return "+14153333333" + } + t.Errorf("Unsupported contact type %s", contactType) + panic("Unsupported contact type " + contactType) + }, + wantStatusCode: http.StatusNotFound, + wantBody: `{"error":"Resource not found."}`, + }, + { + name: "🟢 return 204 if the contact info exists", + networkPassphrase: network.TestNetworkPassphrase, + getContactinfoFn: func(t *testing.T, receiver *data.Receiver) string { return receiver.ContactByType(contactType) }, + wantStatusCode: http.StatusNoContent, + wantBody: "null", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + + h := DeleteContactInfoHandler{NetworkPassphrase: tc.networkPassphrase, Models: models} + r := chi.NewRouter() + r.Delete("/wallet-registration/contact-info/{contact_info}", h.ServeHTTP) + + // test + req, err := http.NewRequest("DELETE", "/wallet-registration/contact-info/"+tc.getContactinfoFn(t, receiver), nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + // assert response + assert.Equal(t, tc.wantStatusCode, rr.Code) + assert.JSONEq(t, tc.wantBody, rr.Body.String()) + }) + } + }) + } +} diff --git a/internal/serve/httphandler/delete_phone_number_handler_test.go b/internal/serve/httphandler/delete_phone_number_handler_test.go deleted file mode 100644 index 48d7eba94..000000000 --- a/internal/serve/httphandler/delete_phone_number_handler_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package httphandler - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - "github.com/stellar/go/network" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" -) - -func Test_DeletePhoneNumberHandler(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - ctx := context.Background() - models, err := data.NewModels(dbConnectionPool) - require.NoError(t, err) - receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{PhoneNumber: "+14152222222"}) - - t.Run("return 404 if network passphrase is not testnet", func(t *testing.T) { - h := DeletePhoneNumberHandler{NetworkPassphrase: network.PublicNetworkPassphrase, Models: models} - r := chi.NewRouter() - r.Delete("/wallet-registration/phone-number/{phone_number}", h.ServeHTTP) - - // test - req, err := http.NewRequest("DELETE", "/wallet-registration/phone-number/"+receiver.PhoneNumber, nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // assert response - assert.Equal(t, http.StatusNotFound, rr.Code) - wantJson := `{"error": "Resource not found."}` - assert.JSONEq(t, wantJson, rr.Body.String()) - }) - - t.Run("return 400 if network passphrase is testnet but phone number is invalid", func(t *testing.T) { - h := DeletePhoneNumberHandler{NetworkPassphrase: network.TestNetworkPassphrase, Models: models} - r := chi.NewRouter() - r.Delete("/wallet-registration/phone-number/{phone_number}", h.ServeHTTP) - - // test - req, err := http.NewRequest("DELETE", "/wallet-registration/phone-number/foobar", nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // assert response - assert.Equal(t, http.StatusBadRequest, rr.Code) - wantJson := `{ - "error": "The request was invalid in some way.", - "extras": { - "phone_number": "invalid phone number" - } - }` - assert.JSONEq(t, wantJson, rr.Body.String()) - }) - - t.Run("return 404 if network passphrase is testnet but phone number does not exist", func(t *testing.T) { - h := DeletePhoneNumberHandler{NetworkPassphrase: network.TestNetworkPassphrase, Models: models} - r := chi.NewRouter() - r.Delete("/wallet-registration/phone-number/{phone_number}", h.ServeHTTP) - - // test - req, err := http.NewRequest("DELETE", "/wallet-registration/phone-number/+14153333333", nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // assert response - assert.Equal(t, http.StatusNotFound, rr.Code) - wantJson := `{"error":"Resource not found."}` - assert.JSONEq(t, wantJson, rr.Body.String()) - }) - - t.Run("return 204 if network passphrase is testnet and phone nymber exists", func(t *testing.T) { - h := DeletePhoneNumberHandler{NetworkPassphrase: network.TestNetworkPassphrase, Models: models} - r := chi.NewRouter() - r.Delete("/wallet-registration/phone-number/{phone_number}", h.ServeHTTP) - - // test - req, err := http.NewRequest("DELETE", "/wallet-registration/phone-number/"+receiver.PhoneNumber, nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - // assert response - assert.Equal(t, http.StatusNoContent, rr.Code) - wantJson := "null" - assert.JSONEq(t, wantJson, rr.Body.String()) - }) -} diff --git a/internal/serve/httphandler/disbursement_handler.go b/internal/serve/httphandler/disbursement_handler.go index 09bd35edd..b9ff8dc70 100644 --- a/internal/serve/httphandler/disbursement_handler.go +++ b/internal/serve/httphandler/disbursement_handler.go @@ -3,11 +3,14 @@ package httphandler import ( "bytes" "context" + "encoding/csv" "encoding/json" "errors" "fmt" "io" + "mime/multipart" "net/http" + "path/filepath" "slices" "time" @@ -24,6 +27,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) @@ -37,12 +41,38 @@ type DisbursementHandler struct { } type PostDisbursementRequest struct { - Name string `json:"name"` - CountryCode string `json:"country_code"` - WalletID string `json:"wallet_id"` - AssetID string `json:"asset_id"` - VerificationField data.VerificationField `json:"verification_field"` - SMSRegistrationMessageTemplate string `json:"sms_registration_message_template"` + Name string `json:"name"` + WalletID string `json:"wallet_id"` + AssetID string `json:"asset_id"` + VerificationField data.VerificationType `json:"verification_field"` + RegistrationContactType data.RegistrationContactType `json:"registration_contact_type"` + ReceiverRegistrationMessageTemplate string `json:"receiver_registration_message_template"` +} + +func (d DisbursementHandler) validateRequest(req PostDisbursementRequest) *validators.Validator { + v := validators.NewValidator() + + v.Check(req.Name != "", "name", "name is required") + v.Check(req.AssetID != "", "asset_id", "asset_id is required") + v.Check( + slices.Contains(data.AllRegistrationContactTypes(), req.RegistrationContactType), + "registration_contact_type", + fmt.Sprintf("registration_contact_type must be one of %v", data.AllRegistrationContactTypes()), + ) + v.CheckError(utils.ValidateNoHTMLNorJSNorCSS(req.ReceiverRegistrationMessageTemplate), "receiver_registration_message_template", "receiver_registration_message_template cannot contain HTML, JS or CSS") + if !req.RegistrationContactType.IncludesWalletAddress { + v.Check( + slices.Contains(data.GetAllVerificationTypes(), req.VerificationField), + "verification_field", + fmt.Sprintf("verification_field must be one of %v", data.GetAllVerificationTypes()), + ) + v.Check(req.WalletID != "", "wallet_id", "wallet_id is required") + } else { + v.Check(req.VerificationField == "", "verification_field", "verification_field is not allowed for this registration contact type") + v.Check(req.WalletID == "", "wallet_id", "wallet_id is not allowed for this registration contact type") + } + + return v } type PatchDisbursementStatusRequest struct { @@ -50,81 +80,83 @@ type PatchDisbursementStatusRequest struct { } func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Request) { - var disbursementRequest PostDisbursementRequest + ctx := r.Context() - err := json.NewDecoder(r.Body).Decode(&disbursementRequest) + // Grab token and user + token, ok := ctx.Value(middleware.TokenContextKey).(string) + if !ok { + httperror.Unauthorized("", nil, nil).Render(w) + return + } + user, err := d.AuthManager.GetUser(ctx, token) if err != nil { - httperror.BadRequest("invalid request body", err, nil).Render(w) + httperror.InternalError(ctx, "Cannot get user", err, nil).Render(w) return } - v := validators.NewDisbursementRequestValidator(disbursementRequest.VerificationField) - v.Check(disbursementRequest.Name != "", "name", "name is required") - v.Check(disbursementRequest.CountryCode != "", "country_code", "country_code is required") - v.Check(disbursementRequest.WalletID != "", "wallet_id", "wallet_id is required") - v.Check(disbursementRequest.AssetID != "", "asset_id", "asset_id is required") - - if v.HasErrors() { - httperror.BadRequest("Request invalid", err, v.Errors).Render(w) + // Decode and validate body + var req PostDisbursementRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + httperror.BadRequest(err.Error(), err, nil).Render(w) return } - - verificationField := v.ValidateAndGetVerificationType() - + v := d.validateRequest(req) if v.HasErrors() { - httperror.BadRequest("Verification field invalid", err, v.Errors).Render(w) + httperror.BadRequest("", err, v.Errors).Render(w) return } - ctx := r.Context() - wallet, err := d.Models.Wallets.Get(ctx, disbursementRequest.WalletID) - if err != nil { - httperror.BadRequest("wallet ID is invalid", err, nil).Render(w) - return + var wallet *data.Wallet + if req.RegistrationContactType.IncludesWalletAddress { + wallets, findWalletErr := d.Models.Wallets.FindWallets(ctx, + data.NewFilter(data.FilterUserManaged, true), + data.NewFilter(data.FilterEnabledWallets, true)) + + if findWalletErr != nil { + httperror.InternalError(ctx, "Cannot get wallets", findWalletErr, nil).Render(w) + return + } + if len(wallets) == 0 { + httperror.BadRequest("No User Managed Wallets found", nil, nil).Render(w) + return + } + wallet = &wallets[0] + } else { + // Get Wallet + wallet, err = d.Models.Wallets.Get(ctx, req.WalletID) + if err != nil { + httperror.BadRequest("Wallet ID could not be retrieved", err, nil).Render(w) + return + } } if !wallet.Enabled { - httperror.BadRequest("wallet is not enabled", errors.New("wallet is not enabled"), nil).Render(w) - return - } - asset, err := d.Models.Assets.Get(ctx, disbursementRequest.AssetID) - if err != nil { - httperror.BadRequest("asset ID is invalid", err, nil).Render(w) - return - } - country, err := d.Models.Countries.Get(ctx, disbursementRequest.CountryCode) - if err != nil { - httperror.BadRequest("country code is invalid", err, nil).Render(w) + httperror.BadRequest("Wallet is not enabled", errors.New("wallet is not enabled"), nil).Render(w) return } - token, ok := ctx.Value(middleware.TokenContextKey).(string) - if !ok { - msg := fmt.Sprintf("Cannot get token from context when inserting disbursement %s", disbursementRequest.Name) - httperror.InternalError(ctx, msg, nil, nil).Render(w) - return - } - user, err := d.AuthManager.GetUser(ctx, token) + // Get Asset + asset, err := d.Models.Assets.Get(ctx, req.AssetID) if err != nil { - msg := fmt.Sprintf("Cannot insert disbursement %s", disbursementRequest.Name) - httperror.InternalError(ctx, msg, err, nil).Render(w) + httperror.BadRequest("asset ID could not be retrieved", err, nil).Render(w) return } + // Insert disbursement disbursement := data.Disbursement{ - Name: disbursementRequest.Name, - Status: data.DraftDisbursementStatus, + Asset: asset, + Name: req.Name, + ReceiverRegistrationMessageTemplate: req.ReceiverRegistrationMessageTemplate, + RegistrationContactType: req.RegistrationContactType, + VerificationField: req.VerificationField, + Wallet: wallet, + Status: data.DraftDisbursementStatus, StatusHistory: []data.DisbursementStatusHistoryEntry{{ Timestamp: time.Now(), Status: data.DraftDisbursementStatus, UserID: user.ID, }}, - Wallet: wallet, - Asset: asset, - Country: country, - VerificationField: verificationField, - SMSRegistrationMessageTemplate: disbursementRequest.SMSRegistrationMessageTemplate, } - newId, err := d.Models.Disbursements.Insert(ctx, &disbursement) if err != nil { if errors.Is(err, data.ErrRecordAlreadyExists) { @@ -134,7 +166,6 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req } return } - newDisbursement, err := d.Models.Disbursements.Get(ctx, d.Models.DBConnectionPool, newId) if err != nil { msg := fmt.Sprintf("Cannot retrieve disbursement for ID: %s", newId) @@ -142,12 +173,11 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req return } + // Monitor disbursement creation labels := monitor.DisbursementLabels{ - Asset: newDisbursement.Asset.Code, - Country: newDisbursement.Country.Code, - Wallet: newDisbursement.Wallet.Name, + Asset: newDisbursement.Asset.Code, + Wallet: newDisbursement.Wallet.Name, } - err = d.MonitorService.MonitorCounters(monitor.DisbursementsCounterTag, labels.ToMap()) if err != nil { log.Ctx(ctx).Errorf("Error trying to monitor disbursement counter: %s", err) @@ -209,20 +239,21 @@ func (d DisbursementHandler) PostDisbursementInstructions(w http.ResponseWriter, return } - // Parse uploaded CSV file - file, header, err := r.FormFile("file") - if err != nil { - httperror.BadRequest("could not parse file", err, nil).Render(w) + buf, header, httpErr := parseCsvFromMultipartRequest(r) + if httpErr != nil { + httpErr.Render(w) return } - defer file.Close() - // TeeReader is used to read multiple times from the same reader (file) - // We read once to process the instructions, and then again to persist the file to the database - var buf bytes.Buffer - reader := io.TeeReader(file, &buf) + if err = validateCSVHeaders(bytes.NewReader(buf.Bytes()), disbursement.RegistrationContactType); err != nil { + errMsg := fmt.Sprintf("CSV columns are not valid for registration contact type %s: %s", + disbursement.RegistrationContactType, + err) + httperror.BadRequest(errMsg, err, nil).Render(w) + return + } - instructions, v := parseInstructionsFromCSV(ctx, reader, disbursement.VerificationField) + instructions, v := parseInstructionsFromCSV(ctx, bytes.NewReader(buf.Bytes()), disbursement.RegistrationContactType, disbursement.VerificationField) if v != nil && v.HasErrors() { httperror.BadRequest("could not parse csv file", err, v.Errors).Render(w) return @@ -247,14 +278,22 @@ func (d DisbursementHandler) PostDisbursementInstructions(w http.ResponseWriter, return } - if err = d.Models.DisbursementInstructions.ProcessAll(ctx, user.ID, instructions, disbursement, disbursementUpdate, data.MaxInstructionsPerDisbursement); err != nil { + if err = d.Models.DisbursementInstructions.ProcessAll(ctx, data.DisbursementInstructionsOpts{ + UserID: user.ID, + Instructions: instructions, + Disbursement: disbursement, + DisbursementUpdate: disbursementUpdate, + MaxNumberOfInstructions: data.MaxInstructionsPerDisbursement, + }); err != nil { switch { case errors.Is(err, data.ErrMaxInstructionsExceeded): - httperror.BadRequest(fmt.Sprintf("number of instructions exceeds maximum of : %d", data.MaxInstructionsPerDisbursement), err, nil).Render(w) + httperror.BadRequest(fmt.Sprintf("number of instructions exceeds maximum of %d", data.MaxInstructionsPerDisbursement), err, nil).Render(w) case errors.Is(err, data.ErrReceiverVerificationMismatch): httperror.BadRequest(errors.Unwrap(err).Error(), err, nil).Render(w) + case errors.Is(err, data.ErrReceiverWalletAddressMismatch): + httperror.BadRequest(errors.Unwrap(err).Error(), err, nil).Render(w) default: - httperror.InternalError(ctx, fmt.Sprintf("Cannot process instructions for disbursement with ID: %s", disbursementID), err, nil).Render(w) + httperror.InternalError(ctx, fmt.Sprintf("Cannot process instructions for disbursement with ID %s", disbursementID), err, nil).Render(w) } return } @@ -266,6 +305,32 @@ func (d DisbursementHandler) PostDisbursementInstructions(w http.ResponseWriter, httpjson.Render(w, response, httpjson.JSON) } +// parseCsvFromMultipartRequest parses the CSV file from a multipart request and returns the file content and header, +// or an error if the file is not a valid CSV or the MIME type is not text/csv. +func parseCsvFromMultipartRequest(r *http.Request) (*bytes.Buffer, *multipart.FileHeader, *httperror.HTTPError) { + // Parse uploaded CSV file + file, header, err := r.FormFile("file") + if err != nil { + return nil, nil, httperror.BadRequest("could not parse file", err, nil) + } + defer file.Close() + + if err = utils.ValidatePathIsNotTraversal(header.Filename); err != nil { + return nil, nil, httperror.BadRequest("file name contains invalid traversal pattern", nil, nil) + } + + if filepath.Ext(header.Filename) != ".csv" { + return nil, nil, httperror.BadRequest("the file extension should be .csv", nil, nil) + } + + var buf bytes.Buffer + if _, err = io.Copy(&buf, file); err != nil { + return nil, nil, httperror.BadRequest("could not read file", err, nil) + } + + return &buf, header, nil +} + func (d DisbursementHandler) GetDisbursement(w http.ResponseWriter, r *http.Request) { disbursementID := chi.URLParam(r, "id") @@ -429,8 +494,13 @@ func (d DisbursementHandler) GetDisbursementInstructions(w http.ResponseWriter, return } + filename := disbursement.FileName + if filepath.Ext(filename) != ".csv" { // add .csv extension if missing + filename = filename + ".csv" + } + // `attachment` returns a file-download prompt. change that to `inline` to open in browser - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, disbursement.FileName)) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("Content-Type", "text/csv") _, err = w.Write(disbursement.FileContent) if err != nil { @@ -438,11 +508,12 @@ func (d DisbursementHandler) GetDisbursementInstructions(w http.ResponseWriter, } } -func parseInstructionsFromCSV(ctx context.Context, file io.Reader, verificationField data.VerificationField) ([]*data.DisbursementInstruction, *validators.DisbursementInstructionsValidator) { - validator := validators.NewDisbursementInstructionsValidator(verificationField) +// parseInstructionsFromCSV parses the CSV file and returns a list of DisbursementInstructions +func parseInstructionsFromCSV(ctx context.Context, reader io.Reader, contactType data.RegistrationContactType, verificationField data.VerificationType) ([]*data.DisbursementInstruction, *validators.DisbursementInstructionsValidator) { + validator := validators.NewDisbursementInstructionsValidator(contactType, verificationField) instructions := []*data.DisbursementInstruction{} - if err := gocsv.Unmarshal(file, &instructions); err != nil { + if err := gocsv.Unmarshal(reader, &instructions); err != nil { log.Ctx(ctx).Errorf("error parsing csv file: %s", err.Error()) validator.Errors["file"] = "could not parse file" return nil, validator @@ -464,3 +535,64 @@ func parseInstructionsFromCSV(ctx context.Context, file io.Reader, verificationF return sanitizedInstructions, nil } + +// validateCSVHeaders validates the headers of the CSV file to make sure we're passing the correct columns. +func validateCSVHeaders(file io.Reader, registrationContactType data.RegistrationContactType) error { + headers, err := csv.NewReader(file).Read() + if err != nil { + return fmt.Errorf("reading csv headers: %w", err) + } + + hasHeaders := map[string]bool{ + "phone": false, + "email": false, + "walletAddress": false, + "verification": false, + } + + // Populate header presence map + for _, header := range headers { + if _, exists := hasHeaders[header]; exists { + hasHeaders[header] = true + } + } + + // establish the header rules. Each registration contact type has its own rules. + type headerRules struct { + required []string + disallowed []string + } + + rules := map[data.RegistrationContactType]headerRules{ + data.RegistrationContactTypePhone: { + required: []string{"phone", "verification"}, + disallowed: []string{"email", "walletAddress"}, + }, + data.RegistrationContactTypeEmail: { + required: []string{"email", "verification"}, + disallowed: []string{"phone", "walletAddress"}, + }, + data.RegistrationContactTypeEmailAndWalletAddress: { + required: []string{"email", "walletAddress"}, + disallowed: []string{"phone", "verification"}, + }, + data.RegistrationContactTypePhoneAndWalletAddress: { + required: []string{"phone", "walletAddress"}, + disallowed: []string{"email", "verification"}, + }, + } + + // Validate headers according to the rules + for _, req := range rules[registrationContactType].required { + if !hasHeaders[req] { + return fmt.Errorf("%s column is required", req) + } + } + for _, dis := range rules[registrationContactType].disallowed { + if hasHeaders[dis] { + return fmt.Errorf("%s column is not allowed for this registration contact type", dis) + } + } + + return nil +} diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index b414273ce..824d038df 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -37,13 +37,150 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) -func Test_DisbursementHandler_PostDisbursement(t *testing.T) { - const url = "/disbursements" - const method = "POST" +func Test_DisbursementHandler_validateRequest(t *testing.T) { + type TestCase struct { + name string + request PostDisbursementRequest + expectedErrors map[string]interface{} + } + + testCases := []TestCase{ + { + name: "🔴 all fields are empty", + request: PostDisbursementRequest{}, + expectedErrors: map[string]interface{}{ + "name": "name is required", + "wallet_id": "wallet_id is required", + "asset_id": "asset_id is required", + "registration_contact_type": fmt.Sprintf("registration_contact_type must be one of %v", data.AllRegistrationContactTypes()), + "verification_field": fmt.Sprintf("verification_field must be one of %v", data.GetAllVerificationTypes()), + }, + }, + { + name: "🔴 wallet_id and verification_field not allowed for user managed wallet", + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + RegistrationContactType: data.RegistrationContactTypePhoneAndWalletAddress, + VerificationField: data.VerificationTypeDateOfBirth, + }, + expectedErrors: map[string]interface{}{ + "wallet_id": "wallet_id is not allowed for this registration contact type", + "verification_field": "verification_field is not allowed for this registration contact type", + }, + }, + { + name: "🔴 registration_contact_type and verification_field are invalid", + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + RegistrationContactType: data.RegistrationContactType{ + ReceiverContactType: "invalid1", + }, + VerificationField: "invalid2", + }, + expectedErrors: map[string]interface{}{ + "registration_contact_type": fmt.Sprintf("registration_contact_type must be one of %v", data.AllRegistrationContactTypes()), + "verification_field": fmt.Sprintf("verification_field must be one of %v", data.GetAllVerificationTypes()), + }, + }, + { + name: "🔴 receiver_registration_message_template contains HTML", + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + RegistrationContactType: data.RegistrationContactTypePhone, + VerificationField: data.VerificationTypeDateOfBirth, + ReceiverRegistrationMessageTemplate: "Redeem money", + }, + expectedErrors: map[string]interface{}{ + "receiver_registration_message_template": "receiver_registration_message_template cannot contain HTML, JS or CSS", + }, + }, + { + name: "🔴 receiver_registration_message_template contains JS", + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + RegistrationContactType: data.RegistrationContactTypePhone, + VerificationField: data.VerificationTypeDateOfBirth, + ReceiverRegistrationMessageTemplate: "javascript:alert(localStorage.getItem('sdp_session'))", + }, + expectedErrors: map[string]interface{}{ + "receiver_registration_message_template": "receiver_registration_message_template cannot contain HTML, JS or CSS", + }, + }, + { + name: "🟢 all fields are valid", + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + RegistrationContactType: data.RegistrationContactTypePhone, + VerificationField: data.VerificationTypeDateOfBirth, + }, + }, + { + name: "🟢 all fields are valid w/ receiver_registration_message_template", + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + RegistrationContactType: data.RegistrationContactTypePhone, + VerificationField: data.VerificationTypeDateOfBirth, + ReceiverRegistrationMessageTemplate: "My custom invitation message", + }, + }, + } + + for _, rct := range data.AllRegistrationContactTypes() { + var name string + var expectedErrors map[string]interface{} + if !rct.IncludesWalletAddress { + name = fmt.Sprintf("🔴[%s]registration_contact_type without wallet address REQUIRES verification_field", rct) + expectedErrors = map[string]interface{}{ + "verification_field": fmt.Sprintf("verification_field must be one of %v", data.GetAllVerificationTypes()), + } + } else { + name = fmt.Sprintf("🟢[%s]registration_contact_type with wallet address DOES NOT REQUIRE registration_contact_type", rct) + } + newTestCase := TestCase{ + name: name, + request: PostDisbursementRequest{ + Name: "disbursement 1", + AssetID: "61dbfa89-943a-413c-b862-a2177384d321", + RegistrationContactType: rct, + }, + expectedErrors: expectedErrors, + } + if !rct.IncludesWalletAddress { + newTestCase.request.WalletID = "aab4a4a9-2493-4f37-9741-01d5bd31d68b" + } + + testCases = append(testCases, newTestCase) + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handler := &DisbursementHandler{} + v := handler.validateRequest(tc.request) + if len(tc.expectedErrors) == 0 { + assert.False(t, v.HasErrors()) + } else { + assert.True(t, v.HasErrors()) + assert.Equal(t, tc.expectedErrors, v.Errors) + } + }) + } +} +func Test_DisbursementHandler_PostDisbursement(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -57,18 +194,6 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { ID: "user-id", Email: "email@email.com", } - authManagerMock := &auth.AuthManagerMock{} - authManagerMock. - On("GetUser", mock.Anything, token). - Return(user, nil) - - mMonitorService := monitorMocks.NewMockMonitorService(t) - - handler := &DisbursementHandler{ - Models: models, - MonitorService: mMonitorService, - AuthManager: authManagerMock, - } // setup fixtures wallets := data.ClearAndCreateWalletFixtures(t, ctx, dbConnectionPool) @@ -76,224 +201,221 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { disabledWallet := wallets[1] data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, disabledWallet.ID) + userManagedWallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "User Managed Wallet", "stellar.org", "stellar.org", "stellar://") + data.MakeWalletUserManaged(t, ctx, dbConnectionPool, userManagedWallet.ID) + userManagedWallet = data.GetWalletFixture(t, ctx, dbConnectionPool, userManagedWallet.Name) + enabledWallet.Assets = nil asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) - country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) - - smsTemplate := "You have a new payment waiting for you from org x. Click on the link to register." - - t.Run("returns error when body is invalid", func(t *testing.T) { - requestBody := ` - { - "name": "My New Disbursement name 5", - }` - want := `{"error":"invalid request body"}` - - assertPOSTResponse(t, ctx, handler, method, url, requestBody, want, http.StatusBadRequest) + existingDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "existing disbursement", + Asset: asset, + Wallet: &enabledWallet, }) - t.Run("returns error when name is not provided", func(t *testing.T) { - requestBody := ` + type TestCase struct { + name string + prepareMocksFn func(t *testing.T, mMonitorService *monitorMocks.MockMonitorService) + reqBody map[string]interface{} + wantStatusCode int + wantResponseBodyFn func(d *data.Disbursement) string + } + testCases := []TestCase{ { - "wallet_id": "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - "asset_id": "61dbfa89-943a-413c-b862-a2177384d321", - "country_code": "UKR", - "verification_field": "date_of_birth" - }` - - want := ` + name: "🔴 body parameters are missing", + wantStatusCode: http.StatusBadRequest, + wantResponseBodyFn: func(d *data.Disbursement) string { + return `{ + "error": "The request was invalid in some way.", + "extras": { + "name": "name is required", + "wallet_id": "wallet_id is required", + "asset_id": "asset_id is required", + "registration_contact_type": "registration_contact_type must be one of [EMAIL PHONE_NUMBER EMAIL_AND_WALLET_ADDRESS PHONE_NUMBER_AND_WALLET_ADDRESS]", + "verification_field": "verification_field must be one of [DATE_OF_BIRTH YEAR_MONTH PIN NATIONAL_ID_NUMBER]" + } + }` + }, + }, { - "error":"Request invalid", - "extras": { - "name": "name is required" - } - }` - - assertPOSTResponse(t, ctx, handler, method, url, requestBody, want, http.StatusBadRequest) - }) - - t.Run("returns error when wallet_id is not provided", func(t *testing.T) { - requestBody := ` + name: "🔴 wallet_id could not be found", + reqBody: map[string]interface{}{ + "name": "disbursement 1", + "asset_id": asset.ID, + "wallet_id": "not-found-wallet-id", + "registration_contact_type": data.RegistrationContactTypePhone, + "verification_field": data.VerificationTypeDateOfBirth, + }, + wantStatusCode: http.StatusBadRequest, + wantResponseBodyFn: func(d *data.Disbursement) string { + return `{"error":"Wallet ID could not be retrieved"}` + }, + }, { - "name": "My New Disbursement name 5", - "asset_id": "61dbfa89-943a-413c-b862-a2177384d321", - "country_code": "UKR", - "verification_field": "date_of_birth" - }` - - want := `{"error":"Request invalid", "extras": {"wallet_id": "wallet_id is required"}}` - - assertPOSTResponse(t, ctx, handler, method, url, requestBody, want, http.StatusBadRequest) - }) - - t.Run("returns error when asset_id is not provided", func(t *testing.T) { - requestBody := ` + name: "🔴 wallet is not enabled", + reqBody: map[string]interface{}{ + "name": "disbursement 1", + "asset_id": asset.ID, + "wallet_id": disabledWallet.ID, + "registration_contact_type": data.RegistrationContactTypePhone, + "verification_field": data.VerificationTypeDateOfBirth, + }, + wantStatusCode: http.StatusBadRequest, + wantResponseBodyFn: func(d *data.Disbursement) string { + return `{"error":"Wallet is not enabled"}` + }, + }, { - "name": "My New Disbursement name 5", - "wallet_id": "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - "country_code": "UKR", - "verification_field": "date_of_birth" - }` - - want := `{"error":"Request invalid", "extras": {"asset_id": "asset_id is required"}}` - - assertPOSTResponse(t, ctx, handler, method, url, requestBody, want, http.StatusBadRequest) - }) - - t.Run("returns error when country_code is not provided", func(t *testing.T) { - requestBody := ` + name: "🔴 asset_id could not be found", + reqBody: map[string]interface{}{ + "name": "disbursement 1", + "asset_id": "not-found-asset-id", + "wallet_id": enabledWallet.ID, + "registration_contact_type": data.RegistrationContactTypePhone, + "verification_field": data.VerificationTypeDateOfBirth, + }, + wantStatusCode: http.StatusBadRequest, + wantResponseBodyFn: func(d *data.Disbursement) string { + return `{"error":"asset ID could not be retrieved"}` + }, + }, { - "name": "My New Disbursement name 5", - "wallet_id": "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - "asset_id": "61dbfa89-943a-413c-b862-a2177384d321", - "verification_field": "date_of_birth" - }` - - want := `{"error":"Request invalid", "extras": {"country_code": "country_code is required"}}` - - assertPOSTResponse(t, ctx, handler, method, url, requestBody, want, http.StatusBadRequest) - }) - - t.Run("returns error when no verification field is provided", func(t *testing.T) { - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: enabledWallet.ID, - }) - require.NoError(t, err) - - want := `{"error":"Verification field invalid", "extras": {"verification_field": "invalid parameter. valid values are: [DATE_OF_BIRTH YEAR_MONTH PIN NATIONAL_ID_NUMBER]"}}` - - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) - }) - - t.Run("returns error when wallet_id is not valid", func(t *testing.T) { - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - VerificationField: data.VerificationFieldDateOfBirth, - }) - require.NoError(t, err) - - want := `{"error":"wallet ID is invalid"}` - - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) - }) - - t.Run("returns error when wallet is not enabled", func(t *testing.T) { - data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, disabledWallet.ID) - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: disabledWallet.ID, - VerificationField: data.VerificationFieldDateOfBirth, - }) - require.NoError(t, err) - - want := `{"error":"wallet is not enabled"}` - - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) - }) - - t.Run("returns error when asset_id is not valid", func(t *testing.T) { - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - WalletID: enabledWallet.ID, - VerificationField: data.VerificationFieldDateOfBirth, - }) - require.NoError(t, err) - - want := `{"error":"asset ID is invalid"}` - - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) - }) - - t.Run("returns error when country_code is not valid", func(t *testing.T) { - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: "AAA", - AssetID: asset.ID, - WalletID: enabledWallet.ID, - VerificationField: data.VerificationFieldDateOfBirth, - }) - require.NoError(t, err) + name: "🔴 non-unique disbursement name", + reqBody: map[string]interface{}{ + "name": existingDisbursement.Name, + "asset_id": asset.ID, + "wallet_id": enabledWallet.ID, + "registration_contact_type": data.RegistrationContactTypePhone, + "verification_field": data.VerificationTypeDateOfBirth, + }, + wantStatusCode: http.StatusConflict, + wantResponseBodyFn: func(d *data.Disbursement) string { + return `{"error":"disbursement already exists"}` + }, + }, + } - want := `{"error":"country code is invalid"}` + // Add successful testCases + for i, registrationContactType := range data.AllRegistrationContactTypes() { + var customInviteTemplate string + var testNameSuffix string + var wallet data.Wallet + if i%2 == 0 { + customInviteTemplate = "You have a new payment waiting for you from org x. Click on the link to register." + testNameSuffix = "(w/ custom invite template)" + } + if registrationContactType.IncludesWalletAddress { + wallet = *userManagedWallet + } else { + wallet = enabledWallet + } - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) - }) + successfulTestCase := TestCase{ + name: fmt.Sprintf("🟢[%s]registration_contact_type%s", registrationContactType, testNameSuffix), + prepareMocksFn: func(t *testing.T, mMonitorService *monitorMocks.MockMonitorService) { + labels := monitor.DisbursementLabels{ + Asset: asset.Code, + Wallet: wallet.Name, + } + mMonitorService.On("MonitorCounters", monitor.DisbursementsCounterTag, labels.ToMap()).Return(nil).Once() + }, + reqBody: map[string]interface{}{ + "name": fmt.Sprintf("successful disbursement %d", i), + "asset_id": asset.ID, + "registration_contact_type": registrationContactType.String(), + "receiver_registration_message_template": customInviteTemplate, + }, + wantStatusCode: http.StatusCreated, + wantResponseBodyFn: func(d *data.Disbursement) string { + respMap := map[string]interface{}{ + "created_at": d.CreatedAt.Format(time.RFC3339Nano), + "id": d.ID, + "name": fmt.Sprintf("successful disbursement %d", i), + "receiver_registration_message_template": customInviteTemplate, + "registration_contact_type": registrationContactType.String(), + "updated_at": d.UpdatedAt.Format(time.RFC3339Nano), + "status": data.DraftDisbursementStatus, + "status_history": []map[string]interface{}{ + { + "status": data.DraftDisbursementStatus, + "timestamp": d.StatusHistory[0].Timestamp, + "user_id": user.ID, + }, + }, + "asset": map[string]interface{}{ + "code": asset.Code, + "id": asset.ID, + "issuer": asset.Issuer, + "created_at": asset.CreatedAt.Format(time.RFC3339Nano), + "updated_at": asset.UpdatedAt.Format(time.RFC3339Nano), + "deleted_at": nil, + }, + "wallet": map[string]interface{}{ + "id": wallet.ID, + "name": wallet.Name, + "deep_link_schema": wallet.DeepLinkSchema, + "homepage": wallet.Homepage, + "sep_10_client_domain": wallet.SEP10ClientDomain, + "created_at": wallet.CreatedAt.Format(time.RFC3339Nano), + "updated_at": wallet.UpdatedAt.Format(time.RFC3339Nano), + "enabled": true, + }, + } + + if !registrationContactType.IncludesWalletAddress { + respMap["verification_field"] = data.VerificationTypeDateOfBirth + } + + resp, err := json.Marshal(respMap) + require.NoError(t, err) + return string(resp) + }, + } - labels := monitor.DisbursementLabels{ - Asset: asset.Code, - Country: country.Code, - Wallet: enabledWallet.Name, + if !registrationContactType.IncludesWalletAddress { + successfulTestCase.reqBody["wallet_id"] = wallet.ID + successfulTestCase.reqBody["verification_field"] = data.VerificationTypeDateOfBirth + } + testCases = append(testCases, successfulTestCase) } - t.Run("returns error when disbursement name is not unique", func(t *testing.T) { - mMonitorService.On("MonitorCounters", monitor.DisbursementsCounterTag, labels.ToMap()).Return(nil).Once() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mAuthManager := &auth.AuthManagerMock{} + mAuthManager. + On("GetUser", mock.Anything, token). + Return(user, nil) + mMonitorService := monitorMocks.NewMockMonitorService(t) + if tc.prepareMocksFn != nil { + tc.prepareMocksFn(t, mMonitorService) + } - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: enabledWallet.ID, - VerificationField: data.VerificationFieldDateOfBirth, - }) - require.NoError(t, err) + handler := &DisbursementHandler{ + Models: models, + AuthManager: mAuthManager, + MonitorService: mMonitorService, + } - want := `{"error":"disbursement already exists"}` + requestBody, err := json.Marshal(tc.reqBody) + require.NoError(t, err) + rr := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(ctx, "POST", "/disbursements", bytes.NewReader(requestBody)) + http.HandlerFunc(handler.PostDisbursement).ServeHTTP(rr, req) + resp := rr.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) - // create disbursement - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), "", http.StatusCreated) - // try creating again - assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusConflict) - }) + require.Equalf(t, tc.wantStatusCode, resp.StatusCode, "status code doesn't match and here's the response body: %s", respBody) + var actualDisbursement *data.Disbursement + if tc.wantResponseBodyFn != nil { + require.NoError(t, json.Unmarshal(respBody, &actualDisbursement)) + } - t.Run("successfully create a disbursement", func(t *testing.T) { - mMonitorService.On("MonitorCounters", monitor.DisbursementsCounterTag, labels.ToMap()).Return(nil).Once() - - expectedName := "disbursement 2" - requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: expectedName, - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: enabledWallet.ID, - VerificationField: data.VerificationFieldDateOfBirth, - SMSRegistrationMessageTemplate: smsTemplate, + wantBody := tc.wantResponseBodyFn(actualDisbursement) + assert.JSONEq(t, wantBody, string(respBody)) }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - req, _ := http.NewRequestWithContext(ctx, method, url, strings.NewReader(string(requestBody))) - http.HandlerFunc(handler.PostDisbursement).ServeHTTP(rr, req) - - resp := rr.Result() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) - - var actualDisbursement data.Disbursement - err = json.NewDecoder(resp.Body).Decode(&actualDisbursement) - - require.NoError(t, err) - assert.Equal(t, expectedName, actualDisbursement.Name) - assert.Equal(t, data.DraftDisbursementStatus, actualDisbursement.Status) - assert.Equal(t, asset, actualDisbursement.Asset) - assert.Equal(t, &enabledWallet, actualDisbursement.Wallet) - assert.Equal(t, country, actualDisbursement.Country) - assert.Equal(t, 1, len(actualDisbursement.StatusHistory)) - assert.Equal(t, data.DraftDisbursementStatus, actualDisbursement.StatusHistory[0].Status) - assert.Equal(t, user.ID, actualDisbursement.StatusHistory[0].UserID) - assert.Equal(t, smsTemplate, actualDisbursement.SMSRegistrationMessageTemplate) - }) - - authManagerMock.AssertExpectations(t) + } } func Test_DisbursementHandler_GetDisbursements_Errors(t *testing.T) { @@ -431,7 +553,6 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { // create fixtures wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) - country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) createdByUser := auth.User{ ID: "User1", @@ -494,7 +615,6 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { StatusHistory: draftStatusHistory, Asset: asset, Wallet: wallet, - Country: country, CreatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), }) disbursement2 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ @@ -503,7 +623,6 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { StatusHistory: draftStatusHistory, Asset: asset, Wallet: wallet, - Country: country, CreatedAt: time.Date(2023, 2, 20, 23, 40, 20, 1431, time.UTC), }) disbursement3 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ @@ -512,7 +631,6 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { StatusHistory: startedStatusHistory, Asset: asset, Wallet: wallet, - Country: country, CreatedAt: time.Date(2023, 3, 19, 23, 40, 20, 1431, time.UTC), }) disbursement4 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ @@ -521,7 +639,6 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { StatusHistory: draftStatusHistory, Asset: asset, Wallet: wallet, - Country: country, CreatedAt: time.Date(2023, 4, 19, 23, 40, 20, 1431, time.UTC), }) @@ -814,14 +931,26 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { // create fixtures wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) - country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) // create disbursement - draftDisbursement := data.CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, handler.Models.Disbursements, data.Disbursement{ - Name: "disbursement1", - Asset: asset, - Country: country, - Wallet: wallet, + phoneDraftDisbursement := data.CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, handler.Models.Disbursements, data.Disbursement{ + Name: "disbursement1", + Asset: asset, + Wallet: wallet, + }) + + emailDraftDisbursement := data.CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, handler.Models.Disbursements, data.Disbursement{ + Name: "disbursement with emails", + Asset: asset, + Wallet: wallet, + RegistrationContactType: data.RegistrationContactTypeEmail, + }) + + emailWalletDraftDisbursement := data.CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, handler.Models.Disbursements, data.Disbursement{ + Name: "disbursement with emails and wallets", + Asset: asset, + Wallet: wallet, + RegistrationContactType: data.RegistrationContactTypeEmailAndWalletAddress, }) startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, handler.Models.Disbursements, &data.Disbursement{ @@ -840,16 +969,17 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { } testCases := []struct { - name string - disbursementID string - fieldName string - csvRecords [][]string - expectedStatus int - expectedMessage string + name string + disbursementID string + multipartFieldName string + actualFileName string + csvRecords [][]string + expectedStatus int + expectedMessage string }{ { name: "valid input", - disbursementID: draftDisbursement.ID, + disbursementID: phoneDraftDisbursement.ID, csvRecords: [][]string{ {"phone", "id", "amount", "verification"}, {"+380445555555", "123456789", "100.5", "1990-01-01"}, @@ -857,9 +987,53 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { expectedStatus: http.StatusOK, expectedMessage: "File uploaded successfully", }, + { + name: ".bat file fails", + disbursementID: phoneDraftDisbursement.ID, + csvRecords: [][]string{ + {"phone", "id", "amount", "verification"}, + {"+380445555555", "123456789", "100.5", "1990-01-01"}, + }, + actualFileName: "file.bat", + expectedStatus: http.StatusBadRequest, + expectedMessage: "the file extension should be .csv", + }, + { + name: ".sh file fails", + disbursementID: phoneDraftDisbursement.ID, + csvRecords: [][]string{ + {"phone", "id", "amount", "verification"}, + {"+380445555555", "123456789", "100.5", "1990-01-01"}, + }, + actualFileName: "file.sh", + expectedStatus: http.StatusBadRequest, + expectedMessage: "the file extension should be .csv", + }, + { + name: ".bash file fails", + disbursementID: phoneDraftDisbursement.ID, + csvRecords: [][]string{ + {"phone", "id", "amount", "verification"}, + {"+380445555555", "123456789", "100.5", "1990-01-01"}, + }, + actualFileName: "file.bash", + expectedStatus: http.StatusBadRequest, + expectedMessage: "the file extension should be .csv", + }, + { + name: ".csv file with transversal path ..\\.. fails", + disbursementID: phoneDraftDisbursement.ID, + csvRecords: [][]string{ + {"phone", "id", "amount", "verification"}, + {"+380445555555", "123456789", "100.5", "1990-01-01"}, + }, + actualFileName: "..\\..\\file.csv", + expectedStatus: http.StatusBadRequest, + expectedMessage: "file name contains invalid traversal pattern", + }, { name: "invalid date of birth", - disbursementID: draftDisbursement.ID, + disbursementID: phoneDraftDisbursement.ID, csvRecords: [][]string{ {"phone", "id", "amount", "verification"}, {"+380445555555", "123456789", "100.5", "1990/01/01"}, @@ -869,7 +1043,7 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { }, { name: "invalid phone number", - disbursementID: draftDisbursement.ID, + disbursementID: phoneDraftDisbursement.ID, csvRecords: [][]string{ {"phone", "id", "amount", "verification"}, {"380-12-345-678", "123456789", "100.5", "1990-01-01"}, @@ -884,11 +1058,11 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { expectedMessage: "disbursement ID is invalid", }, { - name: "valid input", - disbursementID: draftDisbursement.ID, - fieldName: "instructions", - expectedStatus: http.StatusBadRequest, - expectedMessage: "could not parse file", + name: "invalid input", + disbursementID: phoneDraftDisbursement.ID, + multipartFieldName: "instructions", + expectedStatus: http.StatusBadRequest, + expectedMessage: "could not parse file", }, { name: "disbursement not in draft/ready status", @@ -903,29 +1077,90 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { expectedMessage: "disbursement is not in draft or ready status", }, { - name: "error parsing header", - disbursementID: draftDisbursement.ID, + name: "no instructions found in file", + disbursementID: phoneDraftDisbursement.ID, csvRecords: [][]string{ - {}, + {"phone", "id", "amount", "verification"}, }, expectedStatus: http.StatusBadRequest, - expectedMessage: "could not parse file", + expectedMessage: "could not parse csv file", }, { - name: "no instructions found in file", - disbursementID: draftDisbursement.ID, + name: "headers invalid - email column missing for email contact type", + disbursementID: emailDraftDisbursement.ID, + csvRecords: [][]string{ + {"id", "amount", "verification"}, + {"123456789", "100.5", "1990-01-01"}, + }, + expectedStatus: http.StatusBadRequest, + expectedMessage: fmt.Sprintf( + "CSV columns are not valid for registration contact type %s: email column is required", + data.RegistrationContactTypeEmail), + }, + { + name: "columns invalid - email column not allowed for phone contact type", + disbursementID: phoneDraftDisbursement.ID, + csvRecords: [][]string{ + {"phone", "email", "id", "amount", "verification"}, + {"+380445555555", "foobar@test.com", "123456789", "100.5", "1990-01-01"}, + }, + expectedStatus: http.StatusBadRequest, + expectedMessage: fmt.Sprintf( + "CSV columns are not valid for registration contact type %s: email column is not allowed for this registration contact type", + data.RegistrationContactTypePhone), + }, + { + name: "columns invalid - phone column not allowed for email contact type", + disbursementID: emailDraftDisbursement.ID, + csvRecords: [][]string{ + {"phone", "email", "id", "amount", "verification"}, + {"+380445555555", "foobar@test.com", "123456789", "100.5", "1990-01-01"}, + }, + expectedStatus: http.StatusBadRequest, + expectedMessage: fmt.Sprintf( + "CSV columns are not valid for registration contact type %s: phone column is not allowed for this registration contact type", + data.RegistrationContactTypeEmail), + }, + { + name: "columns invalid - wallet column missing for email-wallet contact type", + disbursementID: emailWalletDraftDisbursement.ID, + csvRecords: [][]string{ + {"email", "id", "amount"}, + {"foobar@test.com", "123456789", "100.5"}, + }, + expectedStatus: http.StatusBadRequest, + expectedMessage: fmt.Sprintf( + "CSV columns are not valid for registration contact type %s: walletAddress column is required", + data.RegistrationContactTypeEmailAndWalletAddress), + }, + { + name: "columns invalid - verification column not allowed for wallet contact type", + disbursementID: emailWalletDraftDisbursement.ID, + csvRecords: [][]string{ + {"walletAddress", "email", "id", "amount", "verification"}, + {"GB3SAK22KSTIFQAV5GCDNPW7RTQCWGFDKALBY5KJ3JRF2DLSED3E7PVH", "foobar@test.com", "123456789", "100.5", "1990-01-01"}, + }, + expectedStatus: http.StatusBadRequest, + expectedMessage: fmt.Sprintf( + "CSV columns are not valid for registration contact type %s: verification column is not allowed for this registration contact type", + data.RegistrationContactTypeEmailAndWalletAddress), + }, + { + name: "instructions invalid - walletAddress is invalid", + disbursementID: emailWalletDraftDisbursement.ID, csvRecords: [][]string{ - {"phone", "id", "amount", "date-of-birth"}, + {"walletAddress", "email", "id", "amount"}, + {"GB3SAK22KSTIFQAV5GKALBY5KJ3JRF2DLSED3E7PVH", "foobar@test.com", "123456789", "100.5"}, }, expectedStatus: http.StatusBadRequest, - expectedMessage: "no valid instructions found", + expectedMessage: "invalid wallet address", }, { name: "max instructions exceeded", - disbursementID: draftDisbursement.ID, + disbursementID: phoneDraftDisbursement.ID, csvRecords: maxCSVRecords, expectedStatus: http.StatusBadRequest, - expectedMessage: "number of instructions exceeds maximum of : 10000", + expectedMessage: "number of instructions exceeds maximum of 10000", }, } @@ -934,7 +1169,7 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { fileContent, err := createCSVFile(t, tc.csvRecords) require.NoError(t, err) - req, err := createInstructionsMultipartRequest(t, ctx, tc.fieldName, tc.disbursementID, fileContent) + req, err := createInstructionsMultipartRequest(t, ctx, tc.multipartFieldName, tc.actualFileName, tc.disbursementID, fileContent) require.NoError(t, err) // Record the response @@ -1099,24 +1334,19 @@ func Test_DisbursementHandler_GetDisbursementReceivers(t *testing.T) { asset := data.CreateAssetFixture(t, context.Background(), dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, context.Background(), dbConnectionPool, - "FRA", - "France") // create disbursements disbursementWithReceivers := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, handler.Models.Disbursements, &data.Disbursement{ - Name: "disbursement with receivers", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement with receivers", + Status: data.DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) disbursementWithoutReceivers := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, handler.Models.Disbursements, &data.Disbursement{ - Name: "disbursement without receivers", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement without receivers", + Status: data.DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) // create disbursement receivers @@ -1159,7 +1389,7 @@ func Test_DisbursementHandler_GetDisbursementReceivers(t *testing.T) { { ID: receiver3.ID, PhoneNumber: receiver3.PhoneNumber, - Email: *receiver3.Email, + Email: receiver3.Email, ExternalID: receiver3.ExternalID, ReceiverWallet: receiverWallet3, Payment: payment3, @@ -1169,7 +1399,7 @@ func Test_DisbursementHandler_GetDisbursementReceivers(t *testing.T) { { ID: receiver2.ID, PhoneNumber: receiver2.PhoneNumber, - Email: *receiver2.Email, + Email: receiver2.Email, ExternalID: receiver2.ExternalID, ReceiverWallet: receiverWallet2, Payment: payment2, @@ -1179,7 +1409,7 @@ func Test_DisbursementHandler_GetDisbursementReceivers(t *testing.T) { { ID: receiver1.ID, PhoneNumber: receiver1.PhoneNumber, - Email: *receiver1.Email, + Email: receiver1.Email, ExternalID: receiver1.ExternalID, ReceiverWallet: receiverWallet1, Payment: payment1, @@ -1606,74 +1836,100 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { } func Test_DisbursementHandler_GetDisbursementInstructions(t *testing.T) { - ctx := context.Background() - dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, outerErr) defer dbConnectionPool.Close() + ctx := context.Background() models, outerErr := data.NewModels(dbConnectionPool) require.NoError(t, outerErr) - handler := &DisbursementHandler{ - Models: models, - } - + handler := &DisbursementHandler{Models: models} r := chi.NewRouter() r.Get("/disbursements/{id}/instructions", handler.GetDisbursementInstructions) - disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{}) - require.NotNil(t, disbursement) - - t.Run("disbursement doesn't exist", func(t *testing.T) { - id := "9e0ff65f-f6e9-46e9-bf03-dc46723e3bfb" - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/disbursements/%s/instructions", id), nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - require.Equal(t, http.StatusNotFound, rr.Code) - require.Contains(t, rr.Body.String(), services.ErrDisbursementNotFound.Error()) + disbursementFileContent := data.CreateInstructionsFixture(t, []*data.DisbursementInstruction{ + {Phone: "1234567890", ID: "1", Amount: "123.12", VerificationValue: "1995-02-20"}, + {Phone: "0987654321", ID: "2", Amount: "321", VerificationValue: "1974-07-19"}, + {Phone: "0987654321", ID: "3", Amount: "321", VerificationValue: "1974-07-19"}, }) + d := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{}) + require.NotNil(t, d) - t.Run("disbursement has no instructions", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/disbursements/%s/instructions", disbursement.ID), nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) + testCases := []struct { + name string + updateDisbursementFn func(d *data.Disbursement) error + getDisbursementIDFn func(d *data.Disbursement) string + expectedStatus int + expectedErrMessage string + wantFilename string + }{ + { + name: "404-disbursement doesn't exist", + getDisbursementIDFn: func(d *data.Disbursement) string { + return "non-existent-disbursement-id" + }, + expectedStatus: http.StatusNotFound, + expectedErrMessage: services.ErrDisbursementNotFound.Error(), + }, + { + name: "404-disbursement has no instructions", + getDisbursementIDFn: func(d *data.Disbursement) string { return d.ID }, + expectedStatus: http.StatusNotFound, + expectedErrMessage: "disbursement " + d.ID + " has no instructions file", + }, + { + name: "200-disbursement has instructions", + updateDisbursementFn: func(d *data.Disbursement) error { + return models.Disbursements.Update(ctx, &data.DisbursementUpdate{ + ID: d.ID, + FileContent: disbursementFileContent, + FileName: "instructions.csv", + }) + }, + wantFilename: "instructions.csv", + getDisbursementIDFn: func(d *data.Disbursement) string { return d.ID }, + expectedStatus: http.StatusOK, + }, + { + name: "200-disbursement has instructions but filename is missing .csv", + updateDisbursementFn: func(d *data.Disbursement) error { + return models.Disbursements.Update(ctx, &data.DisbursementUpdate{ + ID: d.ID, + FileContent: disbursementFileContent, + FileName: "instructions.bat", + }) + }, + wantFilename: "instructions.bat.csv", + getDisbursementIDFn: func(d *data.Disbursement) string { return d.ID }, + expectedStatus: http.StatusOK, + }, + } - require.Equal(t, http.StatusNotFound, rr.Code) - require.Contains(t, rr.Body.String(), fmt.Sprintf("disbursement %s has no instructions file", disbursement.ID)) - }) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.updateDisbursementFn != nil { + require.NoError(t, tc.updateDisbursementFn(d)) + } - t.Run("disbursement has instructions", func(t *testing.T) { - disbursementFileContent := data.CreateInstructionsFixture(t, []*data.DisbursementInstruction{ - {Phone: "1234567890", ID: "1", Amount: "123.12", VerificationValue: "1995-02-20"}, - {Phone: "0987654321", ID: "2", Amount: "321", VerificationValue: "1974-07-19"}, - {Phone: "0987654321", ID: "3", Amount: "321", VerificationValue: "1974-07-19"}, - }) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/disbursements/%s/instructions", tc.getDisbursementIDFn(d)), nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) - err := models.Disbursements.Update(ctx, &data.DisbursementUpdate{ - ID: disbursement.ID, - FileContent: disbursementFileContent, - FileName: "instructions.csv", + require.Equal(t, tc.expectedStatus, rr.Code) + if tc.expectedStatus != http.StatusOK { + require.Contains(t, rr.Body.String(), tc.expectedErrMessage) + } else { + t.Log(rr.Header()) + require.Equal(t, "text/csv", rr.Header().Get("Content-Type")) + require.Equal(t, "attachment; filename=\""+tc.wantFilename+"\"", rr.Header().Get("Content-Disposition")) + require.Equal(t, string(disbursementFileContent), rr.Body.String()) + } }) - require.NoError(t, err) - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/disbursements/%s/instructions", disbursement.ID), nil) - require.NoError(t, err) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - - require.Equal(t, http.StatusOK, rr.Code) - require.Equal(t, "text/csv", rr.Header().Get("Content-Type")) - require.Equal(t, "attachment; filename=\"instructions.csv\"", rr.Header().Get("Content-Disposition")) - require.Equal(t, string(disbursementFileContent), rr.Body.String()) - }) + } } func createCSVFile(t *testing.T, records [][]string) (io.Reader, error) { @@ -1687,15 +1943,19 @@ func createCSVFile(t *testing.T, records [][]string) (io.Reader, error) { return &buf, nil } -func createInstructionsMultipartRequest(t *testing.T, ctx context.Context, fieldName, disbursementID string, fileContent io.Reader) (*http.Request, error) { +func createInstructionsMultipartRequest(t *testing.T, ctx context.Context, multipartFieldName, fileName, disbursementID string, fileContent io.Reader) (*http.Request, error) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) - if fieldName == "" { - fieldName = "file" + if multipartFieldName == "" { + multipartFieldName = "file" + } + + if fileName == "" { + fileName = "instructions.csv" } - part, err := writer.CreateFormFile(fieldName, "instructions.csv") + part, err := writer.CreateFormFile(multipartFieldName, fileName) require.NoError(t, err) _, err = io.Copy(part, fileContent) @@ -1711,23 +1971,6 @@ func createInstructionsMultipartRequest(t *testing.T, ctx context.Context, field return req, nil } -func assertPOSTResponse(t *testing.T, ctx context.Context, handler *DisbursementHandler, method, url, requestBody, want string, expectedStatus int) { - rr := httptest.NewRecorder() - req, _ := http.NewRequestWithContext(ctx, method, url, strings.NewReader(requestBody)) - http.HandlerFunc(handler.PostDisbursement).ServeHTTP(rr, req) - - resp := rr.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, expectedStatus, resp.StatusCode) - - if want != "" { - assert.JSONEq(t, want, string(respBody)) - } -} - func buildURLWithQueryParams(baseURL, endpoint string, queryParams map[string]string) string { url := baseURL + endpoint if len(queryParams) > 0 { diff --git a/internal/serve/httphandler/forgot_password_handler.go b/internal/serve/httphandler/forgot_password_handler.go index ff2f00b49..c8546c373 100644 --- a/internal/serve/httphandler/forgot_password_handler.go +++ b/internal/serve/httphandler/forgot_password_handler.go @@ -1,6 +1,7 @@ package httphandler import ( + "context" "encoding/json" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" @@ -76,18 +78,28 @@ func (h ForgotPasswordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) // validate request v := validators.NewValidator() - v.Check(forgotPasswordRequest.Email != "", "email", "email is required") - if v.HasErrors() { httperror.BadRequest("request invalid", err, v.Errors).Render(w) return } - resetToken, err := h.AuthManager.ForgotPassword(ctx, forgotPasswordRequest.Email) - // if we don't find the user by email, we just return an ok response - // to prevent malicious client from searching accounts in the system + err = db.RunInTransaction(ctx, h.Models.DBConnectionPool, nil, func(tx db.DBTransaction) error { + resetToken, txErr := h.AuthManager.ForgotPassword(ctx, tx, forgotPasswordRequest.Email) + if txErr != nil { + return fmt.Errorf("resetting password: %w", txErr) + } + + sendErr := h.SendForgotPasswordMessage(ctx, *tnt.SDPUIBaseURL, forgotPasswordRequest.Email, resetToken) + if sendErr != nil { + return fmt.Errorf("sending forgot password message: %w", sendErr) + } + + return nil + }) if err != nil { + // if we don't find the user by email, we just return an ok response + // to prevent malicious client from searching accounts in the system if errors.Is(err, auth.ErrUserNotFound) { log.Ctx(ctx).Errorf("error in forgot password handler, email not found: %s", forgotPasswordRequest.Email) } else if errors.Is(err, auth.ErrUserHasValidToken) { @@ -98,50 +110,43 @@ func (h ForgotPasswordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } } - if err == nil { - organization, err := h.Models.Organizations.Get(ctx) - if err != nil { - err = fmt.Errorf("error getting organization data: %w", err) - httperror.InternalError(ctx, "", err, nil).Render(w) - return - } + responseBody := ForgotPasswordResponseBody{ + Message: "Password reset requested. If the email is registered, you'll receive a reset link shortly. Check your inbox and spam folders.", + } - resetPasswordLink, err := url.JoinPath(*tnt.SDPUIBaseURL, "reset-password") - if err != nil { - err = fmt.Errorf("error getting reset password link: %w", err) - log.Ctx(ctx).Error(err) - httperror.InternalError(ctx, "", err, nil).Render(w) - return - } + httpjson.RenderStatus(w, http.StatusOK, responseBody, httpjson.JSON) +} - forgotPasswordData := htmltemplate.ForgotPasswordMessageTemplate{ - ResetToken: resetToken, - ResetPasswordLink: resetPasswordLink, - OrganizationName: organization.Name, - } - messageContent, err := htmltemplate.ExecuteHTMLTemplateForForgotPasswordMessage(forgotPasswordData) - if err != nil { - err = fmt.Errorf("error executing forgot password message template: %w", err) - httperror.InternalError(ctx, "", err, nil).Render(w) - return - } +func (h ForgotPasswordHandler) SendForgotPasswordMessage(ctx context.Context, uiBaseURL, email, resetToken string) error { + organization, err := h.Models.Organizations.Get(ctx) + if err != nil { + return fmt.Errorf("getting organization data: %w", err) + } - msg := message.Message{ - ToEmail: forgotPasswordRequest.Email, - Title: forgotPasswordMessageTitle, - Message: messageContent, - } - err = h.MessengerClient.SendMessage(msg) - if err != nil { - err = fmt.Errorf("error sending forgot password email for email %s: %w", forgotPasswordRequest.Email, err) - httperror.InternalError(ctx, "", err, nil).Render(w) - return - } + resetPasswordLink, err := url.JoinPath(uiBaseURL, "reset-password") + if err != nil { + return fmt.Errorf("getting reset password link: %w", err) } - responseBody := ForgotPasswordResponseBody{ - Message: "Password reset requested. If the email is registered, you'll receive a reset link shortly. Check your inbox and spam folders.", + forgotPasswordData := htmltemplate.StaffForgotPasswordEmailMessageTemplate{ + ResetToken: resetToken, + ResetPasswordLink: resetPasswordLink, + OrganizationName: organization.Name, + } + messageContent, err := htmltemplate.ExecuteHTMLTemplateForStaffForgotPasswordEmailMessage(forgotPasswordData) + if err != nil { + return fmt.Errorf("executing forgot password message template: %w", err) } - httpjson.RenderStatus(w, http.StatusOK, responseBody, httpjson.JSON) + msg := message.Message{ + ToEmail: email, + Title: forgotPasswordMessageTitle, + Body: messageContent, + } + err = h.MessengerClient.SendMessage(msg) + if err != nil { + return fmt.Errorf("sending forgot password email for email %s: %w", utils.TruncateString(email, 3), err) + } + + return nil } diff --git a/internal/serve/httphandler/forgot_password_handler_test.go b/internal/serve/httphandler/forgot_password_handler_test.go index 3edb386fc..3ee873d5d 100644 --- a/internal/serve/httphandler/forgot_password_handler_test.go +++ b/internal/serve/httphandler/forgot_password_handler_test.go @@ -58,6 +58,43 @@ func Test_ForgotPasswordHandler(t *testing.T) { } ctx := tenant.SaveTenantInContext(context.Background(), &tnt) + t.Run("Should not create a token when email provider fails", func(t *testing.T) { + requestBody := ` + { + "email": "valid@email.com" , + "recaptcha_token": "validToken" + }` + + rr := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(requestBody)) + require.NoError(t, err) + + authenticatorMock. + On("ForgotPassword", req.Context(), mock.Anything, "valid@email.com"). + Return("resetToken", nil). + Once() + reCAPTCHAValidatorMock. + On("IsTokenValid", mock.Anything, "validToken"). + Return(true, nil). + Once() + + messengerClientMock. + On("SendMessage", mock.Anything). + Return(errors.New("unexpected error")). + Once() + + http.HandlerFunc(handler.ServeHTTP).ServeHTTP(rr, req) + + resp := rr.Result() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + // check that there are no tokens created in the database for this user + sql := `SELECT * FROM auth_user_password_reset JOIN auth_users ON auth_user_password_reset.auth_user_id = auth_users.id WHERE auth_users.email = $1` + rows, queryErr := dbConnectionPool.QueryContext(context.Background(), sql, "valid@email.com") + require.NoError(t, queryErr) + assert.False(t, rows.Next()) + }) + t.Run("Should return http status 200 on a valid request", func(t *testing.T) { requestBody := ` { @@ -70,7 +107,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { require.NoError(t, err) authenticatorMock. - On("ForgotPassword", req.Context(), "valid@email.com"). + On("ForgotPassword", req.Context(), mock.Anything, "valid@email.com"). Return("resetToken", nil). Once() reCAPTCHAValidatorMock. @@ -81,7 +118,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { resetPasswordLink, err := urllib.JoinPath(uiBaseURL, "reset-password") require.NoError(t, err) - content, err := htmltemplate.ExecuteHTMLTemplateForForgotPasswordMessage(htmltemplate.ForgotPasswordMessageTemplate{ + content, err := htmltemplate.ExecuteHTMLTemplateForStaffForgotPasswordEmailMessage(htmltemplate.StaffForgotPasswordEmailMessageTemplate{ ResetToken: "resetToken", ResetPasswordLink: resetPasswordLink, OrganizationName: "MyCustomAid", @@ -91,7 +128,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { msg := message.Message{ ToEmail: "valid@email.com", Title: forgotPasswordMessageTitle, - Message: content, + Body: content, } messengerClientMock. On("SendMessage", msg). @@ -121,7 +158,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { require.NoError(t, err) authenticatorMock. - On("ForgotPassword", req.Context(), "valid@email.com"). + On("ForgotPassword", req.Context(), mock.Anything, "valid@email.com"). Return("resetToken", nil). Once() reCAPTCHAValidatorMock. @@ -153,7 +190,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { require.NoError(t, err) authenticatorMock. - On("ForgotPassword", req.Context(), "not_found@email.com"). + On("ForgotPassword", req.Context(), mock.Anything, "not_found@email.com"). Return("", auth.ErrUserNotFound). Once() reCAPTCHAValidatorMock. @@ -180,7 +217,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { require.NoError(t, err) authenticatorMock. - On("ForgotPassword", req.Context(), "valid@email.com"). + On("ForgotPassword", req.Context(), mock.Anything, "valid@email.com"). Return("", auth.ErrUserHasValidToken). Once() reCAPTCHAValidatorMock. @@ -240,7 +277,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { require.NoError(t, err) authenticatorMock. - On("ForgotPassword", req.Context(), "valid@email.com"). + On("ForgotPassword", req.Context(), mock.Anything, "valid@email.com"). Return("resetToken", nil). Once() reCAPTCHAValidatorMock. @@ -251,7 +288,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { resetPasswordLink, err := urllib.JoinPath(uiBaseURL, "reset-password") require.NoError(t, err) - content, err := htmltemplate.ExecuteHTMLTemplateForForgotPasswordMessage(htmltemplate.ForgotPasswordMessageTemplate{ + content, err := htmltemplate.ExecuteHTMLTemplateForStaffForgotPasswordEmailMessage(htmltemplate.StaffForgotPasswordEmailMessageTemplate{ ResetToken: "resetToken", ResetPasswordLink: resetPasswordLink, OrganizationName: "MyCustomAid", @@ -261,7 +298,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { msg := message.Message{ ToEmail: "valid@email.com", Title: forgotPasswordMessageTitle, - Message: content, + Body: content, } messengerClientMock. On("SendMessage", msg). @@ -295,7 +332,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { require.NoError(t, err) authenticatorMock. - On("ForgotPassword", req.Context(), "valid@email.com"). + On("ForgotPassword", req.Context(), mock.Anything, "valid@email.com"). Return("", errors.New("unexpected error")). Once() reCAPTCHAValidatorMock. diff --git a/internal/serve/httphandler/login_handler.go b/internal/serve/httphandler/login_handler.go index 3f452e3a2..4835a5f57 100644 --- a/internal/serve/httphandler/login_handler.go +++ b/internal/serve/httphandler/login_handler.go @@ -147,11 +147,11 @@ func (h LoginHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - msgTemplate := htmltemplate.MFAMessageTemplate{ + msgTemplate := htmltemplate.StaffMFAEmailMessageTemplate{ MFACode: code, OrganizationName: organization.Name, } - msgContent, err := htmltemplate.ExecuteHTMLTemplateForMFAMessage(msgTemplate) + msgContent, err := htmltemplate.ExecuteHTMLTemplateForStaffMFAEmailMessage(msgTemplate) if err != nil { httperror.InternalError(ctx, "Cannot execute mfa message template", err, nil).Render(rw) return @@ -160,7 +160,7 @@ func (h LoginHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { msg := message.Message{ ToEmail: user.Email, Title: mfaMessageTitle, - Message: msgContent, + Body: msgContent, } err = h.MessengerClient.SendMessage(msg) if err != nil { diff --git a/internal/serve/httphandler/login_handler_test.go b/internal/serve/httphandler/login_handler_test.go index 6bd5a050f..05bcf2847 100644 --- a/internal/serve/httphandler/login_handler_test.go +++ b/internal/serve/httphandler/login_handler_test.go @@ -605,7 +605,7 @@ func Test_LoginHandlerr_ServeHTTP_MFA(t *testing.T) { Return("123123", nil). Once() - content, err := htmltemplate.ExecuteHTMLTemplateForMFAMessage(htmltemplate.MFAMessageTemplate{ + content, err := htmltemplate.ExecuteHTMLTemplateForStaffMFAEmailMessage(htmltemplate.StaffMFAEmailMessageTemplate{ OrganizationName: "MyCustomAid", MFACode: "123123", }) @@ -614,7 +614,7 @@ func Test_LoginHandlerr_ServeHTTP_MFA(t *testing.T) { msg := message.Message{ ToEmail: "testuser@mail.com", Title: mfaMessageTitle, - Message: content, + Body: content, } messengerClientMock. On("SendMessage", msg). diff --git a/internal/serve/httphandler/payments_handler.go b/internal/serve/httphandler/payments_handler.go index a63be3d4f..d647674e3 100644 --- a/internal/serve/httphandler/payments_handler.go +++ b/internal/serve/httphandler/payments_handler.go @@ -85,24 +85,24 @@ func (p PaymentsHandler) decorateWithCircleTransactionInfo(ctx context.Context, func (p PaymentsHandler) GetPayment(w http.ResponseWriter, r *http.Request) { paymentID := chi.URLParam(r, "id") + ctx := r.Context() - payment, err := p.Models.Payment.Get(r.Context(), paymentID, p.DBConnectionPool) + payment, err := p.Models.Payment.Get(ctx, paymentID, p.DBConnectionPool) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { errorResponse := fmt.Sprintf("Cannot retrieve payment with ID: %s", paymentID) httperror.NotFound(errorResponse, err, nil).Render(w) return } else { - ctx := r.Context() msg := fmt.Sprintf("Cannot retrieve payment with id %s", paymentID) httperror.InternalError(ctx, msg, err, nil).Render(w) return } } - payments, err := p.decorateWithCircleTransactionInfo(r.Context(), *payment) + payments, err := p.decorateWithCircleTransactionInfo(ctx, *payment) if err != nil { - httperror.InternalError(r.Context(), "Cannot retrieve payment with circle info", err, nil).Render(w) + httperror.InternalError(ctx, "Cannot retrieve payment with circle info", err, nil).Render(w) return } diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index cd5abcce3..f7571ea33 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -65,18 +65,16 @@ func Test_PaymentsHandlerGet(t *testing.T) { ctx := context.Background() asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.DraftReceiversWalletStatus) disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: data.DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) stellarTransactionID, err := utils.RandomString(64) @@ -132,7 +130,8 @@ func Test_PaymentsHandlerGet(t *testing.T) { "status": "DRAFT", "created_at": %q, "updated_at": %q, - "sms_registration_message_template":"" + "registration_contact_type": %q, + "receiver_registration_message_template":"" }, "asset": { "id": %q, @@ -150,23 +149,18 @@ func Test_PaymentsHandlerGet(t *testing.T) { "name": "wallet1", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, - "invitation_sent_at": null, - "anchor_platform_transaction_id": %q + "invitation_sent_at": null }, "created_at": %q, "updated_at": %q, "external_payment_id": %q }`, payment.ID, payment.StellarTransactionID, payment.StellarOperationID, payment.StatusHistory[0].Timestamp.Format(time.RFC3339Nano), - disbursement.ID, disbursement.CreatedAt.Format(time.RFC3339Nano), disbursement.UpdatedAt.Format(time.RFC3339Nano), - asset.ID, receiverWallet.ID, receiver.ID, wallet.ID, receiverWallet.StellarAddress, receiverWallet.StellarMemo, - receiverWallet.StellarMemoType, receiverWallet.CreatedAt.Format(time.RFC3339Nano), receiverWallet.UpdatedAt.Format(time.RFC3339Nano), - receiverWallet.AnchorPlatformTransactionID, payment.CreatedAt.Format(time.RFC3339Nano), payment.UpdatedAt.Format(time.RFC3339Nano), + disbursement.ID, disbursement.CreatedAt.Format(time.RFC3339Nano), disbursement.UpdatedAt.Format(time.RFC3339Nano), disbursement.RegistrationContactType.String(), + asset.ID, receiverWallet.ID, receiver.ID, wallet.ID, receiverWallet.CreatedAt.Format(time.RFC3339Nano), receiverWallet.UpdatedAt.Format(time.RFC3339Nano), + payment.CreatedAt.Format(time.RFC3339Nano), payment.UpdatedAt.Format(time.RFC3339Nano), payment.ExternalPaymentID) assert.JSONEq(t, wantJson, rr.Body.String()) @@ -204,13 +198,11 @@ func Test_PaymentHandler_GetPayments_CirclePayments(t *testing.T) { // Create fixtures asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Country: country, - Wallet: wallet, - Status: data.ReadyDisbursementStatus, - Asset: asset, + Wallet: wallet, + Status: data.ReadyDisbursementStatus, + Asset: asset, }) receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) @@ -473,7 +465,6 @@ func Test_PaymentHandler_GetPayments_Success(t *testing.T) { // create fixtures asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") // create receivers @@ -485,19 +476,17 @@ func Test_PaymentHandler_GetPayments_Success(t *testing.T) { // create disbursements disbursement1 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: data.DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) disbursement2 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 2", - Status: data.ReadyDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 2", + Status: data.ReadyDisbursementStatus, + Asset: asset, + Wallet: wallet, }) stellarTransactionID, err := utils.RandomString(64) @@ -770,7 +759,6 @@ func Test_PaymentHandler_GetPayments_Success(t *testing.T) { func Test_PaymentHandler_RetryPayments(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -780,18 +768,8 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { tnt := tenant.Tenant{ID: "tenant-id"} - ctx := context.Background() - ctx = tenant.SaveTenantInContext(ctx, &tnt) - - data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) - data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) - data.DeleteAllCountryFixtures(t, ctx, dbConnectionPool) - data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) - data.DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) - data.DeleteAllWalletFixtures(t, ctx, dbConnectionPool) + ctx := tenant.SaveTenantInContext(context.Background(), &tnt) - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") @@ -799,11 +777,10 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.RegisteredReceiversWalletStatus) disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Country: country, Wallet: wallet, Asset: asset, Status: data.StartedDisbursementStatus, - VerificationField: data.VerificationFieldDateOfBirth, + VerificationField: data.VerificationTypeDateOfBirth, }) t.Run("returns Unauthorized when no token in the context", func(t *testing.T) { @@ -1484,18 +1461,16 @@ func Test_PaymentsHandler_getPaymentsWithCount(t *testing.T) { }) asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.DraftReceiversWalletStatus) disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: data.DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) payment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ @@ -1537,7 +1512,8 @@ func Test_PaymentsHandler_getPaymentsWithCount(t *testing.T) { ReceiverWallet: receiverWallet, }) - response, err := handler.getPaymentsWithCount(ctx, &data.QueryParams{}) + params := data.QueryParams{SortBy: data.DefaultPaymentSortField, SortOrder: data.DefaultPaymentSortOrder} + response, err := handler.getPaymentsWithCount(ctx, ¶ms) require.NoError(t, err) assert.Equal(t, response.Total, 2) @@ -1572,15 +1548,13 @@ func Test_PaymentsHandler_PatchPaymentStatus(t *testing.T) { // create fixtures wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) - country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUSA) // create disbursements startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "ready disbursement", - Status: data.StartedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "ready disbursement", + Status: data.StartedDisbursementStatus, + Asset: asset, + Wallet: wallet, }) // create disbursement receivers diff --git a/internal/serve/httphandler/profile_handler.go b/internal/serve/httphandler/profile_handler.go index e9d054817..81ad1cbcb 100644 --- a/internal/serve/httphandler/profile_handler.go +++ b/internal/serve/httphandler/profile_handler.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "image" - "sort" // Don't remove the `image/jpeg` and `image/png` packages import unless // the `image` package is no longer necessary. @@ -19,6 +18,7 @@ import ( "io/fs" "net/http" "net/url" + "sort" "strings" "github.com/stellar/go/support/http/httpdecode" @@ -47,28 +47,22 @@ type ProfileHandler struct { PublicFilesFS fs.FS DistributionAccountResolver signing.DistributionAccountResolver PasswordValidator *authUtils.PasswordValidator + utils.NetworkType } type PatchOrganizationProfileRequest struct { - OrganizationName string `json:"organization_name"` - TimezoneUTCOffset string `json:"timezone_utc_offset"` - IsApprovalRequired *bool `json:"is_approval_required"` - SMSResendInterval *int64 `json:"sms_resend_interval"` - PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days"` - SMSRegistrationMessageTemplate *string `json:"sms_registration_message_template"` - OTPMessageTemplate *string `json:"otp_message_template"` - PrivacyPolicyLink *string `json:"privacy_policy_link"` + OrganizationName string `json:"organization_name"` + TimezoneUTCOffset string `json:"timezone_utc_offset"` + IsApprovalRequired *bool `json:"is_approval_required"` + ReceiverInvitationResendInterval *int64 `json:"receiver_invitation_resend_interval_days"` + PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days"` + ReceiverRegistrationMessageTemplate *string `json:"receiver_registration_message_template"` + OTPMessageTemplate *string `json:"otp_message_template"` + PrivacyPolicyLink *string `json:"privacy_policy_link"` } func (r *PatchOrganizationProfileRequest) AreAllFieldsEmpty() bool { - return (r.OrganizationName == "" && - r.TimezoneUTCOffset == "" && - r.IsApprovalRequired == nil && - r.SMSRegistrationMessageTemplate == nil && - r.OTPMessageTemplate == nil && - r.SMSResendInterval == nil && - r.PaymentCancellationPeriodDays == nil && - r.PrivacyPolicyLink == nil) + return r == nil || utils.IsEmpty(*r) } type PatchUserProfileRequest struct { @@ -105,7 +99,7 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht // limiting the amount of memory allocated in the server to handle the request if err := req.ParseMultipartForm(h.MaxMemoryAllocation); err != nil { - err = fmt.Errorf("error parsing multipart form: %w", err) + err = fmt.Errorf("parsing multipart form: %w", err) log.Ctx(ctx).Error(err) httperror.BadRequest("could not parse multipart form data", err, map[string]interface{}{ "details": "request too large. Max size 2MB.", @@ -115,7 +109,7 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht multipartFile, _, err := req.FormFile("logo") if err != nil && !errors.Is(err, http.ErrMissingFile) { - err = fmt.Errorf("error parsing logo file: %w", err) + err = fmt.Errorf("parsing logo file: %w", err) log.Ctx(ctx).Error(err) httperror.BadRequest("could not parse request logo", err, nil).Render(rw) return @@ -145,7 +139,7 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht var reqBody PatchOrganizationProfileRequest d := req.FormValue("data") if err = json.Unmarshal([]byte(d), &reqBody); err != nil { - err = fmt.Errorf("error decoding data: %w", err) + err = fmt.Errorf("decoding data: %w", err) log.Ctx(ctx).Error(err) httperror.BadRequest("", err, nil).Render(rw) return @@ -159,16 +153,32 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht return } + validator := validators.NewValidator() + if reqBody.PrivacyPolicyLink != nil && *reqBody.PrivacyPolicyLink != "" { + schemes := []string{"https"} + if !h.IsPubnet() { + schemes = append(schemes, "http") + } + validator.CheckError(utils.ValidateURLScheme(*reqBody.PrivacyPolicyLink, schemes...), "privacy_policy_link", "") + } + if reqBody.ReceiverRegistrationMessageTemplate != nil { + validator.CheckError(utils.ValidateNoHTMLNorJSNorCSS(*reqBody.ReceiverRegistrationMessageTemplate), "receiver_registration_message_template", "receiver_registration_message_template cannot contain HTML, JS or CSS") + } + if validator.HasErrors() { + httperror.BadRequest("", nil, validator.Errors).Render(rw) + return + } + organizationUpdate := data.OrganizationUpdate{ - Name: reqBody.OrganizationName, - Logo: fileContentBytes, - TimezoneUTCOffset: reqBody.TimezoneUTCOffset, - IsApprovalRequired: reqBody.IsApprovalRequired, - SMSRegistrationMessageTemplate: reqBody.SMSRegistrationMessageTemplate, - OTPMessageTemplate: reqBody.OTPMessageTemplate, - SMSResendInterval: reqBody.SMSResendInterval, - PaymentCancellationPeriodDays: reqBody.PaymentCancellationPeriodDays, - PrivacyPolicyLink: reqBody.PrivacyPolicyLink, + Name: reqBody.OrganizationName, + Logo: fileContentBytes, + TimezoneUTCOffset: reqBody.TimezoneUTCOffset, + IsApprovalRequired: reqBody.IsApprovalRequired, + ReceiverRegistrationMessageTemplate: reqBody.ReceiverRegistrationMessageTemplate, + OTPMessageTemplate: reqBody.OTPMessageTemplate, + ReceiverInvitationResendIntervalDays: reqBody.ReceiverInvitationResendInterval, + PaymentCancellationPeriodDays: reqBody.PaymentCancellationPeriodDays, + PrivacyPolicyLink: reqBody.PrivacyPolicyLink, } requestDict, err := utils.ConvertType[data.OrganizationUpdate, map[string]interface{}](organizationUpdate) if err != nil { @@ -358,27 +368,28 @@ func (h ProfileHandler) GetOrganizationInfo(rw http.ResponseWriter, req *http.Re } resp := map[string]interface{}{ - "name": org.Name, - "logo_url": lu.String(), - "distribution_account": distributionAccount, - "distribution_account_public_key": distributionAccount.Address, // TODO: deprecate `distribution_account_public_key` - "timezone_utc_offset": org.TimezoneUTCOffset, - "is_approval_required": org.IsApprovalRequired, - "sms_resend_interval": 0, - "payment_cancellation_period_days": 0, - "privacy_policy_link": org.PrivacyPolicyLink, + "name": org.Name, + "logo_url": lu.String(), + "distribution_account": distributionAccount, + "distribution_account_public_key": distributionAccount.Address, // TODO: deprecate `distribution_account_public_key` + "timezone_utc_offset": org.TimezoneUTCOffset, + "is_approval_required": org.IsApprovalRequired, + "receiver_invitation_resend_interval_days": 0, + "payment_cancellation_period_days": 0, + "privacy_policy_link": org.PrivacyPolicyLink, + "message_channel_priority": org.MessageChannelPriority, } - if org.SMSRegistrationMessageTemplate != data.DefaultSMSRegistrationMessageTemplate { - resp["sms_registration_message_template"] = org.SMSRegistrationMessageTemplate + if org.ReceiverRegistrationMessageTemplate != data.DefaultReceiverRegistrationMessageTemplate { + resp["receiver_registration_message_template"] = org.ReceiverRegistrationMessageTemplate } if org.OTPMessageTemplate != data.DefaultOTPMessageTemplate { resp["otp_message_template"] = org.OTPMessageTemplate } - if org.SMSResendInterval != nil { - resp["sms_resend_interval"] = *org.SMSResendInterval + if org.ReceiverInvitationResendIntervalDays != nil { + resp["receiver_invitation_resend_interval_days"] = *org.ReceiverInvitationResendIntervalDays } if org.PaymentCancellationPeriodDays != nil { diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index a38b31621..d91037a6b 100644 --- a/internal/serve/httphandler/profile_handler_test.go +++ b/internal/serve/httphandler/profile_handler_test.go @@ -32,9 +32,10 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/publicfiles" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" + authUtils "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -76,8 +77,8 @@ func resetOrganizationInfo(t *testing.T, ctx context.Context, dbConnectionPool d organizations SET name = 'MyCustomAid', logo = NULL, timezone_utc_offset = '+00:00', - sms_registration_message_template = DEFAULT, otp_message_template = DEFAULT, - sms_resend_interval = NULL, payment_cancellation_period_days = NULL, privacy_policy_link = NULL` + receiver_registration_message_template = DEFAULT, otp_message_template = DEFAULT, + receiver_invitation_resend_interval_days = NULL, payment_cancellation_period_days = NULL, privacy_policy_link = NULL` _, err := dbConnectionPool.ExecContext(ctx, q) require.NoError(t, err) } @@ -106,10 +107,17 @@ func Test_PatchOrganizationProfileRequest_AreAllFieldsEmpty(t *testing.T) { } func Test_ProfileHandler_PatchOrganizationProfile_Failures(t *testing.T) { + // Setup DB + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + // PNG file pngImg := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) pngImgBuf := new(bytes.Buffer) - err := png.Encode(pngImgBuf, pngImg) + err = png.Encode(pngImgBuf, pngImg) require.NoError(t, err) // CSV file @@ -137,6 +145,7 @@ func Test_ProfileHandler_PatchOrganizationProfile_Failures(t *testing.T) { mockAuthManagerFn func(authManagerMock *auth.AuthManagerMock) wantStatusCode int wantRespBody string + networkType utils.NetworkType }{ { name: "returns Unauthorized when no token is found", @@ -221,17 +230,128 @@ func Test_ProfileHandler_PatchOrganizationProfile_Failures(t *testing.T) { } }`, }, + { + name: "returns BadRequest when the privacy_policy_link is invalid", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "privacy_policy_link": "example.com/privacy-policy" + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "privacy_policy_link": "invalid URL format" + } + }`, + }, + { + name: "returns BadRequest when the privacy_policy_link scheme is invalid", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "privacy_policy_link": "ftp://example.com/privacy-policy" + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "privacy_policy_link": "invalid URL scheme is not part of [https http]" + } + }`, + }, + { + name: "returns BadRequest when the privacy_policy_link scheme is invalid (pubnet)", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "privacy_policy_link": "http://example.com/privacy-policy" + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) + }, + networkType: utils.PubnetNetworkType, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "privacy_policy_link": "invalid URL scheme is not part of [https]" + } + }`, + }, + { + name: "returns BadRequest when receiver_registration_message_template contains HTML", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "receiver_registration_message_template": "Redeem money" + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) + }, + networkType: utils.PubnetNetworkType, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "receiver_registration_message_template": "receiver_registration_message_template cannot contain HTML, JS or CSS" + } + }`, + }, + { + name: "returns BadRequest when receiver_registration_message_template contains JS", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "receiver_registration_message_template": "javascript:alert(localStorage.getItem('sdp_session'))" + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) + }, + networkType: utils.PubnetNetworkType, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "receiver_registration_message_template": "receiver_registration_message_template cannot contain HTML, JS or CSS" + } + }`, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Setup DB - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - // Inject authenticated token into context: ctx := context.Background() if tc.token != "" { @@ -239,11 +359,15 @@ func Test_ProfileHandler_PatchOrganizationProfile_Failures(t *testing.T) { } // Setup password validator - pwValidator, err := utils.GetPasswordValidatorInstance() + pwValidator, err := authUtils.GetPasswordValidatorInstance() require.NoError(t, err) // Setup handler with mocked dependencies - handler := &ProfileHandler{MaxMemoryAllocation: 1024 * 1024, PasswordValidator: pwValidator} + handler := &ProfileHandler{ + MaxMemoryAllocation: 1024 * 1024, + PasswordValidator: pwValidator, + NetworkType: tc.networkType, + } if tc.mockAuthManagerFn != nil { authManagerMock := &auth.AuthManagerMock{} tc.mockAuthManagerFn(authManagerMock) @@ -268,12 +392,21 @@ func Test_ProfileHandler_PatchOrganizationProfile_Failures(t *testing.T) { } func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { + // Setup DB + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + // PNG file newPNGImgBuf := func() *bytes.Buffer { pngImg := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) pngImgBuf := new(bytes.Buffer) - err := png.Encode(pngImgBuf, pngImg) - require.NoError(t, err) + innerErr := png.Encode(pngImgBuf, pngImg) + require.NoError(t, innerErr) return pngImgBuf } @@ -283,7 +416,7 @@ func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { // JPEG file jpegImg := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) jpegImgBuf := new(bytes.Buffer) - err := jpeg.Encode(jpegImgBuf, jpegImg, &jpeg.Options{Quality: jpeg.DefaultQuality}) + err = jpeg.Encode(jpegImgBuf, jpegImg, &jpeg.Options{Quality: jpeg.DefaultQuality}) require.NoError(t, err) url := "/profile/organization" @@ -346,25 +479,25 @@ func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { "organization_name": "My Org Name", "otp_message_template": "Here's your OTP Code to complete your registration. MyOrg 👋", "payment_cancellation_period_days": 2, - "sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", - "sms_resend_interval": 2, + "receiver_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", + "receiver_invitation_resend_interval_days": 2, "timezone_utc_offset": "-03:00", "privacy_policy_link": "https://example.com/privacy-policy" }` return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.png", reqBody, newPNGImgBuf()) }, resultingFieldsToCompare: map[string]interface{}{ - "IsApprovalRequired": true, - "Name": "My Org Name", - "Logo": newPNGImgBuf().Bytes(), - "OTPMessageTemplate": "Here's your OTP Code to complete your registration. MyOrg 👋", - "PaymentCancellationPeriodDays": int64(2), - "SMSRegistrationMessageTemplate": "My custom receiver wallet registration invite. MyOrg 👋", - "SMSResendInterval": int64(2), - "TimezoneUTCOffset": "-03:00", - "PrivacyPolicyLink": "https://example.com/privacy-policy", + "IsApprovalRequired": true, + "Name": "My Org Name", + "Logo": newPNGImgBuf().Bytes(), + "OTPMessageTemplate": "Here's your OTP Code to complete your registration. MyOrg 👋", + "PaymentCancellationPeriodDays": int64(2), + "ReceiverRegistrationMessageTemplate": "My custom receiver wallet registration invite. MyOrg 👋", + "ReceiverInvitationResendIntervalDays": int64(2), + "TimezoneUTCOffset": "-03:00", + "PrivacyPolicyLink": "https://example.com/privacy-policy", }, - wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [IsApprovalRequired='true', Logo='...', Name='My Org Name', OTPMessageTemplate='Here's your OTP Code to complete your registration. MyOrg 👋', PaymentCancellationPeriodDays='2', PrivacyPolicyLink='https://example.com/privacy-policy', SMSRegistrationMessageTemplate='My custom receiver wallet registration invite. MyOrg 👋', SMSResendInterval='2', TimezoneUTCOffset='-03:00']"}, + wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [IsApprovalRequired='true', Logo='...', Name='My Org Name', OTPMessageTemplate='Here's your OTP Code to complete your registration. MyOrg 👋', PaymentCancellationPeriodDays='2', PrivacyPolicyLink='https://example.com/privacy-policy', ReceiverInvitationResendIntervalDays='2', ReceiverRegistrationMessageTemplate='My custom receiver wallet registration invite. MyOrg 👋', TimezoneUTCOffset='-03:00']"}, }, { name: "🎉 successfully updates organization back to its default values", @@ -377,37 +510,37 @@ func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { }, updateOrgInitialValuesFn: func(t *testing.T, ctx context.Context, models *data.Models) { otpMessageTemplate := "custom OTPMessageTemplate" - smsRegistrationMessageTemplate := "custom SMSRegistrationMessageTemplate" - smsResendInterval := int64(123) + receiverRegistrationMessageTemplate := "custom ReceiverRegistrationMessageTemplate" + receiverInvitationResendInterval := int64(123) paymentCancellationPeriodDays := int64(456) privacyPolicyLink := "https://example.com/privacy-policy" err := models.Organizations.Update(ctx, &data.OrganizationUpdate{ - SMSRegistrationMessageTemplate: &smsRegistrationMessageTemplate, - OTPMessageTemplate: &otpMessageTemplate, - SMSResendInterval: &smsResendInterval, - PaymentCancellationPeriodDays: &paymentCancellationPeriodDays, - PrivacyPolicyLink: &privacyPolicyLink, + ReceiverRegistrationMessageTemplate: &receiverRegistrationMessageTemplate, + OTPMessageTemplate: &otpMessageTemplate, + ReceiverInvitationResendIntervalDays: &receiverInvitationResendInterval, + PaymentCancellationPeriodDays: &paymentCancellationPeriodDays, + PrivacyPolicyLink: &privacyPolicyLink, }) require.NoError(t, err) }, getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { reqBody := `{ - "sms_registration_message_template": "", + "receiver_registration_message_template": "", "otp_message_template": "", - "sms_resend_interval": 0, + "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, "privacy_policy_link": "" }` return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) }, resultingFieldsToCompare: map[string]interface{}{ - "SMSRegistrationMessageTemplate": "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register.", - "OTPMessageTemplate": "{{.OTP}} is your {{.OrganizationName}} phone verification code.", - "SMSResendInterval": nilInt64, - "PaymentCancellationPeriodDays": nilInt64, - "PrivacyPolicyLink": nilString, + "ReceiverRegistrationMessageTemplate": "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register.", + "OTPMessageTemplate": "{{.OTP}} is your {{.OrganizationName}} verification code.", + "ReceiverInvitationResendIntervalDays": nilInt64, + "PaymentCancellationPeriodDays": nilInt64, + "PrivacyPolicyLink": nilString, }, - wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [OTPMessageTemplate='', PaymentCancellationPeriodDays='0', PrivacyPolicyLink='', SMSRegistrationMessageTemplate='', SMSResendInterval='0']"}, + wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [OTPMessageTemplate='', PaymentCancellationPeriodDays='0', PrivacyPolicyLink='', ReceiverInvitationResendIntervalDays='0', ReceiverRegistrationMessageTemplate='']"}, }, } @@ -417,15 +550,6 @@ func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { log.DefaultLogger.SetOutput(buf) log.SetLevel(log.InfoLevel) - // Setup DB - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - models, err := data.NewModels(dbConnectionPool) - require.NoError(t, err) - // Inject authenticated token into context: ctx := context.Background() if tc.token != "" { @@ -447,7 +571,7 @@ func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { } // Setup password validator - pwValidator, err := utils.GetPasswordValidatorInstance() + pwValidator, err := authUtils.GetPasswordValidatorInstance() require.NoError(t, err) // Setup handler with mocked dependencies @@ -621,7 +745,7 @@ func Test_ProfileHandler_PatchUserProfile(t *testing.T) { } // Setup password validator - pwValidator, err := utils.GetPasswordValidatorInstance() + pwValidator, err := authUtils.GetPasswordValidatorInstance() require.NoError(t, err) // Setup handler with mocked dependencies @@ -802,7 +926,7 @@ func Test_ProfileHandler_PatchUserPassword(t *testing.T) { } // Setup password validator - pwValidator, err := utils.GetPasswordValidatorInstance() + pwValidator, err := authUtils.GetPasswordValidatorInstance() require.NoError(t, err) // Setup handler with mocked dependencies @@ -1134,8 +1258,9 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "timezone_utc_offset": "+00:00", "is_approval_required": false, "privacy_policy_link": null, - "sms_resend_interval": 0, - "payment_cancellation_period_days": 0 + "receiver_invitation_resend_interval_days": 0, + "payment_cancellation_period_days": 0, + "message_channel_priority": ["SMS", "EMAIL"] } `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) @@ -1143,12 +1268,12 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { assert.JSONEq(t, wantsBody, string(respBody)) }) - t.Run("returns the sms_registration_message_template and otp_message_template when they aren't the default values", func(t *testing.T) { + t.Run("returns the receiver_registration_message_template and otp_message_template when they aren't the default values", func(t *testing.T) { ctx = context.WithValue(ctx, middleware.TokenContextKey, "mytoken") msg := "My custom receiver wallet registration invite. MyOrg 👋" err := models.Organizations.Update(ctx, &data.OrganizationUpdate{ - SMSRegistrationMessageTemplate: &msg, + ReceiverRegistrationMessageTemplate: &msg, }) require.NoError(t, err) @@ -1170,10 +1295,11 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, - "sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", - "sms_resend_interval": 0, + "receiver_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", + "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, - "privacy_policy_link": null + "privacy_policy_link": null, + "message_channel_priority": ["SMS", "EMAIL"] } `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) @@ -1204,11 +1330,12 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, - "sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", + "receiver_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", "otp_message_template": "Here's your OTP Code to complete your registration. MyOrg 👋", - "sms_resend_interval": 0, + "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, - "privacy_policy_link": null + "privacy_policy_link": null, + "message_channel_priority": ["SMS", "EMAIL"] } `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) @@ -1216,14 +1343,14 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { assert.JSONEq(t, wantsBody, string(respBody)) }) - t.Run("returns the custom sms_resend_interval", func(t *testing.T) { + t.Run("returns the custom receiver_invitation_resend_interval_days", func(t *testing.T) { resetOrganizationInfo(t, ctx, dbConnectionPool) ctx = context.WithValue(ctx, middleware.TokenContextKey, "mytoken") - var smsResendInterval int64 = 2 + var resendInterval int64 = 2 err := models.Organizations.Update(ctx, &data.OrganizationUpdate{ - SMSResendInterval: &smsResendInterval, + ReceiverInvitationResendIntervalDays: &resendInterval, }) require.NoError(t, err) @@ -1245,9 +1372,10 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, - "sms_resend_interval": 2, + "receiver_invitation_resend_interval_days": 2, "payment_cancellation_period_days": 0, - "privacy_policy_link": null + "privacy_policy_link": null, + "message_channel_priority": ["SMS", "EMAIL"] } `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) @@ -1284,9 +1412,10 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, - "sms_resend_interval": 0, + "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 5, - "privacy_policy_link": null + "privacy_policy_link": null, + "message_channel_priority": ["SMS", "EMAIL"] } `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) @@ -1323,9 +1452,10 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, - "sms_resend_interval": 0, + "receiver_invitation_resend_interval_days": 0, "payment_cancellation_period_days": 0, - "privacy_policy_link": "https://example.com/privacy-policy" + "privacy_policy_link": "https://example.com/privacy-policy", + "message_channel_priority": ["SMS", "EMAIL"] } `, newDistAccountJSON(t, defaultTenantDistAcc), defaultTenantDistAcc) diff --git a/internal/serve/httphandler/receiver_handler.go b/internal/serve/httphandler/receiver_handler.go index ccc30eac9..847c5d613 100644 --- a/internal/serve/httphandler/receiver_handler.go +++ b/internal/serve/httphandler/receiver_handler.go @@ -137,5 +137,5 @@ func (rh ReceiverHandler) GetReceivers(w http.ResponseWriter, r *http.Request) { // GetReceiverVerification returns a list of verification types func (rh ReceiverHandler) GetReceiverVerificationTypes(w http.ResponseWriter, r *http.Request) { - httpjson.Render(w, data.GetAllVerificationFields(), httpjson.JSON) + httpjson.Render(w, data.GetAllVerificationTypes(), httpjson.JSON) } diff --git a/internal/serve/httphandler/receiver_handler_test.go b/internal/serve/httphandler/receiver_handler_test.go index 754d2c3a9..c72405e34 100644 --- a/internal/serve/httphandler/receiver_handler_test.go +++ b/internal/serve/httphandler/receiver_handler_test.go @@ -44,15 +44,13 @@ func Test_ReceiverHandlerGet(t *testing.T) { ctx := context.Background() asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet1 := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet1.com", "www.wallet1.com", "wallet1://") receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) disbursement := data.Disbursement{ - Status: data.DraftDisbursementStatus, - Asset: asset, - Country: country, + Status: data.DraftDisbursementStatus, + Asset: asset, } stellarTransactionID, err := utils.RandomString(64) @@ -92,7 +90,7 @@ func Test_ReceiverHandlerGet(t *testing.T) { "remaining_payments": "0", "registered_wallets": "0", "wallets": [] - }`, receiver.ID, receiver.ExternalID, *receiver.Email, receiver.PhoneNumber, receiver.CreatedAt.Format(time.RFC3339Nano), receiver.UpdatedAt.Format(time.RFC3339Nano)) + }`, receiver.ID, receiver.ExternalID, receiver.Email, receiver.PhoneNumber, receiver.CreatedAt.Format(time.RFC3339Nano), receiver.UpdatedAt.Format(time.RFC3339Nano)) assert.JSONEq(t, wantJson, rr.Body.String()) }) @@ -172,15 +170,12 @@ func Test_ReceiverHandlerGet(t *testing.T) { "sep_10_client_domain": "www.wallet1.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "1", "failed_payments": "0", @@ -192,15 +187,13 @@ func Test_ReceiverHandlerGet(t *testing.T) { "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV", "received_amount": "50.0000000" } - ], - "anchor_platform_transaction_id": %q + ] } ] - }`, receiver.ID, receiver.ExternalID, *receiver.Email, receiver.PhoneNumber, receiver.CreatedAt.Format(time.RFC3339Nano), + }`, receiver.ID, receiver.ExternalID, receiver.Email, receiver.PhoneNumber, receiver.CreatedAt.Format(time.RFC3339Nano), receiver.UpdatedAt.Format(time.RFC3339Nano), receiverWallet1.ID, receiverWallet1.Receiver.ID, receiverWallet1.Wallet.ID, - receiverWallet1.StellarAddress, receiverWallet1.StellarMemo, receiverWallet1.StellarMemoType, receiverWallet1.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.UpdatedAt.Format(time.RFC3339Nano), - message1.CreatedAt.Format(time.RFC3339Nano), message2.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.AnchorPlatformTransactionID) + message1.CreatedAt.Format(time.RFC3339Nano), message2.CreatedAt.Format(time.RFC3339Nano)) assert.JSONEq(t, wantJson, rr.Body.String()) }) @@ -281,15 +274,12 @@ func Test_ReceiverHandlerGet(t *testing.T) { "sep_10_client_domain": "www.wallet1.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "1", "failed_payments": "0", @@ -301,8 +291,7 @@ func Test_ReceiverHandlerGet(t *testing.T) { "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV", "received_amount": "50.0000000" } - ], - "anchor_platform_transaction_id": %q + ] }, { "id": %q, @@ -324,7 +313,7 @@ func Test_ReceiverHandlerGet(t *testing.T) { "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "0", "failed_payments": "0", @@ -340,11 +329,10 @@ func Test_ReceiverHandlerGet(t *testing.T) { "anchor_platform_transaction_id": %q } ] - }`, receiver.ID, receiver.ExternalID, *receiver.Email, receiver.PhoneNumber, receiver.CreatedAt.Format(time.RFC3339Nano), + }`, receiver.ID, receiver.ExternalID, receiver.Email, receiver.PhoneNumber, receiver.CreatedAt.Format(time.RFC3339Nano), receiver.UpdatedAt.Format(time.RFC3339Nano), receiverWallet1.ID, receiverWallet1.Receiver.ID, - receiverWallet1.Wallet.ID, receiverWallet1.StellarAddress, receiverWallet1.StellarMemo, receiverWallet1.StellarMemoType, - receiverWallet1.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.UpdatedAt.Format(time.RFC3339Nano), - message1.CreatedAt.Format(time.RFC3339Nano), message2.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.AnchorPlatformTransactionID, + receiverWallet1.Wallet.ID, receiverWallet1.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.UpdatedAt.Format(time.RFC3339Nano), + message1.CreatedAt.Format(time.RFC3339Nano), message2.CreatedAt.Format(time.RFC3339Nano), receiverWallet2.ID, receiverWallet2.Receiver.ID, receiverWallet2.Wallet.ID, receiverWallet2.StellarAddress, receiverWallet2.StellarMemo, receiverWallet2.StellarMemoType, receiverWallet2.CreatedAt.Format(time.RFC3339Nano), receiverWallet2.UpdatedAt.Format(time.RFC3339Nano), @@ -498,14 +486,13 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { // create fixtures asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") // create receivers date := time.Date(2022, 12, 10, 23, 40, 20, 1431, time.UTC) receiver1Email := "receiver1@mock.com" receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{ - Email: &receiver1Email, + Email: receiver1Email, ExternalID: "external_id_1", PhoneNumber: "+99991111", CreatedAt: &date, @@ -515,7 +502,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { date = time.Date(2023, 1, 10, 23, 40, 20, 1431, time.UTC) receiver2Email := "receiver2@mock.com" receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{ - Email: &receiver2Email, + Email: receiver2Email, ExternalID: "external_id_2", PhoneNumber: "+99992222", CreatedAt: &date, @@ -546,7 +533,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { date = time.Date(2023, 2, 10, 23, 40, 21, 1431, time.UTC) receiver3Email := "receiver3@mock.com" receiver3 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{ - Email: &receiver3Email, + Email: receiver3Email, ExternalID: "external_id_3", PhoneNumber: "+99993333", CreatedAt: &date, @@ -575,9 +562,8 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { }) date = time.Date(2023, 3, 10, 23, 40, 20, 1431, time.UTC) - receiver4Email := "receiver4@mock.com" receiver4 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{ - Email: &receiver4Email, + Email: "receiver4@mock.com", ExternalID: "external_id_4", PhoneNumber: "+99994444", CreatedAt: &date, @@ -607,11 +593,10 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { // create disbursements disbursement1 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, + Name: "disbursement 1", + Status: data.DraftDisbursementStatus, + Asset: asset, + Wallet: wallet, }) stellarTransactionID, err := utils.RandomString(64) @@ -687,21 +672,17 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "sep_10_client_domain": "www.wallet.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "0", "payments_received": "0", "failed_payments": "0", "canceled_payments": "0", - "remaining_payments": "0", - "anchor_platform_transaction_id": %q + "remaining_payments": "0" } ] }, @@ -746,7 +727,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "0", "failed_payments": "0", @@ -804,7 +785,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "1", "failed_payments": "0", @@ -840,9 +821,8 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { }`, receiver4.ID, receiver4.CreatedAt.Format(time.RFC3339Nano), receiver4.UpdatedAt.Format(time.RFC3339Nano), receiverWallet4.ID, receiverWallet4.Receiver.ID, receiverWallet4.Wallet.ID, - receiverWallet4.StellarAddress, receiverWallet4.StellarMemo, receiverWallet4.StellarMemoType, receiverWallet4.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.UpdatedAt.Format(time.RFC3339Nano), - message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.AnchorPlatformTransactionID, + message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano), receiver3.ID, receiver3.CreatedAt.Format(time.RFC3339Nano), receiver3.UpdatedAt.Format(time.RFC3339Nano), receiverWallet3.ID, receiverWallet3.Receiver.ID, receiverWallet3.Wallet.ID, receiverWallet3.StellarAddress, receiverWallet3.StellarMemo, receiverWallet3.StellarMemoType, @@ -947,7 +927,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "1", "failed_payments": "0", @@ -1013,30 +993,25 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "sep_10_client_domain": "www.wallet.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "0", "payments_received": "0", "failed_payments": "0", "canceled_payments": "0", - "remaining_payments": "0", - "anchor_platform_transaction_id": %q + "remaining_payments": "0" } ] } ] }`, receiver4.ID, receiver4.CreatedAt.Format(time.RFC3339Nano), receiver4.UpdatedAt.Format(time.RFC3339Nano), receiverWallet4.ID, receiverWallet4.Receiver.ID, receiverWallet4.Wallet.ID, - receiverWallet4.StellarAddress, receiverWallet4.StellarMemo, receiverWallet4.StellarMemoType, receiverWallet4.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.UpdatedAt.Format(time.RFC3339Nano), - message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.AnchorPlatformTransactionID), + message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano)), }, { name: "fetch receivers with status draft", @@ -1076,30 +1051,25 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "sep_10_client_domain": "www.wallet.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "0", "payments_received": "0", "failed_payments": "0", "canceled_payments": "0", - "remaining_payments": "0", - "anchor_platform_transaction_id": %q + "remaining_payments": "0" } ] } ] }`, receiver4.ID, receiver4.CreatedAt.Format(time.RFC3339Nano), receiver4.UpdatedAt.Format(time.RFC3339Nano), receiverWallet4.ID, receiverWallet4.Receiver.ID, receiverWallet4.Wallet.ID, - receiverWallet4.StellarAddress, receiverWallet4.StellarMemo, receiverWallet4.StellarMemoType, receiverWallet4.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.UpdatedAt.Format(time.RFC3339Nano), - message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.AnchorPlatformTransactionID), + message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano)), }, { name: "fetch receivers created before 2023-01-01", @@ -1169,30 +1139,25 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "sep_10_client_domain": "www.wallet.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "0", "payments_received": "0", "failed_payments": "0", "canceled_payments": "0", - "remaining_payments": "0", - "anchor_platform_transaction_id": %q + "remaining_payments": "0" } ] } ] }`, receiver4.ID, receiver4.CreatedAt.Format(time.RFC3339Nano), receiver4.UpdatedAt.Format(time.RFC3339Nano), receiverWallet4.ID, receiverWallet4.Receiver.ID, receiverWallet4.Wallet.ID, - receiverWallet4.StellarAddress, receiverWallet4.StellarMemo, receiverWallet4.StellarMemoType, receiverWallet4.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.UpdatedAt.Format(time.RFC3339Nano), - message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano), receiverWallet4.AnchorPlatformTransactionID), + message5.CreatedAt.Format(time.RFC3339Nano), message6.CreatedAt.Format(time.RFC3339Nano)), }, { name: "fetch receivers created after 2023-01-01 and before 2023-03-01", @@ -1251,7 +1216,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "0", "failed_payments": "0", @@ -1309,7 +1274,7 @@ func Test_ReceiverHandler_GetReceivers_Success(t *testing.T) { "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "1", "payments_received": "1", "failed_payments": "0", @@ -1439,13 +1404,13 @@ func Test_ReceiverHandler_BuildReceiversResponse(t *testing.T) { receiver1Email := "receiver1@mock.com" receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{ - Email: &receiver1Email, + Email: receiver1Email, ExternalID: "external_id_1", PhoneNumber: "+99991111", }) receiver2Email := "receiver2@mock.com" receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{ - Email: &receiver2Email, + Email: receiver2Email, ExternalID: "external_id_2", PhoneNumber: "+99992222", }) @@ -1539,21 +1504,17 @@ func Test_ReceiverHandler_BuildReceiversResponse(t *testing.T) { "sep_10_client_domain": "www.wallet.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "READY", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "0", "payments_received": "0", "failed_payments": "0", "canceled_payments": "0", - "remaining_payments": "0", - "anchor_platform_transaction_id": %q + "remaining_payments": "0" } ] }, @@ -1583,34 +1544,28 @@ func Test_ReceiverHandler_BuildReceiversResponse(t *testing.T) { "sep_10_client_domain": "www.wallet.com", "enabled": true }, - "stellar_address": %q, - "stellar_memo": %q, - "stellar_memo_type": %q, "status": "DRAFT", "created_at": %q, "updated_at": %q, "invitation_sent_at": null, "invited_at": %q, - "last_sms_sent": %q, + "last_message_sent_at": %q, "total_payments": "0", "payments_received": "0", "failed_payments": "0", "canceled_payments": "0", - "remaining_payments": "0", - "anchor_platform_transaction_id": %q + "remaining_payments": "0" } ] } ]`, receiver2.ID, receiver2.CreatedAt.Format(time.RFC3339Nano), receiver2.UpdatedAt.Format(time.RFC3339Nano), receiverWallet2.ID, receiverWallet2.Receiver.ID, receiverWallet2.Wallet.ID, - receiverWallet2.StellarAddress, receiverWallet2.StellarMemo, receiverWallet2.StellarMemoType, receiverWallet2.CreatedAt.Format(time.RFC3339Nano), receiverWallet2.UpdatedAt.Format(time.RFC3339Nano), - message3.CreatedAt.Format(time.RFC3339Nano), message4.CreatedAt.Format(time.RFC3339Nano), receiverWallet2.AnchorPlatformTransactionID, + message3.CreatedAt.Format(time.RFC3339Nano), message4.CreatedAt.Format(time.RFC3339Nano), receiver1.ID, receiver1.CreatedAt.Format(time.RFC3339Nano), receiver1.UpdatedAt.Format(time.RFC3339Nano), receiverWallet1.ID, receiverWallet1.Receiver.ID, receiverWallet1.Wallet.ID, - receiverWallet1.StellarAddress, receiverWallet1.StellarMemo, receiverWallet1.StellarMemoType, receiverWallet1.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.UpdatedAt.Format(time.RFC3339Nano), - message1.CreatedAt.Format(time.RFC3339Nano), message2.CreatedAt.Format(time.RFC3339Nano), receiverWallet1.AnchorPlatformTransactionID) + message1.CreatedAt.Format(time.RFC3339Nano), message2.CreatedAt.Format(time.RFC3339Nano)) assert.JSONEq(t, wantJson, string(ar)) diff --git a/internal/serve/httphandler/receiver_registration.go b/internal/serve/httphandler/receiver_registration.go index fbdd82178..a2201c45f 100644 --- a/internal/serve/httphandler/receiver_registration.go +++ b/internal/serve/httphandler/receiver_registration.go @@ -11,6 +11,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" htmlTpl "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type ReceiverRegistrationHandler struct { @@ -20,13 +21,14 @@ type ReceiverRegistrationHandler struct { } type ReceiverRegistrationData struct { - StellarAccount string - JWTToken string - Title string - Message string - ReCAPTCHASiteKey string - PrivacyPolicyLink string - OrganizationName string + StellarAccount string + JWTToken string + Title string + Message string + ReCAPTCHASiteKey string + PrivacyPolicyLink string + OrganizationName string + TruncatedContactInfo string } // ServeHTTP will serve the SEP-24 deposit page needed to register users. @@ -86,6 +88,7 @@ func (h ReceiverRegistrationHandler) ServeHTTP(w http.ResponseWriter, r *http.Re htmlTemplateName = "receiver_registered_successfully.tmpl" tmplData.Title = "Registration Complete 🎉" tmplData.Message = "Your Stellar wallet has been registered successfully!" + tmplData.TruncatedContactInfo = utils.TruncateString(rw.OTPConfirmedWith, 3) } registerPage, err := htmlTpl.ExecuteHTMLTemplate(htmlTemplateName, tmplData) diff --git a/internal/serve/httphandler/receiver_registration_test.go b/internal/serve/httphandler/receiver_registration_test.go index 7dc943e6d..71da8efb1 100644 --- a/internal/serve/httphandler/receiver_registration_test.go +++ b/internal/serve/httphandler/receiver_registration_test.go @@ -99,7 +99,7 @@ func Test_ReceiverRegistrationHandler_ServeHTTP(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) assert.Contains(t, string(respBody), "Wallet Registration") - assert.Contains(t, string(respBody), `
`) + assert.Contains(t, string(respBody), `