-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(payments): preventing double payments resulting from a race condi…
…tion Fixed the situation where invoice and debit notes sent at the end of the agreement resulted with double payments. While the original issue has to be solved in yagna, we provide an integrity ensuring solution right now. Doubled payments can still occur, if the debit note will arrive before the invoice. Solving this was not part of this particular change.
- Loading branch information
Showing
9 changed files
with
590 additions
and
195 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,243 @@ | ||
import { AgreementPaymentProcess } from "./agreement_payment_process"; | ||
import { anything, instance, mock, objectContaining, reset, verify, when } from "@johanblumenberg/ts-mockito"; | ||
import { Agreement } from "../agreement"; | ||
import { Allocation } from "./allocation"; | ||
import { Invoice } from "./invoice"; | ||
import { InvoiceStatus } from "ya-ts-client/dist/ya-payment"; | ||
import { RejectionReason } from "./rejection"; | ||
import { DebitNote } from "./debit_note"; | ||
|
||
const agreementMock = mock(Agreement); | ||
const allocationMock = mock(Allocation); | ||
const invoiceMock = mock(Invoice); | ||
const debitNoteMock = mock(DebitNote); | ||
|
||
beforeEach(() => { | ||
reset(agreementMock); | ||
reset(allocationMock); | ||
reset(invoiceMock); | ||
reset(debitNoteMock); | ||
}); | ||
|
||
describe("AgreementPaymentProcess", () => { | ||
describe("Accepting Invoices", () => { | ||
describe("Positive cases", () => { | ||
it("accepts a single invoice", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(invoiceMock.amount).thenReturn("0.123"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const success = await process.addInvoice(instance(invoiceMock)); | ||
|
||
expect(success).toEqual(true); | ||
verify(invoiceMock.accept("0.123", "1000")).called(); | ||
expect(process.isFinished()).toEqual(true); | ||
}); | ||
|
||
it("rejects invoice if it's ignored by the user defined invoice filter", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(invoiceMock.amount).thenReturn("0.123"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => false, | ||
}); | ||
|
||
const success = await process.addInvoice(instance(invoiceMock)); | ||
|
||
expect(success).toEqual(false); | ||
verify( | ||
invoiceMock.reject( | ||
objectContaining({ | ||
rejectionReason: RejectionReason.RejectedByRequestorFilter, | ||
message: "Invoice rejected by Invoice Filter", | ||
totalAmountAccepted: "0", | ||
}), | ||
), | ||
).called(); | ||
expect(process.isFinished()).toEqual(false); | ||
Check failure on line 62 in src/payment/agreement_payment_process.test.ts GitHub Actions / Build and unit-test on supported platforms and NodeJS versions (18.x, ubuntu-latest)AgreementPaymentProcess › Accepting Invoices › Positive cases › rejects invoice if it's ignored by the user defined invoice filter
Check failure on line 62 in src/payment/agreement_payment_process.test.ts GitHub Actions / Build and unit-test on supported platforms and NodeJS versions (20.x, ubuntu-latest)AgreementPaymentProcess › Accepting Invoices › Positive cases › rejects invoice if it's ignored by the user defined invoice filter
|
||
}); | ||
|
||
it("accepts the duplicated invoice if the previous one is still not processed", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(invoiceMock.amount).thenReturn("0.123"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const invoice = instance(invoiceMock); | ||
|
||
// Simulate issue with accepting the first one | ||
const issue = new Error("Failed to accept in yagna"); | ||
when(invoiceMock.accept("0.123", "1000")) | ||
.thenReject(issue) // On first call | ||
.thenResolve(); // On second call | ||
|
||
await expect(() => process.addInvoice(invoice)).rejects.toThrow(issue); | ||
|
||
// Then simulate the duplicate coming again | ||
const success = await process.addInvoice(invoice); | ||
|
||
expect(success).toEqual(true); | ||
verify(invoiceMock.accept("0.123", "1000")).twice(); | ||
expect(process.isFinished()).toEqual(true); | ||
}); | ||
}); | ||
|
||
describe("Negative cases", () => { | ||
it("doesn't accept the same invoice twice if the previous one was already processed", async () => { | ||
// TODO: False and no error to not break? | ||
when(invoiceMock.getStatus()).thenResolve(InvoiceStatus.Accepted); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const invoice = instance(invoiceMock); | ||
|
||
const success = await process.addInvoice(invoice); | ||
|
||
expect(success).toEqual(true); | ||
expect(process.isFinished()).toEqual(true); | ||
await expect(() => process.addInvoice(invoice)).rejects.toThrow( | ||
"This agreement is already covered with an invoice", | ||
); | ||
}); | ||
}); | ||
}); | ||
|
||
describe("Accepting DebitNotes", () => { | ||
describe("Positive cases", () => { | ||
test("accepts a single debit note", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(debitNoteMock.totalAmountDue).thenReturn("0.123"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const debitNote = instance(debitNoteMock); | ||
|
||
const success = await process.addDebitNote(debitNote); | ||
|
||
expect(success).toEqual(true); | ||
verify(debitNoteMock.accept("0.123", "1000")).called(); | ||
expect(process.isFinished()).toEqual(false); | ||
}); | ||
|
||
test("rejects debit note if it's ignored by the user defined debit note filter", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(debitNoteMock.totalAmountDue).thenReturn("0.123"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => false, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const debitNote = instance(debitNoteMock); | ||
|
||
const success = await process.addDebitNote(debitNote); | ||
|
||
expect(success).toEqual(false); | ||
verify( | ||
debitNoteMock.reject( | ||
objectContaining({ | ||
rejectionReason: RejectionReason.RejectedByRequestorFilter, | ||
message: "DebitNote rejected by DebitNote Filter", | ||
totalAmountAccepted: "0", | ||
}), | ||
), | ||
).called(); | ||
expect(process.isFinished()).toEqual(false); | ||
}); | ||
|
||
test("rejects debit note if there is already an invoice for that process", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(invoiceMock.amount).thenReturn("0.123"); | ||
when(debitNoteMock.totalAmountDue).thenReturn("0.456"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const invoice = instance(invoiceMock); | ||
const debitNote = instance(debitNoteMock); | ||
|
||
const invoiceSuccess = await process.addInvoice(invoice); | ||
const debitNoteSuccess = await process.addDebitNote(debitNote); | ||
|
||
expect(invoiceSuccess).toEqual(true); | ||
verify(invoiceMock.accept("0.123", "1000")).called(); | ||
|
||
expect(debitNoteSuccess).toEqual(false); | ||
verify( | ||
debitNoteMock.reject( | ||
objectContaining({ | ||
rejectionReason: RejectionReason.AgreementFinalized, | ||
message: | ||
"DebitNote rejected because the agreement is already covered with a final invoice that should be paid instead of the debit note", | ||
totalAmountAccepted: "0", | ||
}), | ||
), | ||
).called(); | ||
expect(process.isFinished()).toEqual(true); | ||
}); | ||
|
||
test("accepts the duplicated debit note if the previous one is still not processed", async () => { | ||
when(allocationMock.id).thenReturn("1000"); | ||
when(debitNoteMock.totalAmountDue).thenReturn("0.123"); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
const debitNote = instance(debitNoteMock); | ||
|
||
// Simulate issue with accepting the first one | ||
const issue = new Error("Failed to accept in yagna"); | ||
when(debitNoteMock.accept("0.123", "1000")) | ||
.thenReject(issue) // On first call | ||
.thenResolve(); // On second call | ||
|
||
await expect(() => process.addDebitNote(debitNote)).rejects.toThrow(issue); | ||
|
||
// Then simulate the duplicate coming again | ||
const success = await process.addDebitNote(debitNote); | ||
|
||
expect(success).toEqual(true); | ||
verify(debitNoteMock.accept("0.123", "1000")).twice(); | ||
expect(process.isFinished()).toEqual(false); | ||
}); | ||
|
||
test("doesn't accept the same debit note twice if the previous one was already processed", async () => { | ||
when(debitNoteMock.getStatus()).thenResolve(InvoiceStatus.Accepted); | ||
|
||
const process = new AgreementPaymentProcess(instance(agreementMock), instance(allocationMock), { | ||
debitNoteFilter: () => true, | ||
invoiceFilter: () => true, | ||
}); | ||
|
||
// When | ||
const debitNote = instance(debitNoteMock); | ||
|
||
const firstSuccess = await process.addDebitNote(debitNote); | ||
expect(firstSuccess).toEqual(true); | ||
|
||
const secondSuccess = await process.addDebitNote(debitNote); | ||
expect(secondSuccess).toEqual(false); | ||
verify(debitNoteMock.reject(anything())).never(); | ||
expect(process.isFinished()).toEqual(false); | ||
}); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.