diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37d5595022..7f9496dfe9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,12 +2,11 @@ name: Tests on: push: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + workflow_dispatch: env: + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} RAILS_ENV: test STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -16,7 +15,7 @@ env: jobs: rspec: - runs-on: ubicloud-standard-2 + runs-on: ubuntu-24.04 services: postgres: @@ -31,28 +30,22 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - redis: image: redis:7.4.2 ports: - 6389:6379 options: --entrypoint redis-server - strategy: fail-fast: false matrix: ci_node_total: [2] ci_node_index: [0, 1] - steps: - uses: actions/checkout@v4 - - name: Build setup uses: ./.github/common/ - - name: Setup test database run: cd backend && bundle exec rails db:create db:schema:load - - name: Run tests env: RUBY_YJIT_ENABLE: 1 @@ -65,18 +58,14 @@ jobs: playwright: name: playwright - runs-on: ubicloud-standard-4 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - name: Build setup uses: ./.github/common/ - - run: pnpm puppeteer browsers install chrome - - run: node docker/createCertificate.js - - name: Cache Next build id: next-cache uses: actions/cache@v4 @@ -85,13 +74,10 @@ jobs: key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'backend/pnpm-lock.yaml', 'frontend/pnpm-lock.yaml') }}-${{ hashFiles('frontend/**/*.{ts,tsx}') }} restore-keys: | next-${{ runner.os }}- - - run: NODE_ENV=test pnpm run build-next --no-lint shell: bash - - name: Install foreman run: gem install foreman - - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v4 @@ -100,20 +86,16 @@ jobs: key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'backend/pnpm-lock.yaml', 'frontend/pnpm-lock.yaml') }} restore-keys: | playwright-${{ runner.os }}- - - name: Install Playwright Browsers run: pnpm playwright install --with-deps chromium if: steps.playwright-cache.outputs.cache-hit != 'true' - - name: Run docker compose run: docker compose -f docker/docker-compose-local-linux.yml up -d - - name: Run Playwright tests run: pnpm playwright test - - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: playwright-report path: playwright-report/ - retention-days: 7 + retention-days: 7 \ No newline at end of file diff --git a/backend/app/controllers/concerns/set_current.rb b/backend/app/controllers/concerns/set_current.rb index 4a592ba64e..21fd0b398e 100644 --- a/backend/app/controllers/concerns/set_current.rb +++ b/backend/app/controllers/concerns/set_current.rb @@ -26,7 +26,7 @@ def set_current if user && cookies["invitation_token"].present? invite_link = CompanyInviteLink.find_by(token: cookies["invitation_token"]) invited_company = invite_link&.company - user.update!(signup_invite_link: invite_link) if invite_link + AcceptCompanyInviteLink.new(user:, token: invite_link.token).perform if invite_link cookies.delete("invitation_token") end diff --git a/backend/app/mailers/company_worker_mailer.rb b/backend/app/mailers/company_worker_mailer.rb index 2d1cb607d6..ca18094f78 100644 --- a/backend/app/mailers/company_worker_mailer.rb +++ b/backend/app/mailers/company_worker_mailer.rb @@ -77,6 +77,17 @@ def payment_failed_reenter_bank_details(payment_id, amount, currency) mail(to: user.email, reply_to: company.email, subject: "🔴 Payment failed: re-enter your bank details") end + def payment_failed(payment_id, amount, currency) + @payment = Payment.find(payment_id) + @invoice = @payment.invoice + @currency = currency + @amount = amount + company = @invoice.company + user = @invoice.user + + mail(to: user.email, reply_to: company.email, subject: "🔴 Payment failed: payment failure for invoice ##{@invoice.id}") + end + def equity_percent_selection(company_worker_id) company_worker = CompanyWorker.find(company_worker_id) @company = company_worker.company diff --git a/backend/app/services/pay_investor_dividends.rb b/backend/app/services/pay_investor_dividends.rb index 7db37dba20..a6383fa168 100644 --- a/backend/app/services/pay_investor_dividends.rb +++ b/backend/app/services/pay_investor_dividends.rb @@ -20,7 +20,6 @@ def process user.tax_information_confirmed_at.nil? || user.bank_account_for_dividends.nil? return unless user.has_verified_tax_id? - raise "Feature unsupported for company #{company.id}" unless company.equity_enabled? raise "Flexile balance insufficient to pay for dividends to investor #{company_investor.id}" unless Wise::AccountBalance.has_sufficient_flexile_balance?(net_amount_in_usd) raise "Unknown country for user #{user.id}" if user.country_code.blank? diff --git a/backend/app/services/pay_investor_equity_buybacks.rb b/backend/app/services/pay_investor_equity_buybacks.rb index 80d6e98018..3954648b73 100644 --- a/backend/app/services/pay_investor_equity_buybacks.rb +++ b/backend/app/services/pay_investor_equity_buybacks.rb @@ -20,7 +20,6 @@ def process user.tax_information_confirmed_at.nil? return unless user.has_verified_tax_id? - raise "Feature unsupported for company #{company.id}" unless company.equity_enabled? raise "Flexile balance insufficient to pay for equity buybacks to investor #{company_investor.id}" unless Wise::AccountBalance.has_sufficient_flexile_balance?(net_amount_in_usd) raise "Unknown country for user #{user.id}" if user.country_code.blank? diff --git a/backend/app/services/pay_invoice.rb b/backend/app/services/pay_invoice.rb index b320730465..ca56e4ee70 100644 --- a/backend/app/services/pay_invoice.rb +++ b/backend/app/services/pay_invoice.rb @@ -40,7 +40,6 @@ def process account = payout_service.get_recipient_account(recipient_id: bank_account.recipient_id) unless account["active"] bank_account.mark_deleted! - CompanyWorkerMailer.payment_failed_reenter_bank_details(payment.id, amount, target_currency).deliver_later raise WiseError, "Bank account is no longer active for payment #{payment.id}" end quote = payout_service.create_quote(target_currency:, amount:, recipient_id: bank_account.recipient_id) @@ -68,6 +67,16 @@ def process rescue WiseError => e payment.update!(status: Payment::FAILED) invoice.update!(status: Invoice::FAILED) + + target_currency = payment.wise_recipient&.currency || "USD" + amount = payment.cash_amount_in_usd + + if e.message.include?("Bank account is no longer active") + CompanyWorkerMailer.payment_failed_reenter_bank_details(payment.id, amount, target_currency).deliver_later + else + CompanyWorkerMailer.payment_failed(payment.id, amount, target_currency).deliver_later + end + raise e end end diff --git a/backend/app/sidekiq/wise_transfer_update_job.rb b/backend/app/sidekiq/wise_transfer_update_job.rb index dad9774ad8..ea6e0091ee 100644 --- a/backend/app/sidekiq/wise_transfer_update_job.rb +++ b/backend/app/sidekiq/wise_transfer_update_job.rb @@ -36,6 +36,11 @@ def perform(params) if payment.is_a?(Payment) amount_cents = api_service.get_transfer(transfer_id:)["sourceValue"] * -100 payment.balance_transactions.create!(company: payment.company, amount_cents:, transaction_type: BalanceTransaction::PAYMENT_FAILED) + + transfer_details = api_service.get_transfer(transfer_id:) + amount = transfer_details["targetValue"] + currency = transfer_details["targetCurrency"] + CompanyWorkerMailer.payment_failed(payment.id, amount, currency).deliver_later end end invoice.update!(status: Invoice::FAILED) diff --git a/backend/app/views/company_worker_mailer/payment_failed.html.erb b/backend/app/views/company_worker_mailer/payment_failed.html.erb new file mode 100644 index 0000000000..020c346a09 --- /dev/null +++ b/backend/app/views/company_worker_mailer/payment_failed.html.erb @@ -0,0 +1,34 @@ +

+ There was an issue processing your payment of <%= Money.from_amount(@amount, @currency).format(with_currency: true, symbol: false, no_cents_if_whole: true) %> +

+
+
Invoice ID
+
+ <%= @invoice.invoice_number %> +
+
Invoice amount
+
+ <%= cents_format(@invoice.total_amount_in_usd_cents, with_currency: true, symbol: false) %> +
+
Bank account
+
+ ****<%= @payment.recipient_last4 %> +
+
+

+ Unfortunately, there was an issue processing your payment through Wise. This could be due to: +

+ + +

+ Please log into your account to verify your bank details are correct and up-to-date. If your bank details appear correct, please contact support for assistance. +

+ +

+ We'll attempt to process your payment again once the issue is resolved. +

diff --git a/backend/app/views/user_mailer/otp_code.html.erb b/backend/app/views/user_mailer/otp_code.html.erb index f369e9b77d..d85e3c332c 100644 --- a/backend/app/views/user_mailer/otp_code.html.erb +++ b/backend/app/views/user_mailer/otp_code.html.erb @@ -1,7 +1,3 @@ -

Your verification code

- -

Your verification code is: <%= @otp_code %>

+

Your verification code is <%= @otp_code %>

This code will expire in 10 minutes. If you didn't request this code, please ignore this email.

- -

If you're having trouble, contact us at <%= ApplicationMailer::SUPPORT_EMAIL %>.

