Skip to content

Commit 6cb8df8

Browse files
feat: add wallet transaction details (#3339)
## Context This PR enhances the handling of WalletTransactions by introducing support for failed transactions and improving GraphQL access to transaction data. The changes provide better visibility into payment failures and their impact on invoices. ## Description GraphQL Changes - Added WalletTransactionResolver to allow retrieving a single wallet transaction. - Linked payments to an invoice - Added failed_at and linked invoice to WalletTransaction for better tracking of failed transactions. Backend Changes - Introduced a new failed status for WalletTransaction to reflect payment failures. - Added failed_at timestamp to store when a transaction fails. - Updated logic to handle payment failures: - When a payment fails, the corresponding WalletTransaction is now marked as failed with the failed_at timestamp.| ## Ongoing Refactor Working on a separate PR to refactor how we retrieve the invoice for a WalletTransaction so we always have invoice_id in transaction instead of searching on the fees
1 parent 75285b7 commit 6cb8df8

23 files changed

+471
-15
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
module Resolvers
4+
class WalletTransactionResolver < Resolvers::BaseResolver
5+
include AuthenticableApiUser
6+
include RequiredOrganization
7+
8+
description "Query a single wallet transaction"
9+
10+
argument :id, ID, required: true, description: "Unique ID of the wallet transaction"
11+
12+
type Types::WalletTransactions::Object, null: true
13+
14+
def resolve(id:)
15+
current_organization.wallet_transactions.includes(:invoice).find(id)
16+
rescue ActiveRecord::RecordNotFound
17+
not_found_error(resource: "wallet_transaction")
18+
end
19+
end
20+
end

app/graphql/types/invoices/object.rb

+5
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class Object < Types::BaseObject
7070
field :integration_salesforce_syncable, GraphQL::Types::Boolean, null: false
7171
field :integration_syncable, GraphQL::Types::Boolean, null: false
7272
field :payable_type, GraphQL::Types::String, null: false
73+
field :payments, [Types::Payments::Object], null: true
7374
field :tax_provider_voidable, GraphQL::Types::Boolean, null: false
7475

7576
def payable_type
@@ -80,6 +81,10 @@ def applied_taxes
8081
object.applied_taxes.order(tax_rate: :desc)
8182
end
8283

84+
def payments
85+
object.payments.order(updated_at: :desc)
86+
end
87+
8388
def integration_syncable
8489
object.should_sync_invoice? &&
8590
object.integration_resources

app/graphql/types/query_type.rb

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class QueryType < Types::BaseObject
7373
field :tax, resolver: Resolvers::TaxResolver
7474
field :taxes, resolver: Resolvers::TaxesResolver
7575
field :wallet, resolver: Resolvers::WalletResolver
76+
field :wallet_transaction, resolver: Resolvers::WalletTransactionResolver
7677
field :wallet_transactions, resolver: Resolvers::WalletTransactionsResolver
7778
field :wallets, resolver: Resolvers::WalletsResolver
7879
field :webhook_endpoint, resolver: Resolvers::WebhookEndpointResolver

app/graphql/types/wallet_transactions/object.rb

+9
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,18 @@ class Object < Types::BaseObject
1616
field :transaction_type, Types::WalletTransactions::TransactionTypeEnum, null: false
1717

1818
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
19+
field :failed_at, GraphQL::Types::ISO8601DateTime, null: true
20+
field :invoice, Types::Invoices::Object, null: true
1921
field :metadata, [Types::WalletTransactions::MetadataObject], null: true
2022
field :settled_at, GraphQL::Types::ISO8601DateTime, null: true
2123
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
24+
25+
def invoice
26+
return object.invoice if object.invoice_id.present?
27+
28+
fee = Fee.find_by(invoiceable_id: object.id, invoiceable_type: "WalletTransaction")
29+
fee&.invoice
30+
end
2231
end
2332
end
2433
end

app/jobs/invoices/prepaid_credit_job.rb

+8-3
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ class PrepaidCreditJob < ApplicationJob
77
retry_on ActiveRecord::StaleObjectError, wait: :polynomially_longer, attempts: 6
88
unique :until_executed, on_conflict: :log
99

10-
def perform(invoice)
10+
def perform(invoice, payment_status = :succeeded) # Default to :succeeded for old jobs
1111
wallet_transaction = invoice.fees.find_by(fee_type: "credit")&.invoiceable
12-
Wallets::ApplyPaidCreditsService.call(wallet_transaction:)
13-
Invoices::FinalizeOpenCreditService.call(invoice:)
12+
13+
if payment_status.to_sym == :succeeded
14+
Wallets::ApplyPaidCreditsService.call(wallet_transaction:)
15+
Invoices::FinalizeOpenCreditService.call(invoice:)
16+
elsif payment_status.to_sym == :failed
17+
WalletTransactions::MarkAsFailedService.new(wallet_transaction:).call
18+
end
1419
end
1520
end
1621
end

app/models/wallet_transaction.rb

+9-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ class WalletTransaction < ApplicationRecord
1111

1212
STATUSES = [
1313
:pending,
14-
:settled
14+
:settled,
15+
:failed
1516
].freeze
1617

1718
TRANSACTION_STATUSES = [
@@ -46,6 +47,12 @@ def amount_cents
4647
def unit_amount_cents
4748
wallet.rate_amount * wallet.currency_for_balance.subunit_to_unit
4849
end
50+
51+
def mark_as_failed!(timestamp = Time.zone.now)
52+
return if failed?
53+
54+
update!(status: :failed, failed_at: timestamp)
55+
end
4956
end
5057

5158
# == Schema Information
@@ -55,6 +62,7 @@ def unit_amount_cents
5562
# id :uuid not null, primary key
5663
# amount :decimal(30, 5) default(0.0), not null
5764
# credit_amount :decimal(30, 5) default(0.0), not null
65+
# failed_at :datetime
5866
# invoice_requires_successful_payment :boolean default(FALSE), not null
5967
# metadata :jsonb
6068
# settled_at :datetime

app/serializers/v1/wallet_transaction_serializer.rb

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def serialize
1313
amount: model.amount,
1414
credit_amount: model.credit_amount,
1515
settled_at: model.settled_at&.iso8601,
16+
failed_at: model.failed_at&.iso8601,
1617
created_at: model.created_at.iso8601,
1718
invoice_requires_successful_payment: model.invoice_requires_successful_payment?,
1819
metadata: model.metadata

app/services/invoices/update_service.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ def valid_payment_status?(payment_status)
8080

8181
def handle_prepaid_credits(payment_status)
8282
return unless invoice.invoice_type&.to_sym == :credit
83-
return unless payment_status&.to_sym == :succeeded
83+
return unless %i[succeeded failed].include?(payment_status.to_sym)
8484

85-
Invoices::PrepaidCreditJob.perform_later(invoice)
85+
Invoices::PrepaidCreditJob.perform_later(invoice, payment_status)
8686
end
8787

8888
def valid_metadata_count?(metadata:)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module WalletTransactions
4+
class MarkAsFailedService < BaseService
5+
def initialize(wallet_transaction:)
6+
@wallet_transaction = wallet_transaction
7+
super
8+
end
9+
10+
def call
11+
return result unless wallet_transaction
12+
return result if wallet_transaction.status == "failed"
13+
14+
wallet_transaction.mark_as_failed!
15+
SendWebhookJob.perform_later("wallet_transaction.updated", wallet_transaction)
16+
result.wallet_transaction = wallet_transaction
17+
result
18+
end
19+
20+
private
21+
22+
attr_reader :wallet_transaction
23+
end
24+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
class AddFailedAtToWalletTransactions < ActiveRecord::Migration[7.2]
4+
disable_ddl_transaction!
5+
def change
6+
add_column :wallet_transactions, :failed_at, :datetime
7+
end
8+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
class MarkPendingWalletTransactionsAsFailed < ActiveRecord::Migration[7.2]
4+
disable_ddl_transaction!
5+
6+
def up
7+
# Update transactions using the latest `updated_at` from failed payments (if available)
8+
safety_assured do
9+
execute <<~SQL.squish
10+
UPDATE wallet_transactions wt
11+
SET status = 2,
12+
failed_at = (
13+
SELECT MAX(p.updated_at)
14+
FROM fees f
15+
INNER JOIN invoices i ON i.id = f.invoice_id
16+
INNER JOIN payments p ON p.payable_id = i.id
17+
WHERE f.invoiceable_id = wt.id
18+
AND f.invoiceable_type = 'WalletTransaction'
19+
AND i.payment_status = 2
20+
AND p.payable_payment_status = 'failed'
21+
)
22+
WHERE wt.status = 0
23+
AND EXISTS (
24+
SELECT 1
25+
FROM fees f
26+
INNER JOIN invoices i ON i.id = f.invoice_id
27+
INNER JOIN payments p ON p.payable_id = i.id
28+
WHERE f.invoiceable_id = wt.id
29+
AND f.invoiceable_type = 'WalletTransaction'
30+
AND i.payment_status = 2
31+
AND p.payable_payment_status = 'failed'
32+
);
33+
SQL
34+
end
35+
36+
# Then update transactions using `invoices.updated_at` if no failed payment exists
37+
safety_assured do
38+
execute <<~SQL.squish
39+
UPDATE wallet_transactions wt
40+
SET status = 2,
41+
failed_at = (
42+
SELECT MAX(i.updated_at)
43+
FROM fees f
44+
INNER JOIN invoices i ON i.id = f.invoice_id
45+
WHERE f.invoiceable_id = wt.id
46+
AND f.invoiceable_type = 'WalletTransaction'
47+
AND i.payment_status = 2
48+
AND NOT EXISTS (
49+
SELECT 1
50+
FROM payments p
51+
WHERE p.payable_id = i.id
52+
)
53+
)
54+
WHERE wt.status = 0
55+
AND EXISTS (
56+
SELECT 1
57+
FROM fees f
58+
INNER JOIN invoices i ON i.id = f.invoice_id
59+
WHERE f.invoiceable_id = wt.id
60+
AND f.invoiceable_type = 'WalletTransaction'
61+
AND i.payment_status = 2
62+
AND NOT EXISTS (
63+
SELECT 1
64+
FROM payments p
65+
WHERE p.payable_id = i.id
66+
)
67+
);
68+
SQL
69+
end
70+
end
71+
72+
def down
73+
# do nothing
74+
end
75+
end

db/schema.rb

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

schema.graphql

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)