Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle refunds where there is more than one invoice on the same day #198

Merged
merged 1 commit into from
Nov 14, 2023

Conversation

rupertbates
Copy link
Member

What does this change?

The invoicing-api currently has an issue with refunding subscriptions where there is more than one invoice on the same day.

This can happen during a product switch, for instance, if I take out an annual recurring contribution for £30 then decide that actually I want supporter plus so that I can access the app and immediately switch to annual S+ at a cost of £95/year. In Zuora my subscription will now have two associated invoices dated today one for £30 and one for £65 (30 + 65 = total cost of the S+ sub).

If I then cancel within 14 days we will attempt a refund of the £65 paid at switch using the invoicing-api and that will pick the most recent positive posted invoice on the account to carry out the refund on.

However the 'most recent' check is only to a day level so which of the invoices it chooses is undefined - in practice it seems to be the first one which in this example is the wrong one and does not have enough money on it to enable the refund.

This PR changes the behaviour which picks the appropriate invoice to run the refund against by ensuring that the invoice which we pick has an amount at least equal to the amount we are trying to refund.

invoices: List[Invoice],
itemsByInvoiceId: Map[String, List[InvoiceItem]],
): (String, Invoice, List[InvoiceItem]) = {
joinInvoiceWithInvoiceItemsOnInvoiceIdKey(invoices, itemsByInvoiceId).iterator
.filter({ case (_, invoice, _) => invoice.Status == "Posted" })
.filter({ case (_, invoice, _) => invoice.Amount > 0 })
.filter({ case (_, invoice, _) => invoice.Amount >= refund })
Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe this should be invoice.PaymentAmount instead?

Copy link
Member

Choose a reason for hiding this comment

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

good question, if they are in payment failure, (or the invoice is already adjusted or paid from a credit balance) then this might not work out well

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually existing adjustments and payments are fetched and checked later on in the program logic:

val itemAdjustments = getInvoiceItemAdjustments(invoiceId) tap { _ =>

val Some(paymentId) = getInvoicePaymentId(invoiceId) tap { paymentId =>

So it's probably ok, I wouldn't be surprised if there's a way to break it though!

Copy link
Member

Choose a reason for hiding this comment

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

yeah but by that time we have already settled on a specific invoice which we fail if it doesn't have a payment

https://github.com/guardian/invoicing-api/blob/38fca38faa79761035975920e3a3ebdbd8687c4f/src/main/scala/com/gu/invoicing/refund/Program.scala#L49C27-L49C46
It doesn't look like we check if it's in payment failure, we would try to refund against a failed payment perhaps

s"""{"queryString": "select Id, invoiceId, paymentId, CreatedDate from InvoicePayment where invoiceId = '$invoiceId'"}""",

the adjustment fetching is just so we can distribute further adjustments by the look

val (invoiceId, invoice, items) = decideRelevantInvoice(refund, invoices, itemsByInvoiceId) tap {
case (_, invoice, _) =>
s"$invoice should be posted and have an amount >= the refund value" assert (
invoice.Amount >= refund && invoice.Status == "Posted"
Copy link
Member

Choose a reason for hiding this comment

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

I guess this is some kind of postcondition based programming? as it's asserting the logic in the function anyway.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes it's slightly odd, I think it's just to concentrate all the error handling in one place and ensure that it's consistent and readable. I actually quite like it as an approach.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

@johnduffell johnduffell left a comment

Choose a reason for hiding this comment

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

it's going to work better in this case, but I think there are bigger issues in how we're using this functionality. I think we should probably model this on the auto cancellation process - ideally find the exact invoice for the cancelled period, and only refund the payment on that.

@rupertbates rupertbates merged commit fe8bccb into main Nov 14, 2023
2 checks passed
@rupertbates rupertbates deleted the check-invoice-amount branch November 14, 2023 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants