Skip to content

Conversation

@BlairCurrey
Copy link
Contributor

@BlairCurrey BlairCurrey commented Sep 30, 2025

resolves #3374 (linear)

Some relevant context:

  • we are changing how amounts spent against grants are calculated. currently in main it just sums all payments on outgoing payment creation. instead we want to keep a record with the running total (OutgoingPaymentGrantSpentAmounts)
  • amounts tracked are the grant total and interval total (if any)
  • base branch bc/raf-1031/grant-spent-amounts has half of this implemented: incrementing the totals on outgoing payment creation. This PR is to add the other half: adjustments when outgoing payments are completed

Changes

  • Changes handleSending and handleFailed to get the latest spent amounts and update as needed. This happens within the lifecycle of the worker (outgoingPayment.processNext).
  • Adds many tests in lifecycle.test.ts which tests the grant total counting by calling outgoingPaymentService.processNext. This required mocking the paymentMethodHandler.pay method since we dont have another real rafiki to run it against and we need the control of manipulating the response (it fails, it completes partially, completes fully)

Deviations from the issue as stated:

  • issues only mentions "failure" but really the problem is any needed adjustments when payments are completed. This includes when payments fail, but also includes "partial" payments. That is, a payment which completes for an amount that differs from the amounted anticipated/reserved/held etc. during outgoing payment creation.

Manual Test

I mostly tested using unit tests because I needed control over the time (for intervals) and how pay was resolving. Here is how you can do some manual tests via bruno to verify the happy path.

Basic single payment case.

  1. Start Rafiki
  2. Do bruno Example > Open Payments flow
  3. connect to cloud_nine_wallet_backend database using your preferred method. You can connect directly to the psql shell with docker exec -it rafiki-shared-database-1 su - postgres -c "psql cloud_nine_wallet_backend" and then enter \x to make the output more readable.
  4. Query for the records with select * from "outgoingPaymentGrantSpentAmounts"; (filter by grantId/outgoingPaymentId etc. as needed if not a fresh volume).
  5. The flow should produce 1 record with these amounts matching:
cloud_nine_wallet_backend=# select * from "outgoingPaymentGrantSpentAmounts";
-[ RECORD 1 ]----------------+-------------------------------------
id                           | 567489e0-747c-4a4c-9587-efbc34ff9de9
grantId                      | dde5481d-6447-403f-877a-a11fa7f742c4
outgoingPaymentId            | 6fdc1b38-115d-48b7-a7fa-a994c3a8c1fe
receiveAmountScale           | 2
receiveAmountCode            | USD
paymentReceiveAmountValue    | 100
intervalReceiveAmountValue   | 
grantTotalReceiveAmountValue | 100
debitAmountScale             | 2
debitAmountCode              | USD
paymentDebitAmountValue      | 205
intervalDebitAmountValue     | 
grantTotalDebitAmountValue   | 205
paymentState                 | FUNDING
intervalStart                | 
intervalEnd                  | 
createdAt                    | 2025-10-22 17:48:32.643+00

@github-actions github-actions bot added type: tests Testing related pkg: backend Changes in the backend package. type: source Changes business logic labels Sep 30, 2025
@BlairCurrey BlairCurrey changed the title Blair/raf 1036 feat: handle grant spent amount calculation on payment completion Oct 7, 2025
Copy link
Contributor

@mkurapov mkurapov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few thoughts

state: OutgoingPaymentState.Funding,
grantId
grantId,
createdAt: new Date()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done automatically

(we have table.timestamp('createdAt').defaultTo(knex.fn.now()) in the create outgoing payments table migration)

Copy link
Contributor Author

@BlairCurrey BlairCurrey Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to do this so it can be controlled via fake timers in the tests. Otherwise there was a test that failed. It was some business logic checking mocked time vs. real outgoingPayment.createdAt (since it is set by postgres and uncontrollable by jest's fake timers).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it still work if the test patched the outgoing payment after it was created?

Copy link
Contributor Author

@BlairCurrey BlairCurrey Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thought on the patch, but I just tried it and no it doesnt work, because this check that comes right after creating the payment in outgoingPaymentService.create:

          if (
            payment.walletAddressId !== payment.quote.walletAddressId ||
            payment.quote.expiresAt.getTime() <= payment.createdAt.getTime()
          ) {
            throw OutgoingPaymentError.InvalidQuote
          }

Where the quote's expiresAt is subject to the mocked time but the payment is not. Trying to patch after its created results in a InvalidQuote error.

I think it's probably generally useful to be able to control it from the application level, so it can be mocked like this. Functionally setting it to new Date() should be the same as knex.fn.now().

},
'No outgoingPaymentGrantSpentAmounts record found for grant interval'
)
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to think through this a bit, but I think we would still want to update the OutgoingPaymentGrantSpentAmounts instead of returning early

Copy link
Contributor Author

@BlairCurrey BlairCurrey Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like, as a fallback? Is that your thinking?

I guess maybe that makes sense. This should never happen though so IDK how we'd get the correct interval/grant totals. Since it's totally unexpected I dont think we can just assume the interval/grant amounts were 0 or something. I suppose we can sum them all up like we do for the legacy path on the payment create side.

@BlairCurrey BlairCurrey changed the base branch from bc/raf-1031/grant-spent-amounts to main October 20, 2025 19:14
@BlairCurrey BlairCurrey changed the base branch from main to bc/raf-1031/grant-spent-amounts October 20, 2025 19:15
state: OutgoingPaymentState.Funding,
grantId
grantId,
createdAt: new Date()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it still work if the test patched the outgoing payment after it was created?

@BlairCurrey BlairCurrey marked this pull request as ready for review October 21, 2025 14:06
@BlairCurrey BlairCurrey requested a review from mkurapov October 30, 2025 17:08
)

await OutgoingPaymentGrantSpentAmounts.query(deps.knex).insert({
...latestPaymentSpentAmounts,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be safe, let's not have this spread operator and just define all of the fields in-line, otherwise we might include fields that we don't want to update

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

.first()

// Should have new spent amounts with payment factored out
expect(latestGrantSpentAmounts).toMatchObject({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor, but lets check there are two records that were created here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a check to make sure they are different records.

},
'ILP payment completed'
)
return { receive: receipt.amountDelivered, debit: finalDebitAmount }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should leave a comment here explaining the behaviour why not the receipt.amountSent

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at this point I think its just easier and more clear to just remove the debit amount from the return type. updated w/ that.

@BlairCurrey BlairCurrey merged commit 1be38b5 into bc/raf-1031/grant-spent-amounts Nov 1, 2025
26 of 38 checks passed
@BlairCurrey BlairCurrey deleted the blair/raf-1036 branch November 1, 2025 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: backend Changes in the backend package. type: source Changes business logic type: tests Testing related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants