Skip to content

Commit 6488c55

Browse files
authored
fix(backend): local payments - allow multiple outgoing payments pay into single incoming (#3428)
* fix(backend): allow paying multiple times into incoming payment * chore(backend): add log for outgoing payment worker * feat(integration): add CreateOutgoingPaymentFromIncomingPayment admin action * test(integration): add & update integration tests for multiple outgoing payments into single incoming * chore: lint
1 parent 8783194 commit 6488c55

File tree

10 files changed

+281
-105
lines changed

10 files changed

+281
-105
lines changed

packages/backend/src/open_payments/payment/outgoing/worker.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,21 +119,25 @@ async function onLifecycleError(
119119
const error = typeof err === 'string' ? err : err.message
120120
const stateAttempts = payment.stateAttempts + 1
121121

122+
const errorDescription =
123+
err instanceof PaymentMethodHandlerError ? err.description : undefined
124+
125+
const errLog = {
126+
state: payment.state,
127+
error,
128+
stateAttempts,
129+
errorDescription
130+
}
131+
122132
if (
123133
stateAttempts < deps.config.maxOutgoingPaymentRetryAttempts &&
124134
isRetryableError(err)
125135
) {
126-
deps.logger.warn(
127-
{ state: payment.state, error, stateAttempts },
128-
'payment lifecycle failed; retrying'
129-
)
136+
deps.logger.warn(errLog, 'payment lifecycle failed; retrying')
130137
await payment.$query(deps.knex).patch({ stateAttempts })
131138
} else {
132139
// Too many attempts or non-retryable error; fail payment.
133-
deps.logger.warn(
134-
{ state: payment.state, error, stateAttempts },
135-
'payment lifecycle failed'
136-
)
140+
deps.logger.warn(errLog, 'payment lifecycle failed')
137141
await lifecycle.handleFailed(deps, payment, error)
138142
}
139143
}

packages/backend/src/payment-method/local/service.test.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,8 @@ describe('LocalPaymentService', (): void => {
466466
}
467467
})
468468