diff --git a/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_creating_a_quote_fails_and_sends_a_notification.yml b/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_creating_a_quote_fails_and_sends_a_notification.yml new file mode 100644 index 0000000000..9842406de2 --- /dev/null +++ b/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_creating_a_quote_fails_and_sends_a_notification.yml @@ -0,0 +1,278 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.sandbox.transferwise.tech/v1/accounts + body: + encoding: UTF-8 + string: '{"currency":"GBP","type":"sort_code","details":{"legalType":"PRIVATE","email":"someone@somewhere.com","accountHolderName":"someone + somewhere","sortCode":231470,"accountNumber":28821822,"address":{"country":"GB","city":"London","firstLine":"112 + 2nd street","postCode":"SW1P 3"}},"profile":""}' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 08:07:31 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b547e13f6d9aa5-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Expires: + - '0' + Pragma: + - no-cache + X-Trace-Id: + - 1f99f28c972aa8929f31062564e00225 + Route: + - v1_account_create + X-Envoy-Upstream-Service-Time: + - '245' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=ZJz0gN7A7ypbkxk048VSUDvyxgjl82gj6O30VZ2sniA-1754554051-1.0.1.1-7L9UsQrHABkAOWYUk5HSym7gNZlkwdOO6858paOK9yrFMwF_omtQGKbwIrg_V5m4ncMYC9MArRga2RuojVLNxFE4yQuUUBwnyGdmpgElKzY; + path=/; expires=Thu, 07-Aug-25 08:37:31 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":701432364,"business":null,"profile":,"accountHolderName":"someone + somewhere","currency":"GBP","country":"GB","type":"sort_code","details":{"address":{"country":"GB","countryCode":"GB","firstLine":"112 + 2nd street","postCode":"SW1P 3","city":"London","state":null},"email":"someone@somewhere.com","legalType":"PRIVATE","accountHolderName":null,"accountNumber":"28821822","sortCode":"231470","abartn":null,"accountType":null,"bankgiroNumber":null,"ifscCode":null,"bsbCode":null,"institutionNumber":null,"transitNumber":null,"phoneNumber":null,"bankCode":null,"russiaRegion":null,"routingNumber":null,"branchCode":null,"cpf":null,"cardToken":null,"idType":null,"idNumber":null,"idCountryIso3":null,"idValidFrom":null,"idValidTo":null,"clabe":null,"swiftCode":null,"dateOfBirth":null,"clearingNumber":null,"bankName":"WISE + PAYMENTS LTD WISE PAYMENTS LIMITED","branchName":null,"businessNumber":null,"province":null,"city":null,"rut":null,"token":null,"cnpj":null,"payinReference":null,"pspReference":null,"orderId":null,"idDocumentType":null,"idDocumentNumber":null,"identificationNumber":null,"targetProfile":null,"targetUserId":null,"taxId":null,"job":null,"nationality":null,"interacAccount":null,"bban":null,"town":null,"postCode":null,"language":null,"billerCode":null,"customerReferenceNumber":null,"prefix":null,"relationship":null,"IBAN":null,"iban":null,"bic":null,"BIC":null},"user":13122309,"active":true,"ownedByCustomer":false,"confirmations":null}' + recorded_at: Thu, 07 Aug 2025 08:07:31 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v4/profiles//balances?types=STANDARD + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 08:07:31 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Pragma: + - no-cache + Expires: + - '0' + X-Frame-Options: + - DENY + X-Trace-Id: + - f32ac45f197201e8730ea983004c43ee + Route: + - v4_balance_get + X-Envoy-Upstream-Service-Time: + - '90' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=RVFdyCR8HTJUPQ7pl1aawsqNsWAxpbGCNBIvDMBBBVU-1754554051-1.0.1.1-7AirlgyFCPms_Bdzl0dl20mwWay69HF7zn8BN_xOiWmwBC.nYcuITPi.r6ipG5e_qtchJhCXaBfoPpAzPt99D4cGMINMGlo8sM7GVsAOK2g; + path=/; expires=Thu, 07-Aug-25 08:37:31 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Cf-Ray: + - 96b547e57e3f9a83-NAG + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '[{"id":285903,"currency":"GBP","amount":{"value":1000000.00,"currency":"GBP"},"reservedAmount":{"value":0.00,"currency":"GBP"},"cashAmount":{"value":1000000.00,"currency":"GBP"},"totalWorth":{"value":1000000.00,"currency":"GBP"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:38.170301Z","modificationTime":"2025-07-17T09:35:38.561910Z","visible":true,"primary":true,"groupId":null,"recipientId":701360838},{"id":285904,"currency":"EUR","amount":{"value":1000000.00,"currency":"EUR"},"reservedAmount":{"value":0.00,"currency":"EUR"},"cashAmount":{"value":1000000.00,"currency":"EUR"},"totalWorth":{"value":1000000.00,"currency":"EUR"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:38.926250Z","modificationTime":"2025-07-17T09:35:39.278111Z","visible":true,"primary":true,"groupId":null,"recipientId":701360839},{"id":285905,"currency":"USD","amount":{"value":1000000.00,"currency":"USD"},"reservedAmount":{"value":0.00,"currency":"USD"},"cashAmount":{"value":1000000.00,"currency":"USD"},"totalWorth":{"value":1000000.00,"currency":"USD"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:39.696822Z","modificationTime":"2025-07-17T09:35:40.030487Z","visible":true,"primary":true,"groupId":null,"recipientId":701360840},{"id":285855,"currency":"AUD","amount":{"value":1000000.00,"currency":"AUD"},"reservedAmount":{"value":0.00,"currency":"AUD"},"cashAmount":{"value":1000000.00,"currency":"AUD"},"totalWorth":{"value":1000000.00,"currency":"AUD"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:40.382713Z","modificationTime":"2025-07-17T09:35:40.701227Z","visible":true,"primary":true,"groupId":null,"recipientId":701360841}]' + recorded_at: Thu, 07 Aug 2025 08:07:31 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v1/rates?source=USD&target=GBP + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 08:07:31 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b547e7bcf49aa7-NAG + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Pragma: + - no-cache + Expires: + - '0' + X-Frame-Options: + - DENY + X-Trace-Id: + - 1d15a6dc955f1295de61330bb2aa53ff + Access-Control-Allow-Origin: + - "*" + Route: + - rates + X-Envoy-Upstream-Service-Time: + - '18' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=BZTnYebp58P655Tx8SE_rJQl0pKbsMWJnnsa_.3wTV8-1754554051-1.0.1.1-mlp0rCI5BmhmufGTyLI7vkdLHgBpSLkXJZ3gXpI_iISjL1ZQPUsD3OFkVylDuAmWMv_AaG4.a8azUXvWYBStgusIpEp.KMviDnwIWDt0x24; + path=/; expires=Thu, 07-Aug-25 08:37:31 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '[{"rate":0.747584,"source":"USD","target":"GBP","time":"2025-08-07T08:04:38+0000"}]' + recorded_at: Thu, 07 Aug 2025 08:07:31 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v1/accounts/701432364 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 08:07:32 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b547e94c499a75-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - fe0d40e95aefcb71cac5d87104254752 + Route: + - account_details_v1_accounts_get + X-Envoy-Upstream-Service-Time: + - '50' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=1sSG4jSNAkDkN.Eea75uZF6os.pMZStx8hmkgBHpjCA-1754554052-1.0.1.1-KhVUUfKKMnCQ10SHI5b6QinG1Qkuga_ckt4yzSdwUkwzlPp3VSpSUmODpczpBUc4QFljlrbSLxc_OvCG8FQwDo0NmCpTjVt7FvRoqGZY6AM; + path=/; expires=Thu, 07-Aug-25 08:37:32 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":701432364,"business":null,"profile":,"accountHolderName":"someone + somewhere","currency":"GBP","country":"GB","type":"sort_code","details":{"address":{"country":"GB","countryCode":"GB","firstLine":"112 + 2nd street","postCode":"SW1P 3","city":"London","state":null},"email":"someone@somewhere.com","legalType":"PRIVATE","accountHolderName":null,"accountNumber":"28821822","sortCode":"231470","abartn":null,"accountType":null,"bankgiroNumber":null,"ifscCode":null,"bsbCode":null,"institutionNumber":null,"transitNumber":null,"phoneNumber":null,"bankCode":null,"russiaRegion":null,"routingNumber":null,"branchCode":null,"cpf":null,"cardToken":null,"idType":null,"idNumber":null,"idCountryIso3":null,"idValidFrom":null,"idValidTo":null,"clabe":null,"swiftCode":null,"dateOfBirth":null,"clearingNumber":null,"bankName":null,"branchName":null,"businessNumber":null,"province":null,"city":null,"rut":null,"token":null,"cnpj":null,"payinReference":null,"pspReference":null,"orderId":null,"idDocumentType":null,"idDocumentNumber":null,"identificationNumber":null,"targetProfile":null,"targetUserId":null,"taxId":null,"job":null,"nationality":null,"interacAccount":null,"bban":null,"town":null,"postCode":null,"language":null,"billerCode":null,"customerReferenceNumber":null,"prefix":null,"relationship":null,"IBAN":null,"iban":null,"bic":null,"BIC":null},"user":13122309,"active":true,"ownedByCustomer":false,"confirmations":null}' + recorded_at: Thu, 07 Aug 2025 08:07:32 GMT +recorded_with: VCR 6.3.1 diff --git a/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_creating_a_transfer_fails_and_sends_a_notification.yml b/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_creating_a_transfer_fails_and_sends_a_notification.yml new file mode 100644 index 0000000000..f7219ca324 --- /dev/null +++ b/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_creating_a_transfer_fails_and_sends_a_notification.yml @@ -0,0 +1,433 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.sandbox.transferwise.tech/v1/accounts + body: + encoding: UTF-8 + string: '{"currency":"GBP","type":"sort_code","details":{"legalType":"PRIVATE","email":"someone@somewhere.com","accountHolderName":"someone + somewhere","sortCode":231470,"accountNumber":28821822,"address":{"country":"GB","city":"London","firstLine":"112 + 2nd street","postCode":"SW1P 3"}},"profile":""}' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:35 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - c276b905517818251e388058097808ba + Route: + - v1_account_create + X-Envoy-Upstream-Service-Time: + - '131' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=4AYbPojOK6cB_uFcfQCJc7OKQK2jt9ox5uXFWE6dhAo-1754559035-1.0.1.1-6gCTFi14BtQPD_ymkwIQtgJfqkCSkgsb26lal_4DdNUxHMegy0ae23wyJdl.O30cNEQwr2XL.ZQ0r5yI6KygnNPy292LeasrVjIUsY5Obhg; + path=/; expires=Thu, 07-Aug-25 10:00:35 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Cf-Ray: + - 96b5c191d9c19a72-NAG + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":701432364,"business":null,"profile":,"accountHolderName":"someone + somewhere","currency":"GBP","country":"GB","type":"sort_code","details":{"address":{"country":"GB","countryCode":"GB","firstLine":"112 + 2nd street","postCode":"SW1P 3","city":"London","state":null},"email":"someone@somewhere.com","legalType":"PRIVATE","accountHolderName":null,"accountNumber":"28821822","sortCode":"231470","abartn":null,"accountType":null,"bankgiroNumber":null,"ifscCode":null,"bsbCode":null,"institutionNumber":null,"transitNumber":null,"phoneNumber":null,"bankCode":null,"russiaRegion":null,"routingNumber":null,"branchCode":null,"cpf":null,"cardToken":null,"idType":null,"idNumber":null,"idCountryIso3":null,"idValidFrom":null,"idValidTo":null,"clabe":null,"swiftCode":null,"dateOfBirth":null,"clearingNumber":null,"bankName":null,"branchName":null,"businessNumber":null,"province":null,"city":null,"rut":null,"token":null,"cnpj":null,"payinReference":null,"pspReference":null,"orderId":null,"idDocumentType":null,"idDocumentNumber":null,"identificationNumber":null,"targetProfile":null,"targetUserId":null,"taxId":null,"job":null,"nationality":null,"interacAccount":null,"bban":null,"town":null,"postCode":null,"language":null,"billerCode":null,"customerReferenceNumber":null,"prefix":null,"relationship":null,"IBAN":null,"iban":null,"bic":null,"BIC":null},"user":13122309,"active":true,"ownedByCustomer":false,"confirmations":null}' + recorded_at: Thu, 07 Aug 2025 09:30:35 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v4/profiles//balances?types=STANDARD + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:36 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c1957f6a9a8d-NAG + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Expires: + - '0' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Pragma: + - no-cache + X-Trace-Id: + - 4213c2b3dcb81d4a36400e2aca2e82e7 + Route: + - v4_balance_get + X-Envoy-Upstream-Service-Time: + - '97' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=HPJj05cCgEWzIDAzZjbKWYSbALaLYzLgs7TagIaL6ZY-1754559036-1.0.1.1-lLCFahYJgZJ2wWpWGHCuo45BH9w9sEjYB5wwDU.mD0iN0lccO1C4Lt6ZOo0LJppRk196BAgMIPwQLED8sw9floOgSO9lPWDmh9Q7_yoxpr8; + path=/; expires=Thu, 07-Aug-25 10:00:36 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '[{"id":285903,"currency":"GBP","amount":{"value":1000000.00,"currency":"GBP"},"reservedAmount":{"value":0.00,"currency":"GBP"},"cashAmount":{"value":1000000.00,"currency":"GBP"},"totalWorth":{"value":1000000.00,"currency":"GBP"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:38.170301Z","modificationTime":"2025-07-17T09:35:38.561910Z","visible":true,"primary":true,"groupId":null,"recipientId":701360838},{"id":285904,"currency":"EUR","amount":{"value":1000000.00,"currency":"EUR"},"reservedAmount":{"value":0.00,"currency":"EUR"},"cashAmount":{"value":1000000.00,"currency":"EUR"},"totalWorth":{"value":1000000.00,"currency":"EUR"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:38.926250Z","modificationTime":"2025-07-17T09:35:39.278111Z","visible":true,"primary":true,"groupId":null,"recipientId":701360839},{"id":285905,"currency":"USD","amount":{"value":1000000.00,"currency":"USD"},"reservedAmount":{"value":0.00,"currency":"USD"},"cashAmount":{"value":1000000.00,"currency":"USD"},"totalWorth":{"value":1000000.00,"currency":"USD"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:39.696822Z","modificationTime":"2025-07-17T09:35:40.030487Z","visible":true,"primary":true,"groupId":null,"recipientId":701360840},{"id":285855,"currency":"AUD","amount":{"value":1000000.00,"currency":"AUD"},"reservedAmount":{"value":0.00,"currency":"AUD"},"cashAmount":{"value":1000000.00,"currency":"AUD"},"totalWorth":{"value":1000000.00,"currency":"AUD"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:40.382713Z","modificationTime":"2025-07-17T09:35:40.701227Z","visible":true,"primary":true,"groupId":null,"recipientId":701360841}]' + recorded_at: Thu, 07 Aug 2025 09:30:36 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v1/rates?source=USD&target=GBP + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:36 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c197c8fb9aa5-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Expires: + - '0' + Pragma: + - no-cache + X-Trace-Id: + - 77f9589cca66fcda2108b8c6499db62a + Access-Control-Allow-Origin: + - "*" + Route: + - rates + X-Envoy-Upstream-Service-Time: + - '20' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=wqq3aJw0AH2fy3PpKRWPBE10i4pzAeuXMgVYvpqmMWs-1754559036-1.0.1.1-HoK4FmYhW0s4WC4ikuA2hsdZ7KmtQ2alfOyFbc3TP4PcHe1qE9ZCo.yUTbmVW.9iWH0YFdcYQ9qNQ75YqWxM3BPOc9N2j63xKIWXxJtfAig; + path=/; expires=Thu, 07-Aug-25 10:00:36 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '[{"rate":0.747885,"source":"USD","target":"GBP","time":"2025-08-07T09:29:42+0000"}]' + recorded_at: Thu, 07 Aug 2025 09:30:36 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v1/accounts/701432364 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:36 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c19bc9cf9a6f-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - 20c0b12c4970d1108fce043bc419db4e + Route: + - account_details_v1_accounts_get + X-Envoy-Upstream-Service-Time: + - '41' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=l7dUZdS.7WmpIgzQ4c6NgjS77emQhzAGABlrSftZLS0-1754559036-1.0.1.1-MDspZG1kj2Xu4Cxv32bXV_r.Bn1rK9itQaUzLq2wvgPC8WYkBk9C.xD9QQa0BM2dBI4yrk3V1Tud3zeERaDfO4MbkPx5peYQ69Y8QiVKFVI; + path=/; expires=Thu, 07-Aug-25 10:00:36 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":701432364,"business":null,"profile":,"accountHolderName":"someone + somewhere","currency":"GBP","country":"GB","type":"sort_code","details":{"address":{"country":"GB","countryCode":"GB","firstLine":"112 + 2nd street","postCode":"SW1P 3","city":"London","state":null},"email":"someone@somewhere.com","legalType":"PRIVATE","accountHolderName":null,"accountNumber":"28821822","sortCode":"231470","abartn":null,"accountType":null,"bankgiroNumber":null,"ifscCode":null,"bsbCode":null,"institutionNumber":null,"transitNumber":null,"phoneNumber":null,"bankCode":null,"russiaRegion":null,"routingNumber":null,"branchCode":null,"cpf":null,"cardToken":null,"idType":null,"idNumber":null,"idCountryIso3":null,"idValidFrom":null,"idValidTo":null,"clabe":null,"swiftCode":null,"dateOfBirth":null,"clearingNumber":null,"bankName":null,"branchName":null,"businessNumber":null,"province":null,"city":null,"rut":null,"token":null,"cnpj":null,"payinReference":null,"pspReference":null,"orderId":null,"idDocumentType":null,"idDocumentNumber":null,"identificationNumber":null,"targetProfile":null,"targetUserId":null,"taxId":null,"job":null,"nationality":null,"interacAccount":null,"bban":null,"town":null,"postCode":null,"language":null,"billerCode":null,"customerReferenceNumber":null,"prefix":null,"relationship":null,"IBAN":null,"iban":null,"bic":null,"BIC":null},"user":13122309,"active":true,"ownedByCustomer":false,"confirmations":null}' + recorded_at: Thu, 07 Aug 2025 09:30:36 GMT +- request: + method: post + uri: https://api.sandbox.transferwise.tech/v3/profiles//quotes + body: + encoding: UTF-8 + string: '{"sourceAmount":null,"targetAmount":44.8731,"sourceCurrency":"USD","targetAccount":"701432364","targetCurrency":"GBP","profileId":"","preferredPayIn":"BALANCE"}' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:37 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - 4e53f2b00e5523470c7bd4657e493d56 + Route: + - v3_quotes_quote_create + X-Envoy-Upstream-Service-Time: + - '630' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=Rqd9X8kib__65oo2fn6nd8yGOO5UTcVZXiDmgSBXTe0-1754559037-1.0.1.1-.8fPnYHZ6DhvXRM8HhR8FnIgSVSPZtNN13gSwwh_5xAbkxC.4h3AqWIOXwcM0RD4FOkl2eDz3eZGtalrffz7BG.2eGvs1x2bGcLPALDj42w; + path=/; expires=Thu, 07-Aug-25 10:00:37 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Cf-Ray: + - 96b5c19dbc569a9f-NAG + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"targetAmount":44.87,"guaranteedTargetAmountAllowed":false,"targetAmountAllowed":true,"preferredPayIn":"BALANCE","paymentOptions":[{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0130,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"BALANCE","price":{"priceSetId":558,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":0.79,"currency":"USD","label":"0.79 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.0,"currency":"USD","label":"0 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":0.79,"currency":"USD","label":"0.79 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":0.79,"payIn":0.0,"discount":0,"total":0.79,"priceSetId":558,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":60.79,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"GOOGLE_PAY","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"APPLE_PAY","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0207,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"DIRECT_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.27,"currency":"USD","label":"1.27 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.15,"currency":"USD","label":"0.15 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.15,"discount":0,"total":1.27,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":61.27,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + 5 hours","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.1075,"estimatedDelivery":"2025-08-07T14:20:00Z","payIn":"BANK_TRANSFER","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":7.23,"currency":"USD","label":"7.23 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":6.11,"currency":"USD","label":"6.11 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":6.11,"discount":0,"total":7.23,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":67.23,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"by + Thursday, August 14","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.1075,"estimatedDelivery":"2025-08-14T18:00:00Z","payIn":"SWIFT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":7.23,"currency":"USD","label":"7.23 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":6.11,"currency":"USD","label":"6.11 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":6.11,"discount":0,"total":7.23,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":67.23,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0663,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"INTERNATIONAL_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":4.26,"currency":"USD","label":"4.26 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":3.14,"currency":"USD","label":"3.14 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":3.14,"discount":0,"total":4.26,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":64.26,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0792,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"MC_BUSINESS_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.16,"currency":"USD","label":"5.16 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.04,"currency":"USD","label":"4.04 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":4.04,"discount":0,"total":5.16,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":65.16,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"MC_DEBIT_OR_PREPAID","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"CARD","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"MAESTRO","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0475,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"INT_DEBIT_WITH_EUROPEAN_CARD","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":2.99,"currency":"USD","label":"2.99 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":1.87,"currency":"USD","label":"1.87 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":1.87,"discount":0,"total":2.99,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":62.99,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0475,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"INT_CREDIT_WITH_EUROPEAN_CARD","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":2.99,"currency":"USD","label":"2.99 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":1.87,"currency":"USD","label":"1.87 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":1.87,"discount":0,"total":2.99,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":62.99,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0335,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"VISA_BUSINESS_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":2.08,"currency":"USD","label":"2.08 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.96,"currency":"USD","label":"0.96 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.96,"discount":0,"total":2.08,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":62.08,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"VISA_DEBIT_OR_PREPAID","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"MC_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0532,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"MC_BUSINESS_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":3.37,"currency":"USD","label":"3.37 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":2.25,"currency":"USD","label":"2.25 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":2.25,"discount":0,"total":3.37,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":63.37,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0820,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"VISA_BUSINESS_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.36,"currency":"USD","label":"5.36 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.24,"currency":"USD","label":"4.24 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":4.24,"discount":0,"total":5.36,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":65.36,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0663,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"INTERNATIONAL_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":4.26,"currency":"USD","label":"4.26 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":3.14,"currency":"USD","label":"3.14 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":3.14,"discount":0,"total":4.26,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":64.26,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0699,"estimatedDelivery":"2025-08-07T09:30:37Z","payIn":"VISA_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":4.51,"currency":"USD","label":"4.51 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":3.39,"currency":"USD","label":"3.39 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f78fe-650c-4239-0a31-a929d1bb37f5"},"fee":{"transferwise":1.12,"payIn":3.39,"discount":0,"total":4.51,"priceSetId":556,"partner":0.0},"payOut":"BANK_TRANSFER","sourceAmount":64.51,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","disabled":false}],"notices":[],"transferFlowConfig":{"highAmount":{"showFeePercentage":false,"trackAsHighAmountSender":false,"showEducationStep":false,"offerPrefundingOption":false,"overLimitThroughCs":false,"overLimitThroughWiseAccount":false}},"rateTimestamp":"2025-08-07T09:29:42Z","targetAccountDetailsOwnedByProfile":false,"clientId":"transferwise-personal-tokens","guaranteedTargetAmount":false,"rateExpirationTime":"2025-08-07T21:59:59Z","providedAmountType":"TARGET","targetAccount":701432364,"payInCountry":"DE","payOutCountry":"GB","createdTime":"2025-08-07T09:30:37Z","rateType":"FIXED","payOut":"BANK_TRANSFER","funding":"POST","user":13122309,"profile":,"sourceCurrency":"USD","targetCurrency":"GBP","rate":0.747885,"status":"PENDING","expirationTime":"2025-08-07T10:00:37Z","id":"ce5475b8-5809-4ec9-9039-25d6ae9b74e5","type":"REGULAR"}' + recorded_at: Thu, 07 Aug 2025 09:30:37 GMT +recorded_with: VCR 6.3.1 diff --git a/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_funding_fails_and_sends_a_notification.yml b/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_funding_fails_and_sends_a_notification.yml new file mode 100644 index 0000000000..f56e860a8f --- /dev/null +++ b/backend/spec/fixtures/vcr_cassettes/PayInvoice/when_payment_method_is_setup/errors/marks_the_invoice_and_payment_as_failed_if_funding_fails_and_sends_a_notification.yml @@ -0,0 +1,502 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.sandbox.transferwise.tech/v1/accounts + body: + encoding: UTF-8 + string: '{"currency":"GBP","type":"sort_code","details":{"legalType":"PRIVATE","email":"someone@somewhere.com","accountHolderName":"someone + somewhere","sortCode":231470,"accountNumber":28821822,"address":{"country":"GB","city":"London","firstLine":"112 + 2nd street","postCode":"SW1P 3"}},"profile":""}' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:38 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - 9ef19de0318f7d75b9b50b997015ee46 + Route: + - v1_account_create + X-Envoy-Upstream-Service-Time: + - '120' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=8WDI9LrI4RHSOEv4u.EwOAp1xtawT8xNFJXxOviieNc-1754559038-1.0.1.1-TI5EmpkmTif_SZ.JZrlFvjp938yHz893M7NjnBdMjLTM1rhcpJWWHYzzWdBLNWtwjm.o.AEww63.UX69GuSU2SP9or2lRrmd9eW6tPsFVfQ; + path=/; expires=Thu, 07-Aug-25 10:00:38 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Cf-Ray: + - 96b5c1a51c659a8a-NAG + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":701432364,"business":null,"profile":,"accountHolderName":"someone + somewhere","currency":"GBP","country":"GB","type":"sort_code","details":{"address":{"country":"GB","countryCode":"GB","firstLine":"112 + 2nd street","postCode":"SW1P 3","city":"London","state":null},"email":"someone@somewhere.com","legalType":"PRIVATE","accountHolderName":null,"accountNumber":"28821822","sortCode":"231470","abartn":null,"accountType":null,"bankgiroNumber":null,"ifscCode":null,"bsbCode":null,"institutionNumber":null,"transitNumber":null,"phoneNumber":null,"bankCode":null,"russiaRegion":null,"routingNumber":null,"branchCode":null,"cpf":null,"cardToken":null,"idType":null,"idNumber":null,"idCountryIso3":null,"idValidFrom":null,"idValidTo":null,"clabe":null,"swiftCode":null,"dateOfBirth":null,"clearingNumber":null,"bankName":null,"branchName":null,"businessNumber":null,"province":null,"city":null,"rut":null,"token":null,"cnpj":null,"payinReference":null,"pspReference":null,"orderId":null,"idDocumentType":null,"idDocumentNumber":null,"identificationNumber":null,"targetProfile":null,"targetUserId":null,"taxId":null,"job":null,"nationality":null,"interacAccount":null,"bban":null,"town":null,"postCode":null,"language":null,"billerCode":null,"customerReferenceNumber":null,"prefix":null,"relationship":null,"IBAN":null,"iban":null,"bic":null,"BIC":null},"user":13122309,"active":true,"ownedByCustomer":false,"confirmations":null}' + recorded_at: Thu, 07 Aug 2025 09:30:38 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v4/profiles//balances?types=STANDARD + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:38 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c1a7ac039a99-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - 7da7b06cb43821e854249b9e0e6f3a08 + Route: + - v4_balance_get + X-Envoy-Upstream-Service-Time: + - '91' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=Q7nl.415f9Ee0Gj8jEif3j0B__nuqoltqEAgY24JEIY-1754559038-1.0.1.1-w89pck5cgUwPmae4_wo1Bgkmp4vKF5h8rEA3rlh30KAk4ELN7YlglmJHkp8n67ZBasb8hJWapNC0U_zutAbwBrp0xCmxZ_D57tNosS1H.2M; + path=/; expires=Thu, 07-Aug-25 10:00:38 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '[{"id":285903,"currency":"GBP","amount":{"value":1000000.00,"currency":"GBP"},"reservedAmount":{"value":0.00,"currency":"GBP"},"cashAmount":{"value":1000000.00,"currency":"GBP"},"totalWorth":{"value":1000000.00,"currency":"GBP"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:38.170301Z","modificationTime":"2025-07-17T09:35:38.561910Z","visible":true,"primary":true,"groupId":null,"recipientId":701360838},{"id":285904,"currency":"EUR","amount":{"value":1000000.00,"currency":"EUR"},"reservedAmount":{"value":0.00,"currency":"EUR"},"cashAmount":{"value":1000000.00,"currency":"EUR"},"totalWorth":{"value":1000000.00,"currency":"EUR"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:38.926250Z","modificationTime":"2025-07-17T09:35:39.278111Z","visible":true,"primary":true,"groupId":null,"recipientId":701360839},{"id":285905,"currency":"USD","amount":{"value":1000000.00,"currency":"USD"},"reservedAmount":{"value":0.00,"currency":"USD"},"cashAmount":{"value":1000000.00,"currency":"USD"},"totalWorth":{"value":1000000.00,"currency":"USD"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:39.696822Z","modificationTime":"2025-07-17T09:35:40.030487Z","visible":true,"primary":true,"groupId":null,"recipientId":701360840},{"id":285855,"currency":"AUD","amount":{"value":1000000.00,"currency":"AUD"},"reservedAmount":{"value":0.00,"currency":"AUD"},"cashAmount":{"value":1000000.00,"currency":"AUD"},"totalWorth":{"value":1000000.00,"currency":"AUD"},"type":"STANDARD","name":null,"icon":null,"investmentState":"NOT_INVESTED","creationTime":"2025-07-17T09:35:40.382713Z","modificationTime":"2025-07-17T09:35:40.701227Z","visible":true,"primary":true,"groupId":null,"recipientId":701360841}]' + recorded_at: Thu, 07 Aug 2025 09:30:38 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v1/rates?source=USD&target=GBP + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:39 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c1a9da409a8a-NAG + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Expires: + - '0' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Pragma: + - no-cache + X-Trace-Id: + - 2e08ff05c8747ac2d571043714a820d1 + Access-Control-Allow-Origin: + - "*" + Route: + - rates + X-Envoy-Upstream-Service-Time: + - '17' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=Cq5VXSjJLICnBJV6vZ.dbymJFxSjrdeS5d13HkO08S8-1754559039-1.0.1.1-7ydAPEwPHpOi6.6msJeTo.2KvYNsQWH9DYccLRqZy4zJQhpGHLt1SuFqrb9uI5G9YwPqVH02XUw.0VLDckPnakM1ZbK2vuNgZqg9Jht8XH8; + path=/; expires=Thu, 07-Aug-25 10:00:39 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '[{"rate":0.747885,"source":"USD","target":"GBP","time":"2025-08-07T09:29:42+0000"}]' + recorded_at: Thu, 07 Aug 2025 09:30:39 GMT +- request: + method: get + uri: https://api.sandbox.transferwise.tech/v1/accounts/701432364 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:39 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c1ab9c5f9a8a-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Trace-Id: + - 3b5e8a13a7fc51733d2c77d454ebd591 + Route: + - account_details_v1_accounts_get + X-Envoy-Upstream-Service-Time: + - '42' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=2G9XfbUsvEnRGAhH.kVjD_ADwvuEpiFpVGKVoi7sZsk-1754559039-1.0.1.1-To2cNlpvFNeNZhH9E2z4qjEMb1T3l3dRXSZMhnRC4GJJBconkNv8ArK4LG7jAUvqMenFiBsbsEoHxgQIZQNRNZfsU9C9NCTyBX19VS2JSO8; + path=/; expires=Thu, 07-Aug-25 10:00:39 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":701432364,"business":null,"profile":,"accountHolderName":"someone + somewhere","currency":"GBP","country":"GB","type":"sort_code","details":{"address":{"country":"GB","countryCode":"GB","firstLine":"112 + 2nd street","postCode":"SW1P 3","city":"London","state":null},"email":"someone@somewhere.com","legalType":"PRIVATE","accountHolderName":null,"accountNumber":"28821822","sortCode":"231470","abartn":null,"accountType":null,"bankgiroNumber":null,"ifscCode":null,"bsbCode":null,"institutionNumber":null,"transitNumber":null,"phoneNumber":null,"bankCode":null,"russiaRegion":null,"routingNumber":null,"branchCode":null,"cpf":null,"cardToken":null,"idType":null,"idNumber":null,"idCountryIso3":null,"idValidFrom":null,"idValidTo":null,"clabe":null,"swiftCode":null,"dateOfBirth":null,"clearingNumber":null,"bankName":null,"branchName":null,"businessNumber":null,"province":null,"city":null,"rut":null,"token":null,"cnpj":null,"payinReference":null,"pspReference":null,"orderId":null,"idDocumentType":null,"idDocumentNumber":null,"identificationNumber":null,"targetProfile":null,"targetUserId":null,"taxId":null,"job":null,"nationality":null,"interacAccount":null,"bban":null,"town":null,"postCode":null,"language":null,"billerCode":null,"customerReferenceNumber":null,"prefix":null,"relationship":null,"IBAN":null,"iban":null,"bic":null,"BIC":null},"user":13122309,"active":true,"ownedByCustomer":false,"confirmations":null}' + recorded_at: Thu, 07 Aug 2025 09:30:39 GMT +- request: + method: post + uri: https://api.sandbox.transferwise.tech/v3/profiles//quotes + body: + encoding: UTF-8 + string: '{"sourceAmount":null,"targetAmount":44.8731,"sourceCurrency":"USD","targetAccount":"701432364","targetCurrency":"GBP","profileId":"","preferredPayIn":"BALANCE"}' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:40 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Expires: + - '0' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Pragma: + - no-cache + X-Trace-Id: + - d9cf8ddd4d7c85a5feae1bf12d6013c6 + Route: + - v3_quotes_quote_create + X-Envoy-Upstream-Service-Time: + - '245' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=O6KD4wZq3ma65TBUqhLol2WUZmoJ8t3qIV7uqxYmejw-1754559040-1.0.1.1-vf7COvw7z1YYr6wOIN8L3tVv4LJiHfzae0sjwWeFoT.xrdVK2RTsrttjbbmIrysQRAutlQNHQ7ul6AlhHRz_et6NZ.ceKedn0ia9tvLZ_i8; + path=/; expires=Thu, 07-Aug-25 10:00:40 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Cf-Ray: + - 96b5c1ae3c009a99-NAG + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"targetAmount":44.87,"guaranteedTargetAmountAllowed":false,"targetAmountAllowed":true,"preferredPayIn":"BALANCE","paymentOptions":[{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0130,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":60.79,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"BALANCE","price":{"priceSetId":558,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":0.79,"currency":"USD","label":"0.79 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.0,"currency":"USD","label":"0 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":0.79,"currency":"USD","label":"0.79 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":0.79,"payIn":0.0,"discount":0,"total":0.79,"priceSetId":558,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"GOOGLE_PAY","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"APPLE_PAY","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0207,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":61.27,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"DIRECT_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.27,"currency":"USD","label":"1.27 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.15,"currency":"USD","label":"0.15 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.15,"discount":0,"total":1.27,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + 5 hours","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.1075,"estimatedDelivery":"2025-08-07T14:20:00Z","sourceAmount":67.23,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"BANK_TRANSFER","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":7.23,"currency":"USD","label":"7.23 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":6.11,"currency":"USD","label":"6.11 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":6.11,"discount":0,"total":7.23,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"by + Thursday, August 14","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.1075,"estimatedDelivery":"2025-08-14T18:00:00Z","sourceAmount":67.23,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"SWIFT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":7.23,"currency":"USD","label":"7.23 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":6.11,"currency":"USD","label":"6.11 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":6.11,"discount":0,"total":7.23,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0663,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":64.26,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"INTERNATIONAL_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":4.26,"currency":"USD","label":"4.26 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":3.14,"currency":"USD","label":"3.14 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":3.14,"discount":0,"total":4.26,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0792,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":65.16,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"MC_BUSINESS_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.16,"currency":"USD","label":"5.16 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.04,"currency":"USD","label":"4.04 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":4.04,"discount":0,"total":5.16,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"MC_DEBIT_OR_PREPAID","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"CARD","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"MAESTRO","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0475,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":62.99,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"INT_DEBIT_WITH_EUROPEAN_CARD","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":2.99,"currency":"USD","label":"2.99 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":1.87,"currency":"USD","label":"1.87 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":1.87,"discount":0,"total":2.99,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0475,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":62.99,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"INT_CREDIT_WITH_EUROPEAN_CARD","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":2.99,"currency":"USD","label":"2.99 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":1.87,"currency":"USD","label":"1.87 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":1.87,"discount":0,"total":2.99,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0335,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":62.08,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"VISA_BUSINESS_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":2.08,"currency":"USD","label":"2.08 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.96,"currency":"USD","label":"0.96 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.96,"discount":0,"total":2.08,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0310,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":61.92,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"VISA_DEBIT_OR_PREPAID","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":1.92,"currency":"USD","label":"1.92 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":0.8,"currency":"USD","label":"0.80 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":0.8,"discount":0,"total":1.92,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0789,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":65.14,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"MC_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.14,"currency":"USD","label":"5.14 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.02,"currency":"USD","label":"4.02 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":4.02,"discount":0,"total":5.14,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0532,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":63.37,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"MC_BUSINESS_DEBIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":3.37,"currency":"USD","label":"3.37 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":2.25,"currency":"USD","label":"2.25 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":2.25,"discount":0,"total":3.37,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0820,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":65.36,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"VISA_BUSINESS_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":5.36,"currency":"USD","label":"5.36 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":4.24,"currency":"USD","label":"4.24 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":4.24,"discount":0,"total":5.36,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0663,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":64.26,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"INTERNATIONAL_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":4.26,"currency":"USD","label":"4.26 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":3.14,"currency":"USD","label":"3.14 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":3.14,"discount":0,"total":4.26,"priceSetId":556,"partner":0.0},"disabled":false},{"formattedEstimatedDelivery":"in + seconds","estimatedDeliveryDelays":[],"allowedProfileTypes":["PERSONAL","BUSINESS"],"feePercentage":0.0699,"estimatedDelivery":"2025-08-07T09:30:39Z","sourceAmount":64.51,"targetAmount":44.87,"sourceCurrency":"USD","targetCurrency":"GBP","payOut":"BANK_TRANSFER","payIn":"VISA_CREDIT","price":{"priceSetId":556,"total":{"type":"TOTAL","label":"Total + fees","value":{"amount":4.51,"currency":"USD","label":"4.51 USD"}},"items":[{"type":"PAYIN","label":"fee","value":{"amount":3.39,"currency":"USD","label":"3.39 + USD"}},{"type":"TRANSFERWISE","label":"Our fee","value":{"amount":1.12,"currency":"USD","label":"1.12 + USD"}}],"priceDecisionReferenceId":"620f7926-59aa-425d-02a4-2c085e4d509b"},"fee":{"transferwise":1.12,"payIn":3.39,"discount":0,"total":4.51,"priceSetId":556,"partner":0.0},"disabled":false}],"notices":[],"transferFlowConfig":{"highAmount":{"showFeePercentage":false,"trackAsHighAmountSender":false,"showEducationStep":false,"offerPrefundingOption":false,"overLimitThroughCs":false,"overLimitThroughWiseAccount":false}},"rateTimestamp":"2025-08-07T09:29:42Z","targetAccountDetailsOwnedByProfile":false,"clientId":"transferwise-personal-tokens","sourceCurrency":"USD","targetCurrency":"GBP","createdTime":"2025-08-07T09:30:39Z","rateType":"FIXED","payOut":"BANK_TRANSFER","funding":"POST","rateExpirationTime":"2025-08-07T21:59:59Z","providedAmountType":"TARGET","targetAccount":701432364,"payInCountry":"DE","payOutCountry":"GB","user":13122309,"profile":,"rate":0.747885,"guaranteedTargetAmount":false,"status":"PENDING","expirationTime":"2025-08-07T10:00:39Z","id":"f21f78f3-c5f8-47c3-8dac-971f09699968","type":"REGULAR"}' + recorded_at: Thu, 07 Aug 2025 09:30:40 GMT +- request: + method: post + uri: https://api.sandbox.transferwise.tech/v1/transfers + body: + encoding: UTF-8 + string: '{"targetAccount":"701432364","quoteUuid":"f21f78f3-c5f8-47c3-8dac-971f09699968","customerTransactionId":"b11095fe-ccc7-47bf-984b-5285f3aa63d1","details":{"transferPurpose":"verification.transfers.purpose.pay.other","sourceOfFunds":"verification.source.of.funds.other","reference":"PMT"}}' + headers: + Authorization: + - Bearer 8ead1034-8730-46a5-8750-7ce0482ef0bf + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 07 Aug 2025 09:30:41 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 96b5c1b17a519a95-NAG + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + X-Frame-Options: + - DENY + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Expires: + - '0' + Pragma: + - no-cache + X-Trace-Id: + - f5d266b5cc33eea36420f799169997ea + Route: + - public_transfer_post_transfer + X-Envoy-Upstream-Service-Time: + - '857' + X-Envoy-Attempt-Count: + - '1' + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=deQQ2i4rbIrNuescRxDmQG0J1xruOmtWq9aNN.lfjMA-1754559041-1.0.1.1-pwZnmhz_8GT6ohonoY11wVRnZdh.LZFtwns2Rcw.U71Cjw9cFLSFTi19PJv53ntyp21G9JOXsiCT8ek9zAdZwEGH3opfp7h4gA57B4JKQTs; + path=/; expires=Thu, 07-Aug-25 10:00:41 GMT; domain=.transferwise.tech; HttpOnly; + Secure; SameSite=None + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":55309977,"user":13122309,"targetAccount":701432364,"sourceAccount":null,"quote":null,"quoteUuid":"f21f78f3-c5f8-47c3-8dac-971f09699968","status":"incoming_payment_waiting","reference":"PMT","rate":0.747885,"created":"2025-08-07 + 09:30:41","business":null,"transferRequest":null,"details":{"reference":"PMT"},"hasActiveIssues":false,"sourceCurrency":"USD","sourceValue":60.00,"targetCurrency":"GBP","targetValue":44.87,"customerTransactionId":"b11095fe-ccc7-47bf-984b-5285f3aa63d1"}' + recorded_at: Thu, 07 Aug 2025 09:30:41 GMT +recorded_with: VCR 6.3.1 diff --git a/backend/spec/mailers/user_mailer_spec.rb b/backend/spec/mailers/user_mailer_spec.rb index 4b51fcc966..9700d6a728 100644 --- a/backend/spec/mailers/user_mailer_spec.rb +++ b/backend/spec/mailers/user_mailer_spec.rb @@ -15,10 +15,9 @@ end it "renders the body" do - expect(mail.body.encoded).to include("Your verification code is:") + expect(mail.body.encoded).to include("Your verification code is") expect(mail.body.encoded).to include(user.otp_code) expect(mail.body.encoded).to include("This code will expire in 10 minutes") - expect(mail.body.encoded).to include(ApplicationMailer::SUPPORT_EMAIL) end it "includes the correct OTP code" do diff --git a/backend/spec/services/create_or_update_invoice_service_spec.rb b/backend/spec/services/create_or_update_invoice_service_spec.rb index 867b063bf8..e3dddda8bf 100644 --- a/backend/spec/services/create_or_update_invoice_service_spec.rb +++ b/backend/spec/services/create_or_update_invoice_service_spec.rb @@ -146,23 +146,6 @@ end.to change { user.invoices.count }.by(1) end - it "does not apply an equity split if the feature is not enabled" do - contractor.update!(equity_percentage: 60) - company.update!(equity_enabled: false) - - expect do - result = invoice_service.process - expect(result[:success]).to be(true) - invoice = result[:invoice] - expect(invoice.total_amount_in_usd_cents).to eq(expected_total_amount_in_cents) - expect(invoice.equity_percentage).to eq(0) - expect(invoice.cash_amount_in_cents).to eq(expected_total_amount_in_cents) - expect(invoice.flexile_fee_cents).to eq(50 + (1.5 * expected_total_amount_in_cents / 100).round) - expect(invoice.equity_amount_in_cents).to eq(0) - expect(invoice.equity_amount_in_options).to eq(0) - expect(invoice.notes).to eq("Tax ID: 123efjo32r") - end.to change { user.invoices.count }.by(1) - end it "fails to create an invoice if an active grant is missing, company does not have a share price, and the contractor has an equity percentage" do contractor.update!(equity_percentage: 20) diff --git a/backend/spec/services/pay_investor_dividends_spec.rb b/backend/spec/services/pay_investor_dividends_spec.rb index e1627ec8fa..2cb06e5514 100644 --- a/backend/spec/services/pay_investor_dividends_spec.rb +++ b/backend/spec/services/pay_investor_dividends_spec.rb @@ -72,13 +72,6 @@ end.to change(DividendPayment, :count).by(0) end - it "raises an exception if the company does not have access to the dividends feature" do - company.update!(equity_enabled: false) - - expect do - described_class.new(company_investor, dividends).process - end.to raise_error("Feature unsupported for company #{company.id}") - end it "raises an exception if Flexile does not have sufficient balance to pay for the dividend" do allow(Wise::AccountBalance).to receive(:has_sufficient_flexile_balance?).and_return(false) diff --git a/backend/spec/services/pay_investor_equity_buybacks_spec.rb b/backend/spec/services/pay_investor_equity_buybacks_spec.rb index 4a28c9ff6d..cf60234692 100644 --- a/backend/spec/services/pay_investor_equity_buybacks_spec.rb +++ b/backend/spec/services/pay_investor_equity_buybacks_spec.rb @@ -74,13 +74,6 @@ end.to change(EquityBuybackPayment, :count).by(0) end - it "raises an exception if the company does not have access to the feature" do - company.update!(equity_enabled: false) - - expect do - described_class.new(company_investor, equity_buybacks).process - end.to raise_error("Feature unsupported for company #{company.id}") - end it "raises an exception if Flexile does not have sufficient balance to pay for the equity buyback" do allow(Wise::AccountBalance).to receive(:has_sufficient_flexile_balance?).and_return(false) diff --git a/backend/spec/services/pay_invoice_spec.rb b/backend/spec/services/pay_invoice_spec.rb index ae6ffeef5f..6324583a83 100644 --- a/backend/spec/services/pay_invoice_spec.rb +++ b/backend/spec/services/pay_invoice_spec.rb @@ -203,7 +203,7 @@ def setup_flexile_insufficient_balance expect(invoice.reload.status).to eq(Invoice::FAILED) end - it "marks the invoice and payment as failed if creating a quote fails" do + it "marks the invoice and payment as failed if creating a quote fails and sends a notification" do allow_any_instance_of(Wise::PayoutApi).to receive(:create_quote) do { "error" => "some error" } end @@ -213,6 +213,11 @@ def setup_flexile_insufficient_balance described_class.new(invoice.id).process end.to raise_error(described_class::WiseError) { |error| expect(error.message).to eq "Creating quote failed for payment #{Payment.last.id}" } .and change { invoice.payments.count }.by(1) + .and have_enqueued_mail(CompanyWorkerMailer, :payment_failed).with { |payment_id, amount, currency| + expect(payment_id).to eq(Payment.last.id) + expect(amount).to be_a(Float) + expect(currency).to eq(invoice.user.bank_account.currency) + } payment = Payment.last expect(payment.processor_uuid).to be_present @@ -222,7 +227,7 @@ def setup_flexile_insufficient_balance expect(invoice.reload.status).to eq(Invoice::FAILED) end - it "marks the invoice and payment as failed if creating a transfer fails" do + it "marks the invoice and payment as failed if creating a transfer fails and sends a notification" do allow_any_instance_of(Wise::PayoutApi).to receive(:create_transfer) do { "error" => "some error" } end @@ -232,6 +237,11 @@ def setup_flexile_insufficient_balance described_class.new(invoice.id).process end.to raise_error(described_class::WiseError) { |error| expect(error.message).to eq "Creating transfer failed for payment #{Payment.last.id}" } .and change { invoice.payments.count }.by(1) + .and have_enqueued_mail(CompanyWorkerMailer, :payment_failed).with { |payment_id, amount, currency| + expect(payment_id).to eq(Payment.last.id) + expect(amount).to be_a(Float) + expect(currency).to eq(invoice.user.bank_account.currency) + } payment = Payment.last expect(payment.processor_uuid).to be_present @@ -241,7 +251,7 @@ def setup_flexile_insufficient_balance expect(invoice.reload.status).to eq(Invoice::FAILED) end - it "marks the invoice and payment as failed if funding fails" do + it "marks the invoice and payment as failed if funding fails and sends a notification" do allow_any_instance_of(Wise::PayoutApi).to receive(:fund_transfer) do { "type" => "BALANCE", @@ -257,6 +267,11 @@ def setup_flexile_insufficient_balance described_class.new(invoice.id).process end.to raise_error(described_class::WiseError) { |error| expect(error.message).to eq "Funding transfer failed for payment #{Payment.last.id}" } .and change { invoice.payments.count }.by(1) + .and have_enqueued_mail(CompanyWorkerMailer, :payment_failed).with { |payment_id, amount, currency| + expect(payment_id).to eq(Payment.last.id) + expect(amount).to be_a(Float) + expect(currency).to eq(invoice.user.bank_account.currency) + } payment = Payment.last expect(payment.processor_uuid).to be_present diff --git a/backend/spec/sidekiq/wise_transfer_update_job_spec.rb b/backend/spec/sidekiq/wise_transfer_update_job_spec.rb new file mode 100644 index 0000000000..828f8ad7a5 --- /dev/null +++ b/backend/spec/sidekiq/wise_transfer_update_job_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +RSpec.describe WiseTransferUpdateJob do + describe "#perform" do + let(:company) { create(:company) } + let(:user) { create(:user) } + let(:company_contractor) { create(:company_contractor, company: company, user: user) } + let(:invoice) { create(:invoice, company: company, user: user) } + let(:wise_credential) { create(:wise_credential) } + let(:payment) { create(:payment, invoice: invoice, wise_credential: wise_credential, wise_transfer_id: "transfer_123") } + let(:transfer_id) { payment.wise_transfer_id } + let(:payout_api) { instance_double(Wise::PayoutApi) } + + before do + allow(Wise::PayoutApi).to receive(:new).and_return(payout_api) + allow(payout_api).to receive(:get_transfer).and_return({ "sourceValue" => 100.0, "targetValue" => 95.0, "targetCurrency" => "USD" }) + allow(payout_api).to receive(:delivery_estimate).and_return({ "estimatedDeliveryDate" => "2025-08-10T12:00:00Z" }) + end + + context "when payment fails" do + let(:webhook_params) do + { + "data" => { + "resource" => { + "id" => transfer_id, + "profile_id" => wise_credential.profile_id, + }, + "current_state" => Payments::Wise::CANCELLED, + "occurred_at" => "2025-08-07T10:00:00Z", + }, + } + end + + it "updates the payment status and sends a notification" do + expect do + described_class.new.perform(webhook_params) + end.to have_enqueued_mail(CompanyWorkerMailer, :payment_failed).with { |payment_id, amount, currency| + expect(payment_id).to eq(payment.id) + expect(amount).to eq(95.0) + expect(currency).to eq("USD") + } + + payment.reload + invoice.reload + + expect(payment.status).to eq(Payment::FAILED) + expect(payment.wise_transfer_status).to eq(Payments::Wise::CANCELLED) + expect(invoice.status).to eq(Invoice::FAILED) + end + end + + context "when payment succeeds" do + let(:webhook_params) do + { + "data" => { + "resource" => { + "id" => transfer_id, + "profile_id" => wise_credential.profile_id, + }, + "current_state" => Payments::Wise::OUTGOING_PAYMENT_SENT, + "occurred_at" => "2025-08-07T10:00:00Z", + }, + } + end + + it "updates the payment status and does not send a failure notification" do + expect do + described_class.new.perform(webhook_params) + end.not_to have_enqueued_mail(CompanyWorkerMailer, :payment_failed) + + payment.reload + invoice.reload + + expect(payment.status).to eq(Payment::SUCCEEDED) + expect(payment.wise_transfer_status).to eq(Payments::Wise::OUTGOING_PAYMENT_SENT) + expect(invoice.status).to eq(Invoice::PAID) + end + end + end +end diff --git a/backend/test/mailers/previews/company_worker_mailer_preview.rb b/backend/test/mailers/previews/company_worker_mailer_preview.rb index 99f1032d36..14c8bcb915 100644 --- a/backend/test/mailers/previews/company_worker_mailer_preview.rb +++ b/backend/test/mailers/previews/company_worker_mailer_preview.rb @@ -32,6 +32,14 @@ def payment_failed_reenter_bank_details CompanyWorkerMailer.payment_failed_reenter_bank_details(Payment.last.id, amount, currency) end + def payment_failed + payment = Payment.last.invoice + rate = Wise::PayoutApi.new.get_exchange_rate(target_currency: payment.user.bank_account.currency).first["rate"] + amount = payment.cash_amount_in_usd * rate + currency = payment.user.bank_account.currency + CompanyWorkerMailer.payment_failed(Payment.last.id, amount, currency) + end + def invoice_approved CompanyWorkerMailer.invoice_approved(invoice_id: Invoice.alive.where(equity_percentage: 0).last.id) end diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index c84232dbee..9a4f5fc4e9 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -16,7 +16,10 @@ export const login = async (page: Page, user: typeof users.$inferSelect) => { await page.getByLabel("Verification code").waitFor(); // Use test OTP code - backend should accept this in test environment - await page.getByLabel("Verification code").fill(TEST_OTP_CODE); + // The InputOTP component uses a hidden input for actual input + // Type into the OTP input container to trigger the input + await page.locator('[data-slot="input-otp"]').fill(TEST_OTP_CODE); + await page.getByRole("button", { name: "Continue" }).click(); // Wait for successful redirect @@ -46,9 +49,11 @@ export const signup = async (page: Page, email: string) => { // Wait for OTP step and enter verification code await page.getByLabel("Verification code").waitFor(); - await page.getByLabel("Verification code").fill(TEST_OTP_CODE); - await page.getByRole("button", { name: "Continue" }).click(); - // Wait for successful redirect to onboarding or dashboard + // The InputOTP component uses a hidden input for actual input + // Type into the OTP input container to trigger the input + await page.locator('[data-slot="input-otp"]').fill(TEST_OTP_CODE); + + await page.getByRole("button", { name: "Continue" }).click(); // Wait for successful redirect to onboarding or dashboard await page.waitForURL(/^(?!.*\/(signup|login)$).*/u); }; diff --git a/e2e/tests/company/administrator/onboarding/signup.spec.ts b/e2e/tests/company/administrator/onboarding/signup.spec.ts index 9b2b6b308f..eba1d3089f 100644 --- a/e2e/tests/company/administrator/onboarding/signup.spec.ts +++ b/e2e/tests/company/administrator/onboarding/signup.spec.ts @@ -27,10 +27,11 @@ test.describe("Company administrator signup", () => { await page.getByRole("button", { name: "Sign up" }).click(); // Wait for OTP step and enter verification code - await page.getByLabel("Verification code").waitFor(); - await page.getByLabel("Verification code").fill("000000"); // Test OTP code - await page.getByRole("button", { name: "Continue" }).click(); + // The form should auto-submit when all 6 digits are entered + const otpCode = "000000"; + await page.locator('[data-slot="input-otp"]').fill(otpCode); + // No need to click the button as it should auto-submit // Wait for redirect to dashboard await page.waitForURL(/.*\/invoices.*/u); diff --git a/e2e/tests/company/contractor/contractor-invite-link.spec.ts b/e2e/tests/company/contractor/contractor-invite-link.spec.ts index 45d1b28e68..c465052e4e 100644 --- a/e2e/tests/company/contractor/contractor-invite-link.spec.ts +++ b/e2e/tests/company/contractor/contractor-invite-link.spec.ts @@ -46,10 +46,44 @@ test.describe("Contractor Invite Link Joining flow", () => { test("invite link flow for unauthenticated user", async ({ page, context }) => { await page.goto(`/invite/${inviteLink?.token}`); await expect(page).toHaveURL(/signup/iu); + expect(page.url()).toContain(`invitation_token=${inviteLink?.token}`); const cookies = await context.cookies(); const invitationCookie = cookies.find((c) => c.name === "invitation_token"); expect(inviteLink?.token).toContain(invitationCookie?.value); + + const email = "contractor-signup+e2e@example.com"; + + // Clean up any existing user with this email + await db.delete(users).where(eq(users.email, email)); + + // Enter email and request OTP + await page.getByLabel("Work email").fill(email); + await page.getByRole("button", { name: "Sign up" }).click(); + + // Wait for OTP step and enter verification code + await page.getByLabel("Verification code").waitFor(); + await page.getByLabel("Verification code").fill("000000"); // Test OTP code + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL(/documents/iu); + + const contractor = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + const contractorId = contractor?.id; + expect(contractorId).toBeDefined(); + + if (contractorId) { + const createdCompayContractor = await db.query.companyContractors.findFirst({ + where: and(eq(companyContractors.companyId, company.id), eq(companyContractors.userId, contractorId)), + }); + + expect(createdCompayContractor).toBeDefined(); + expect(createdCompayContractor?.role).toBe(null); + expect(createdCompayContractor?.contractSignedElsewhere).toBe(true); + } }); test("invite link flow for authenticated user", async ({ page }) => { diff --git a/e2e/tests/company/invoices/list.spec.ts b/e2e/tests/company/invoices/list.spec.ts index f934f4abb6..4e8edbe343 100644 --- a/e2e/tests/company/invoices/list.spec.ts +++ b/e2e/tests/company/invoices/list.spec.ts @@ -416,18 +416,19 @@ test.describe("Invoices contractor flow", () => { await receivedInvoiceRow.click({ button: "right" }); await expect(page.getByRole("menuitem").filter({ hasText: "Delete" })).toBeVisible(); - await page.click("body"); + await page.keyboard.press("Escape"); const paidInvoiceRow = page.getByRole("row").getByText("Paid"); await paidInvoiceRow.click({ button: "right" }); await expect(page.getByRole("menuitem").filter({ hasText: "Delete" })).not.toBeVisible(); - await page.click("body"); + await page.keyboard.press("Escape"); await expect(page.locator("tbody tr")).toHaveCount(3); const deletableInvoiceRow = page.getByRole("row").getByText("Awaiting approval").first(); await deletableInvoiceRow.click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible(); await page.getByRole("menuitem", { name: "Delete" }).click(); await page.getByRole("dialog").waitFor(); await page.getByRole("button", { name: "Delete" }).click(); diff --git a/e2e/tests/company/updates/company/create.spec.ts b/e2e/tests/company/updates/company/create.spec.ts index 8838b7ac7d..156a4009cf 100644 --- a/e2e/tests/company/updates/company/create.spec.ts +++ b/e2e/tests/company/updates/company/create.spec.ts @@ -21,9 +21,14 @@ test.describe("company update creation", () => { await companyInvestorsFactory.create({ companyId: company.id }); }); - async function fillForm(page: Page, title: string, body: string) { - await page.getByLabel("Title").fill(title); - await page.locator('[contenteditable="true"]').fill(body); + async function fillFormInModal(page: Page, title: string, body: string, modalTitle: string) { + await withinModal( + async (modal) => { + await modal.getByLabel("Title").fill(title); + await modal.locator('[contenteditable="true"]').fill(body); + }, + { page, title: modalTitle }, + ); } test("allows publishing company update", async ({ page }) => { @@ -31,15 +36,24 @@ test.describe("company update creation", () => { const content = "This will be published"; await login(page, adminUser); - await page.goto("/updates/company/new"); + await page.goto("/updates/company"); + + await page.getByRole("button", { name: "New update" }).click(); + await expect(page.getByRole("dialog", { name: "New company update" })).toBeVisible(); - await fillForm(page, title, content); - await page.getByRole("button", { name: "Publish" }).click(); + await fillFormInModal(page, title, content, "New company update"); + + await withinModal( + async (modal) => { + await modal.getByRole("button", { name: "Publish" }).click(); + }, + { page, title: "New company update" }, + ); await expect(page.getByRole("dialog", { name: "Publish update?" })).toBeVisible(); await page.getByRole("button", { name: "Yes, publish" }).click(); - await page.waitForURL("/updates/company"); + await expect(page.getByRole("dialog")).not.toBeVisible(); await expect(page.getByRole("row").filter({ hasText: title }).filter({ hasText: "Sent" })).toBeVisible(); const updates = await db.query.companyUpdates.findMany({ @@ -54,11 +68,19 @@ test.describe("company update creation", () => { const content = "Test content"; await login(page, adminUser); - await page.goto("/updates/company/new"); + await page.goto("/updates/company"); + + await page.getByRole("button", { name: "New update" }).click(); + await expect(page.getByRole("dialog", { name: "New company update" })).toBeVisible(); - await fillForm(page, title, content); + await fillFormInModal(page, title, content, "New company update"); - await page.getByRole("button", { name: "Preview" }).click(); + await withinModal( + async (modal) => { + await modal.getByRole("button", { name: "Preview" }).click(); + }, + { page, title: "New company update" }, + ); await withinModal( async (modal) => { @@ -77,15 +99,37 @@ test.describe("company update creation", () => { test("prevents submission with validation errors", async ({ page }) => { await login(page, adminUser); - await page.goto("/updates/company/new"); + await page.goto("/updates/company"); - await page.getByRole("button", { name: "Preview" }).click(); - await expect(page.locator('[data-slot="form-message"]').first()).toBeVisible(); - await expect(page.getByRole("dialog")).not.toBeVisible(); + await page.getByRole("button", { name: "New update" }).click(); + await expect(page.getByRole("dialog", { name: "New company update" })).toBeVisible(); - await page.getByRole("button", { name: "Publish" }).click(); - await expect(page.locator('[data-slot="form-message"]').first()).toBeVisible(); - await expect(page.getByRole("dialog")).not.toBeVisible(); + await withinModal( + async (modal) => { + await modal.getByLabel("Title").fill("Important update"); + }, + { page, title: "New company update" }, + ); + + await withinModal( + async (modal) => { + await modal.getByRole("button", { name: "Preview" }).click(); + await expect(modal.locator('[data-slot="form-message"]').first()).toBeVisible(); + }, + { page, title: "New company update" }, + ); + + await expect(page.getByRole("dialog", { name: "Previewing: Important update" })).not.toBeVisible(); + + await withinModal( + async (modal) => { + await modal.getByRole("button", { name: "Publish" }).click(); + await expect(modal.locator('[data-slot="form-message"]').first()).toBeVisible(); + }, + { page, title: "New company update" }, + ); + + await expect(page.getByRole("dialog", { name: "Publish update?" })).not.toBeVisible(); const updates = await db.query.companyUpdates.findMany({ where: eq(companyUpdates.companyId, company.id), @@ -101,16 +145,24 @@ test.describe("company update creation", () => { }); await login(page, adminUser); - await page.goto(`/updates/company/${companyUpdate.externalId}/edit`); + await page.goto("/updates/company"); + + await page.getByRole("row").filter({ hasText: "Original Title" }).click(); + await expect(page.getByRole("dialog", { name: "Edit company update" })).toBeVisible(); - await page.getByLabel("Title").clear(); - await page.getByLabel("Title").fill("Updated Title"); + await withinModal( + async (modal) => { + await modal.getByLabel("Title").clear(); + await modal.getByLabel("Title").fill("Updated Title"); + await modal.getByRole("button", { name: "Update" }).click(); + }, + { page, title: "Edit company update" }, + ); - await page.getByRole("button", { name: "Update" }).click(); await expect(page.getByRole("dialog", { name: "Publish update?" })).toBeVisible(); await page.getByRole("button", { name: "Yes, update" }).click(); - await page.waitForURL("/updates/company"); + await expect(page.getByRole("dialog")).not.toBeVisible(); await expect(page.getByRole("row").filter({ hasText: "Updated Title" }).filter({ hasText: "Sent" })).toBeVisible(); const updatedRecord = await db.query.companyUpdates.findFirst({ diff --git a/e2e/tests/company/updates/company/youtube-embeds.spec.ts b/e2e/tests/company/updates/company/youtube-embeds.spec.ts index c4f7dfa591..7ca55f4ec8 100644 --- a/e2e/tests/company/updates/company/youtube-embeds.spec.ts +++ b/e2e/tests/company/updates/company/youtube-embeds.spec.ts @@ -129,16 +129,24 @@ test.describe("Company Updates - YouTube Embeds", () => { test("should allow creating company update with YouTube URL", async ({ page }) => { await login(page, adminUser); - await page.goto("/updates/company/new"); + await page.goto("/updates/company"); - await expect(page.getByLabel("Title")).toBeVisible(); - await expect(page.getByLabel("Video URL (optional)")).toBeVisible(); + await page.getByRole("button", { name: "New update" }).click(); + await expect(page.getByRole("dialog", { name: "New company update" })).toBeVisible(); - await page.getByLabel("Title").fill("Test Update with YouTube Video"); - await page.getByLabel("Video URL (optional)").fill(ANTIWORK_VIDEO.fullUrl); + await withinModal( + async (modal) => { + await expect(modal.getByLabel("Title")).toBeVisible(); + await expect(modal.getByLabel("Video URL (optional)")).toBeVisible(); + + await modal.getByLabel("Title").fill("Test Update with YouTube Video"); + await modal.getByLabel("Video URL (optional)").fill(ANTIWORK_VIDEO.fullUrl); - await expect(page.getByLabel("Title")).toHaveValue("Test Update with YouTube Video"); - await expect(page.getByLabel("Video URL (optional)")).toHaveValue(ANTIWORK_VIDEO.fullUrl); + await expect(modal.getByLabel("Title")).toHaveValue("Test Update with YouTube Video"); + await expect(modal.getByLabel("Video URL (optional)")).toHaveValue(ANTIWORK_VIDEO.fullUrl); + }, + { page, title: "New company update" }, + ); }); test("should handle YouTube URLs with additional parameters", async ({ page }) => { diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index 20f61feab9..af54cb0c81 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -12,8 +12,15 @@ test("login", async ({ page }) => { await page.getByLabel("Work email").fill(email); await page.getByRole("button", { name: "Log in", exact: true }).click(); - await page.getByLabel("Verification code").fill("000000"); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + + // Fill the OTP code using the InputOTP component's hidden input + // The form should auto-submit when all 6 digits are entered + const otpCode = "000000"; + await page.locator('[data-slot="input-otp"]').fill(otpCode); + + // No need to click the button as it should auto-submit + // Wait for navigation to complete after auto-submit + await page.waitForURL(/.*\/invoices.*/u); await expect(page.getByRole("heading", { name: "Invoices" })).toBeVisible(); @@ -35,9 +42,13 @@ test("login with redirect_url", async ({ page }) => { await page.getByLabel("Work email").fill(email); await page.getByRole("button", { name: "Log in", exact: true }).click(); - await page.getByLabel("Verification code").fill("000000"); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + // Fill the OTP code using the InputOTP component's hidden input + // The form should auto-submit when all 6 digits are entered + const otpCode = "000000"; + await page.locator('[data-slot="input-otp"]').fill(otpCode); + + // No need to click the button as it should auto-submit await page.waitForLoadState("networkidle"); await expect(page.getByRole("heading", { name: "People" })).toBeVisible(); diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 20779b524f..7d0c5d4fbd 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,17 +1,19 @@ "use client"; import Image from "next/image"; import Link from "next/link"; -import { Suspense } from "react"; +import { Suspense, useRef } from "react"; import { AuthAlerts } from "@/components/auth/AuthAlerts"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; import { useAuthApi } from "@/hooks/useAuthApi"; import { useOtpFlowState } from "@/hooks/useOtpFlowState"; import logo from "@/public/logo-icon.svg"; function LoginContent() { + const formRef = useRef(null); const [state, actions] = useOtpFlowState(); const { handleSendOtp, handleAuthenticate } = useAuthApi( { @@ -78,23 +80,40 @@ function LoginContent() { void handleAuthenticate(e); }} className="space-y-4" + ref={formRef} > -
+
- actions.setOtp(e.target.value)} maxLength={6} - required + value={state.otp} + onChange={(value) => { + actions.setOtp(value); + if (value.length === 6 && !state.loading) { + setTimeout(() => formRef.current?.requestSubmit(), 100); + } + }} disabled={state.loading} - /> + autoFocus + required + > + + + + + + + + + + + +
- diff --git a/frontend/app/(auth)/signup/[[...rest]]/page.tsx b/frontend/app/(auth)/signup/[[...rest]]/page.tsx index 92f78a112f..d5c3c8cd9f 100644 --- a/frontend/app/(auth)/signup/[[...rest]]/page.tsx +++ b/frontend/app/(auth)/signup/[[...rest]]/page.tsx @@ -2,11 +2,12 @@ import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { Suspense } from "react"; +import { Suspense, useRef } from "react"; import { AuthAlerts } from "@/components/auth/AuthAlerts"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; import { useAuthApi } from "@/hooks/useAuthApi"; import { useOtpFlowState } from "@/hooks/useOtpFlowState"; @@ -15,6 +16,7 @@ import logo from "@/public/logo-icon.svg"; function SignUpContent() { const searchParams = useSearchParams(); const invitationToken = searchParams.get("invitation_token"); + const formRef = useRef(null); const [state, actions] = useOtpFlowState(); const { handleSendOtp, handleAuthenticate } = useAuthApi( @@ -85,23 +87,40 @@ function SignUpContent() { void handleAuthenticate(e); }} className="space-y-4" + ref={formRef} > -
+
- actions.setOtp(e.target.value)} maxLength={6} - required + value={state.otp} + onChange={(value) => { + actions.setOtp(value); + if (value.length === 6 && !state.loading) { + setTimeout(() => formRef.current?.requestSubmit(), 100); + } + }} disabled={state.loading} - /> + autoFocus + required + > + + + + + + + + + + + +
- diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx index b030041ccd..9b3bb8ecee 100644 --- a/frontend/app/(dashboard)/layout.tsx +++ b/frontend/app/(dashboard)/layout.tsx @@ -154,19 +154,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod ) : null} - {company.checklistItems.length > 0 ? ( - - - - - - - - ) : null} - + {company.checklistItems.length > 0 ? : null} {canShowTryEquity && showTryEquity ? ( @@ -179,7 +170,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod tabIndex={0} > - + Try equity {hovered ? ( diff --git a/frontend/app/(dashboard)/updates/company/CompanyUpdateModal.tsx b/frontend/app/(dashboard)/updates/company/CompanyUpdateModal.tsx new file mode 100644 index 0000000000..ee472478c0 --- /dev/null +++ b/frontend/app/(dashboard)/updates/company/CompanyUpdateModal.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { Users } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import ViewUpdateDialog from "@/app/(dashboard)/updates/company/ViewUpdateDialog"; +import MutationButton, { MutationStatusButton } from "@/components/MutationButton"; +import { Editor as RichTextEditor } from "@/components/RichText"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useCurrentCompany } from "@/global"; +import { trpc } from "@/trpc/client"; +import { pluralize } from "@/utils/pluralize"; + +const formSchema = z.object({ + title: z.string().trim().min(1, "This field is required."), + body: z.string().regex(/>\w/u, "This field is required."), + videoUrl: z.string().nullable(), +}); + +interface CompanyUpdateModalProps { + open: boolean; + onClose: () => void; + updateId?: string; +} + +const CompanyUpdateModal = ({ open, onClose, updateId }: CompanyUpdateModalProps) => { + const company = useCurrentCompany(); + const trpcUtils = trpc.useUtils(); + + const { data: update, isLoading } = trpc.companyUpdates.get.useQuery( + { companyId: company.id, id: updateId ?? "" }, + { enabled: !!updateId && open }, + ); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + title: update?.title ?? "", + body: update?.body ?? "", + videoUrl: update?.videoUrl ?? "", + }, + }); + + useEffect(() => { + if (update) { + form.reset({ + title: update.title, + body: update.body, + videoUrl: update.videoUrl ?? "", + }); + } else if (!updateId) { + form.reset({ + title: "", + body: "", + videoUrl: "", + }); + } + }, [update, updateId, form]); + + const [publishModalOpen, setPublishModalOpen] = useState(false); + const [viewPreview, setViewPreview] = useState(false); + const [previewUpdateId, setPreviewUpdateId] = useState(null); + + const recipientCount = (company.contractorCount ?? 0) + (company.investorCount ?? 0); + + const createMutation = trpc.companyUpdates.create.useMutation(); + const updateMutation = trpc.companyUpdates.update.useMutation(); + const publishMutation = trpc.companyUpdates.publish.useMutation(); + + const saveMutation = useMutation({ + mutationFn: async ({ values, preview }: { values: z.infer; preview: boolean }) => { + const data = { + companyId: company.id, + ...values, + videoUrl: values.videoUrl || null, + }; + let id; + if (update) { + id = update.id; + await updateMutation.mutateAsync({ ...data, id }); + } else if (previewUpdateId) { + id = previewUpdateId; + await updateMutation.mutateAsync({ ...data, id }); + } else { + id = await createMutation.mutateAsync(data); + } + if (!preview && !update?.sentAt) await publishMutation.mutateAsync({ companyId: company.id, id }); + void trpcUtils.companyUpdates.list.invalidate(); + await trpcUtils.companyUpdates.get.invalidate({ companyId: company.id, id }); + if (preview) { + setPreviewUpdateId(id); + setViewPreview(true); + } else { + handleClose(); + } + }, + }); + + const submit = form.handleSubmit(() => setPublishModalOpen(true)); + + const handleClose = () => { + setPublishModalOpen(false); + setViewPreview(false); + setPreviewUpdateId(null); + form.reset(); + onClose(); + }; + + return ( + <> + + + + {update ? "Edit company update" : "New company update"} + + + {isLoading ? ( +
Loading...
+ ) : ( +
+ void submit(e)} className="space-y-6"> +
+ ( + + Title + + + + + + )} + /> + + ( + + Update + + + + + + )} + /> + + ( + + Video URL (optional) + + + + + + )} + /> +
+ +
+ +
+ {company.investorCount ? ( +
+ + + {company.investorCount.toLocaleString()} {pluralize("investor", company.investorCount)} + +
+ ) : null} + {company.contractorCount ? ( +
+ + + {company.contractorCount.toLocaleString()} active{" "} + {pluralize("contractor", company.contractorCount)} + +
+ ) : null} +
+
+
+ + )} + +
+
+ {update?.sentAt ? ( + + ) : ( + <> + + void form.handleSubmit((values) => saveMutation.mutateAsync({ values, preview: true }))() + } + > + Preview + + + + )} +
+
+
+
+ + + + + Publish update? + + {update?.sentAt ? ( +

Your update will be visible in Flexile. No new emails will be sent.

+ ) : ( +

Your update will be emailed to {recipientCount.toLocaleString()} stakeholders.

+ )} + +
+ + + Yes, {update?.sentAt ? "update" : "publish"} + +
+
+
+
+ + {viewPreview && previewUpdateId ? ( + { + setViewPreview(false); + }} + /> + ) : null} + + ); +}; + +export default CompanyUpdateModal; diff --git a/frontend/app/(dashboard)/updates/company/Edit.tsx b/frontend/app/(dashboard)/updates/company/Edit.tsx deleted file mode 100644 index f1859f264e..0000000000 --- a/frontend/app/(dashboard)/updates/company/Edit.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client"; - -import { EnvelopeIcon, UsersIcon } from "@heroicons/react/24/outline"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; -import { FileScan } from "lucide-react"; -import { useParams, usePathname, useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import ViewUpdateDialog from "@/app/(dashboard)/updates/company/ViewUpdateDialog"; -import { DashboardHeader } from "@/components/DashboardHeader"; -import MutationButton, { MutationStatusButton } from "@/components/MutationButton"; -import { Editor as RichTextEditor } from "@/components/RichText"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useCurrentCompany } from "@/global"; -import type { RouterOutput } from "@/trpc"; -import { trpc } from "@/trpc/client"; -import { pluralize } from "@/utils/pluralize"; - -const formSchema = z.object({ - title: z.string().trim().min(1, "This field is required."), - body: z.string().regex(/>\w/u, "This field is required."), - videoUrl: z.string().nullable(), -}); - -type CompanyUpdate = RouterOutput["companyUpdates"]["get"]; -const Edit = ({ update }: { update?: CompanyUpdate }) => { - const { id } = useParams<{ id?: string }>(); - const pathname = usePathname(); - const company = useCurrentCompany(); - const router = useRouter(); - const trpcUtils = trpc.useUtils(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - title: update?.title ?? "", - body: update?.body ?? "", - videoUrl: update?.videoUrl ?? "", - }, - }); - - const [modalOpen, setModalOpen] = useState(false); - const navigatedFromNewPreview = sessionStorage.getItem("navigated-from-new-preview"); - const [viewPreview, setViewPreview] = useState(!!navigatedFromNewPreview); - - const recipientCount = (company.contractorCount ?? 0) + (company.investorCount ?? 0); - - const createMutation = trpc.companyUpdates.create.useMutation(); - const updateMutation = trpc.companyUpdates.update.useMutation(); - const publishMutation = trpc.companyUpdates.publish.useMutation(); - const saveMutation = useMutation({ - mutationFn: async ({ values, preview }: { values: z.infer; preview: boolean }) => { - const data = { - companyId: company.id, - ...values, - }; - let id; - if (update) { - id = update.id; - await updateMutation.mutateAsync({ ...data, id }); - } else { - id = await createMutation.mutateAsync(data); - } - if (!preview && !update?.sentAt) await publishMutation.mutateAsync({ companyId: company.id, id }); - void trpcUtils.companyUpdates.list.invalidate(); - if (preview) { - if (pathname === "/updates/company/new") { - sessionStorage.setItem("navigated-from-new-preview", "yes"); - router.replace(`/updates/company/${id}/edit`); - } else { - await trpcUtils.companyUpdates.get.invalidate({ companyId: company.id, id }); - setViewPreview(true); - } - } else { - router.push(`/updates/company`); - } - }, - }); - - const submit = form.handleSubmit(() => setModalOpen(true)); - - useEffect(() => { - if (navigatedFromNewPreview) { - sessionStorage.removeItem("navigated-from-new-preview"); - } - }, []); - - return ( - <> -
- void submit(e)}> - - - Update - - ) : ( - <> - - void form.handleSubmit((values) => saveMutation.mutateAsync({ values, preview: true }))() - } - > - - Preview - - - - ) - } - /> -
-
- ( - - Title - - - - - - )} - /> - - ( - - Update - - - - - - )} - /> - ( - - Video URL (optional) - - - - - - )} - /> -
-
-
Recipients ({recipientCount.toLocaleString()})
- {company.investorCount ? ( -
- - - {company.investorCount.toLocaleString()} {pluralize("investor", company.investorCount)} - -
- ) : null} - {company.contractorCount ? ( -
- - - {company.contractorCount.toLocaleString()} active {pluralize("contractor", company.contractorCount)} - -
- ) : null} -
-
- - - - Publish update? - - {update?.sentAt ? ( -

Your update will be visible in Flexile. No new emails will be sent.

- ) : ( -

Your update will be emailed to {recipientCount.toLocaleString()} stakeholders.

- )} - -
- - - Yes, {update?.sentAt ? "update" : "publish"} - -
-
-
-
- - - {viewPreview && id ? ( - { - setViewPreview(false); - }} - /> - ) : null} - - ); -}; - -export default Edit; diff --git a/frontend/app/(dashboard)/updates/company/[id]/edit/page.tsx b/frontend/app/(dashboard)/updates/company/[id]/edit/page.tsx deleted file mode 100644 index 07b611aaab..0000000000 --- a/frontend/app/(dashboard)/updates/company/[id]/edit/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; -import { useParams } from "next/navigation"; -import React from "react"; -import EditPage from "@/app/(dashboard)/updates/company/Edit"; -import { useCurrentCompany } from "@/global"; -import { trpc } from "@/trpc/client"; - -export default function Edit() { - const company = useCurrentCompany(); - const { id } = useParams<{ id: string }>(); - const [update] = trpc.companyUpdates.get.useSuspenseQuery({ companyId: company.id, id }); - - return ; -} diff --git a/frontend/app/(dashboard)/updates/company/new/page.tsx b/frontend/app/(dashboard)/updates/company/new/page.tsx deleted file mode 100644 index 6d53fc843b..0000000000 --- a/frontend/app/(dashboard)/updates/company/new/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; -import React from "react"; -import EditPage from "@/app/(dashboard)/updates/company/Edit"; - -export default function New() { - return ; -} diff --git a/frontend/app/(dashboard)/updates/company/page.tsx b/frontend/app/(dashboard)/updates/company/page.tsx index e72954d590..0357899a6c 100644 --- a/frontend/app/(dashboard)/updates/company/page.tsx +++ b/frontend/app/(dashboard)/updates/company/page.tsx @@ -1,8 +1,7 @@ "use client"; import { CircleCheck, Trash2 } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; import React, { useMemo, useState } from "react"; +import CompanyUpdateModal from "@/app/(dashboard)/updates/company/CompanyUpdateModal"; import ViewUpdateDialog from "@/app/(dashboard)/updates/company/ViewUpdateDialog"; import { DashboardHeader } from "@/components/DashboardHeader"; import DataTable, { createColumnHelper, useTable } from "@/components/DataTable"; @@ -22,28 +21,41 @@ const useData = () => { return { updates: data.updates, isLoading }; }; +type UpdateListItem = ReturnType["updates"][number]; + export default function CompanyUpdates() { const user = useCurrentUser(); const { updates, isLoading } = useData(); + const [showModal, setShowModal] = useState(false); + const [editingUpdateId, setEditingUpdateId] = useState(undefined); + + const handleNewUpdate = () => { + setEditingUpdateId(undefined); + setShowModal(true); + }; + + const handleEditUpdate = (update: UpdateListItem) => { + setEditingUpdateId(update.id); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setEditingUpdateId(undefined); + }; return ( <> - New update - - ) : null - } + headerActions={user.roles.administrator ? : null} /> {isLoading ? ( ) : updates.length ? ( user.roles.administrator ? ( - + ) : ( ) @@ -52,14 +64,19 @@ export default function CompanyUpdates() { No updates to display.
)} + + ); } -const AdminList = () => { +const AdminList = ({ onEditUpdate }: { onEditUpdate: (update: UpdateListItem) => void }) => { const { updates } = useData(); const company = useCurrentCompany(); - const router = useRouter(); const trpcUtils = trpc.useUtils(); const [deletingUpdate, setDeletingUpdate] = useState(null); @@ -78,9 +95,9 @@ const AdminList = () => { columnHelper.accessor("title", { header: "Title", cell: (info) => ( - + ), }), columnHelper.accessor((row) => (row.sentAt ? "Sent" : "Draft"), { @@ -93,7 +110,10 @@ const AdminList = () => {
{children}
diff --git a/frontend/components/GettingStarted.tsx b/frontend/components/GettingStarted.tsx index abab21dc8b..0ba5f5393f 100644 --- a/frontend/components/GettingStarted.tsx +++ b/frontend/components/GettingStarted.tsx @@ -80,14 +80,14 @@ export const GettingStarted = () => { } return ( - + setStatus(expanded ? "expanded" : "collapsed")} className="flex h-full flex-col-reverse" > - + {status === "completed" ? (
@@ -96,10 +96,10 @@ export const GettingStarted = () => { )} Getting started - {progressPercentage}% + {progressPercentage}% - + {status === "completed" ? (
diff --git a/frontend/components/RichText.tsx b/frontend/components/RichText.tsx index 883aa5087a..c95db2f8c3 100644 --- a/frontend/components/RichText.tsx +++ b/frontend/components/RichText.tsx @@ -30,15 +30,14 @@ const RichText = ({ content }: { content: Content }) => { export const Editor = ({ value, - invalid, onChange, className, + ...props }: { value: string | null; - invalid?: boolean; onChange: (value: string) => void; className?: string; -}) => { +} & React.ComponentProps<"div">) => { const [addingLink, setAddingLink] = useState<{ url: string } | null>(null); const id = React.useId(); @@ -50,10 +49,7 @@ export const Editor = ({ editorProps: { attributes: { id, - class: cn(className, "prose p-4 max-h-96 overflow-y-auto max-w-full rounded-b-md", { - "outline-red": invalid, - }), - "aria-invalid": String(invalid), + class: cn(className, "prose p-4 min-h-60 max-h-96 overflow-y-auto max-w-full rounded-b-md outline-none"), }, }, immediatelyRender: false, @@ -93,8 +89,16 @@ export const Editor = ({ }; return ( -
-
+
+
{toolbarItems.map((item) => (