469-
test('throws error if incoming payment state is not pending', async (): Promise<void> => {
470-
const { receiver, outgoingPayment } =
469+
test('throws error if incoming payment is completed', async (): Promise<void> => {
470+
const { receiver, outgoingPayment, incomingPayment } =
471471
await createOutgoingPaymentWithReceiver(deps, {
472472
sendingWalletAddress: walletAddressMap['USD'],
473473
receivingWalletAddress: walletAddressMap['USD'],
@@ -481,9 +481,51 @@ describe('LocalPaymentService', (): void => {
481481
}
482482
})
483483

484-
jest.spyOn(incomingPaymentService, 'get').mockResolvedValueOnce({
485-
state: IncomingPaymentState.Processing
486-
} as IncomingPayment)
484+
incomingPayment.state = IncomingPaymentState.Completed
485+
486+
jest
487+
.spyOn(incomingPaymentService, 'get')
488+
.mockResolvedValueOnce(incomingPayment)
489+
490+
expect.assertions(4)
491+
try {
492+
await localPaymentService.pay({
493+
receiver,
494+
outgoingPayment,
495+
finalDebitAmount: 100n,
496+
finalReceiveAmount: 100n
497+
})
498+
} catch (err) {
499+
expect(err).toBeInstanceOf(PaymentMethodHandlerError)
500+
expect((err as PaymentMethodHandlerError).message).toBe(
501+
'Bad Incoming Payment State'
502+
)
503+
expect((err as PaymentMethodHandlerError).description).toBe(
504+
'Incoming Payment cannot be expired or completed'
505+
)
506+
expect((err as PaymentMethodHandlerError).retryable).toBe(false)
507+
}
508+
})
509+
510+
test('throws error if incoming payment is expired', async (): Promise<void> => {
511+
const { receiver, outgoingPayment, incomingPayment } =
512+
await createOutgoingPaymentWithReceiver(deps, {
513+
sendingWalletAddress: walletAddressMap['USD'],
514+
receivingWalletAddress: walletAddressMap['USD'],
515+
method: 'ilp',
516+
quoteOptions: {
517+
debitAmount: {
518+
value: 100n,
519+
assetScale: walletAddressMap['USD'].asset.scale,
520+
assetCode: walletAddressMap['USD'].asset.code
521+
}
522+
}
523+
})
524+
525+
incomingPayment.state = IncomingPaymentState.Expired
526+
jest
527+
.spyOn(incomingPaymentService, 'get')
528+
.mockResolvedValueOnce(incomingPayment)
487529

488530
expect.assertions(4)
489531
try {
@@ -499,7 +541,7 @@ describe('LocalPaymentService', (): void => {
499541
'Bad Incoming Payment State'
500542
)
501543
expect((err as PaymentMethodHandlerError).description).toBe(
502-
`Incoming Payment state should be ${IncomingPaymentState.Pending}`
544+
'Incoming Payment cannot be expired or completed'
503545
)
504546
expect((err as PaymentMethodHandlerError).retryable).toBe(false)
505547
}

packages/backend/src/payment-method/local/service.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
TransferError
3131
} from '../../accounting/errors'
3232
import { IncomingPaymentService } from '../../open_payments/payment/incoming/service'
33-
import { IncomingPaymentState } from '../../open_payments/payment/incoming/model'
3433
import { ConvertResults } from '../../rates/util'
3534

3635
export interface LocalPaymentService extends PaymentMethodService {}
@@ -226,9 +225,9 @@ async function pay(
226225
retryable: false
227226
})
228227
}
229-
if (incomingPayment.state !== IncomingPaymentState.Pending) {
228+
if (incomingPayment.isExpiredOrComplete()) {
230229
throw new PaymentMethodHandlerError('Bad Incoming Payment State', {
231-
description: `Incoming Payment state should be ${IncomingPaymentState.Pending}`,
230+
description: `Incoming Payment cannot be expired or completed`,
232231
retryable: false
233232
})
234233
}

test/integration/integration.test.ts

Lines changed: 94 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'assert'
22
import { MockASE, C9_CONFIG, HLB_CONFIG } from 'test-lib'
3-
import { Fee, WebhookEventType } from 'mock-account-service-lib'
3+
import { WebhookEventType } from 'mock-account-service-lib'
44
import { poll } from './lib/utils'
55
import { TestActions, createTestActions } from './lib/test-actions'
66
import { IncomingPaymentState } from 'test-lib/dist/generated/graphql'
@@ -126,7 +126,6 @@ describe('Integration tests', (): void => {
126126
const outgoingPaymentGrant = await grantRequestOutgoingPayment(
127127
senderWalletAddress,
128128
{
129-
debitAmount: quote.debitAmount,
130129
receiveAmount: quote.receiveAmount
131130
}
132131
)
@@ -202,10 +201,7 @@ describe('Integration tests', (): void => {
202201
)
203202
const outgoingPaymentGrant = await grantRequestOutgoingPayment(
204203
senderWalletAddress,
205-
{
206-
debitAmount: quote.debitAmount,
207-
receiveAmount: quote.receiveAmount
208-
},
204+
{ receiveAmount: quote.receiveAmount },
209205
{
210206
method: 'redirect',
211207
uri: 'https://example.com',
@@ -238,14 +234,15 @@ describe('Integration tests', (): void => {
238234

239235
await getPublicIncomingPayment(incomingPayment.id, amountValueToSend)
240236
})
241-
test('Open Payments without Quote', async (): Promise<void> => {
237+
test('Open Payments Multiple Outgoing Payments into Incoming Payment', async (): Promise<void> => {
242238
const {
243239
grantRequestIncomingPayment,
244240
createIncomingPayment,
245241
grantRequestOutgoingPayment,
246242
pollGrantContinue,
247243
createOutgoingPayment,
248-
getOutgoingPayment
244+
getOutgoingPayment,
245+
getPublicIncomingPayment
249246
} = testActions.openPayments
250247
const { consentInteraction } = testActions
251248

@@ -264,11 +261,7 @@ describe('Integration tests', (): void => {
264261
})
265262
expect(senderWalletAddress.id).toBe(senderWalletAddressUrl)
266263

267-
const debitAmount = {
268-
assetCode: senderWalletAddress.assetCode,
269-
assetScale: senderWalletAddress.assetScale,
270-
value: '500'
271-
}
264+
const grantValue = 100n
272265

273266
const incomingPaymentGrant = await grantRequestIncomingPayment(
274267
receiverWalletAddress
@@ -281,13 +274,23 @@ describe('Integration tests', (): void => {
281274
const outgoingPaymentGrant = await grantRequestOutgoingPayment(
282275
senderWalletAddress,
283276
{
284-
debitAmount,
285-
receiveAmount: debitAmount
277+
debitAmount: {
278+
assetCode: senderWalletAddress.assetCode,
279+
assetScale: senderWalletAddress.assetScale,
280+
value: String(grantValue)
281+
}
286282
}
287283
)
288284
await consentInteraction(outgoingPaymentGrant, senderWalletAddress)
289285
const grantContinue = await pollGrantContinue(outgoingPaymentGrant)
290-
const outgoingPayment = await createOutgoingPayment(
286+
287+
const debitAmount = {
288+
assetCode: senderWalletAddress.assetCode,
289+
assetScale: senderWalletAddress.assetScale,
290+
value: '50'
291+
}
292+
293+
const outgoingPayment1 = await createOutgoingPayment(
291294
senderWalletAddress,
292295
grantContinue,
293296
{
@@ -296,12 +299,32 @@ describe('Integration tests', (): void => {
296299
}
297300
)
298301

299-
const outgoingPayment_ = await getOutgoingPayment(
300-
outgoingPayment.id,
301-
grantContinue
302+
await poll(
303+
async () => getOutgoingPayment(outgoingPayment1.id, grantContinue),
304+
(responseData) => BigInt(responseData.sentAmount.value) > 0n,
305+
5,
306+
0.5
307+
)
308+
309+
expect(outgoingPayment1.debitAmount).toMatchObject(debitAmount)
310+
311+
const outgoingPayment2 = await createOutgoingPayment(
312+
senderWalletAddress,
313+
grantContinue,
314+
{
315+
incomingPayment: incomingPayment.id,
316+
debitAmount
317+
}
318+
)
319+
320+
await poll(
321+
async () => getOutgoingPayment(outgoingPayment2.id, grantContinue),
322+
(responseData) => BigInt(responseData.sentAmount.value) > 0n,
323+
5,
324+
0.5
302325
)
303326

304-
expect(outgoingPayment_.debitAmount).toMatchObject(debitAmount)
327+
await getPublicIncomingPayment(incomingPayment.id, '98') // adjusted for ILP slippage
305328
})
306329
test('Peer to Peer', async (): Promise<void> => {
307330
const {
@@ -399,7 +422,6 @@ describe('Integration tests', (): void => {
399422
const receiverAssetCode = receiver.incomingAmount.assetCode
400423
const exchangeRate =
401424
hlb.config.seed.rates[senderAssetCode][receiverAssetCode]
402-
const fee = c9.config.seed.fees.find((fee: Fee) => fee.asset === 'USD')
403425

404426
// Expected amounts depend on the configuration of asset codes, scale, exchange rate, and fees.
405427
assert(receiverAssetCode === 'EUR')
@@ -409,11 +431,6 @@ describe('Integration tests', (): void => {
409431
)
410432
assert(senderWalletAddress.assetScale === 2)
411433
assert(exchangeRate === 0.91)
412-
assert(fee)
413-
assert(fee.fixed === 100)
414-
assert(fee.basisPoints === 200)
415-
assert(fee.asset === 'USD')
416-
assert(fee.scale === 2)
417434
expect(completedOutgoingPayment.receiveAmount).toMatchObject({
418435
assetCode: 'EUR',
419436
assetScale: 2,
@@ -422,7 +439,7 @@ describe('Integration tests', (): void => {
422439
expect(completedOutgoingPayment.debitAmount).toMatchObject({
423440
assetCode: 'USD',
424441
assetScale: 2,
425-
value: 668n
442+
value: 556n // with ILP slippage
426443
})
427444
expect(completedOutgoingPayment.sentAmount).toMatchObject({
428445
assetCode: 'USD',
@@ -604,7 +621,6 @@ describe('Integration tests', (): void => {
604621
const receiverAssetCode = receiver.incomingAmount.assetCode
605622
const exchangeRate =
606623
c9.config.seed.rates[senderAssetCode][receiverAssetCode]
607-
const fee = c9.config.seed.fees.find((fee: Fee) => fee.asset === 'USD')
608624

609625
// Expected amounts depend on the configuration of asset codes, scale, exchange rate, and fees.
610626
assert(receiverAssetCode === 'EUR')
@@ -614,11 +630,6 @@ describe('Integration tests', (): void => {
614630
)
615631
assert(senderWalletAddress.assetScale === 2)
616632
assert(exchangeRate === 0.91)
617-
assert(fee)
618-
assert(fee.fixed === 100)
619-
assert(fee.basisPoints === 200)
620-
assert(fee.asset === 'USD')
621-
assert(fee.scale === 2)
622633
expect(completedOutgoingPayment.receiveAmount).toMatchObject({
623634
assetCode: 'EUR',
624635
assetScale: 2,
@@ -627,7 +638,7 @@ describe('Integration tests', (): void => {
627638
expect(completedOutgoingPayment.debitAmount).toMatchObject({
628639
assetCode: 'USD',
629640
assetScale: 2,
630-
value: 661n
641+
value: 550n
631642
})
632643
expect(completedOutgoingPayment.sentAmount).toMatchObject({
633644
assetCode: 'USD',
@@ -644,6 +655,55 @@ describe('Integration tests', (): void => {
644655
})
645656
expect(incomingPayment.state).toBe(IncomingPaymentState.Completed)
646657
})
658+
659+
test('Peer to Peer - Multiple Outgoing Payments into Incoming Payment', async (): Promise<void> => {
660+
const {
661+
createReceiver,
662+
createOutgoingPaymentFromIncomingPayment,
663+
getIncomingPayment
664+
} = testActions.admin
665+
666+
const senderWalletAddress = await c9.accounts.getByWalletAddressUrl(
667+
'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin'
668+
)
669+
assert(senderWalletAddress?.walletAddressID)
670+
671+
const senderWalletAddressId = senderWalletAddress.walletAddressID
672+
const createReceiverInput = {
673+
metadata: {
674+
description: 'For lunch!'
675+
},
676+
walletAddressUrl:
677+
'https://cloud-nine-wallet-test-backend:3100/accounts/bhamchest'
678+
}
679+
680+
const value = '500'
681+
const receiver = await createReceiver(createReceiverInput)
682+
683+
await createOutgoingPaymentFromIncomingPayment({
684+
incomingPayment: receiver.id,
685+
walletAddressId: senderWalletAddressId,
686+
debitAmount: {
687+
assetCode: 'USD',
688+
assetScale: 2,
689+
value: value as unknown as bigint
690+
}
691+
})
692+
await createOutgoingPaymentFromIncomingPayment({
693+
incomingPayment: receiver.id,
694+
walletAddressId: senderWalletAddressId,
695+
debitAmount: {
696+
assetCode: 'USD',
697+
assetScale: 2,
698+
value: value as unknown as bigint
699+
}
700+
})
701+
702+
const incomingPaymentId = receiver.id.split('/').slice(-1)[0]
703+
const incomingPayment = await getIncomingPayment(incomingPaymentId)
704+
expect(incomingPayment.receivedAmount.value).toBe(BigInt(value) * 2n)
705+
expect(incomingPayment.state).toBe(IncomingPaymentState.Processing)
706+
})
647707
})
648708
})
649709
})

0 commit comments

Comments
 (0)