Skip to content
This repository was archived by the owner on Jun 2, 2023. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
> :warning: This repository was archived automatically since no ownership was defined :warning:
>
> For details on how to claim stewardship of this repository see:
>
> [How to configure a service in OpsLevel](https://www.notion.so/pleo/How-to-configure-a-service-in-OpsLevel-f6483fcb4fdd4dcc9fc32b7dfe14c262)
>
> To learn more about the automatic process for stewardship which archived this repository see:
>
> [Automatic process for stewardship](https://www.notion.so/pleo/Automatic-process-for-stewardship-43d9def9bc9a4010aba27144ef31e0f2)

## Initial thoughts on how to tackle the problem:

### Task at hand:

- build a logic that will schedule payments and **trigger** payments via the PaymentProvider interface at the first of the month

### assumptions:
1. Time was spent investigating. Solutions like jobrunr.io and others were found to be inadequate for this task. That is why we write our own.
2. The PaymentProvider interface is external to this system, but internal to pleo itself (assumption was made due to passing customerId instead of IBANs).
3. There is only one PaymentProvider system.
4. It is unknown to us if and how fast the PaymentProvider interface can scale.
5. The number of invoices is not that large, that the machine will run out of memory when loading all at once.
6. The number of invoices is not that large, that one machine cannot process them in a timely manner.

### solution idea:
1. schedule task to be executed at the first of the month
2. fetch all unpaid and not failed invoices from the DB
3. process each fetched invoice:
1. mark successfully charged invoices as paid
2. mark invoices failed due to account balance as failed and notify customer
3. in case of a NetworkException the processing is throttled and failed ones retried
- this is done because of assumption two, three and four
- if the retry failed it will be marked for retry in the DB and retried with the other failed invoices
4. in case of a CustomerNotFoundException or CurrencyMismatchException, the invoice is marked as failed:
- failed invoices will either be reviewed by the support-team or an automated solution exists to fix issues
- failed invoices will be marked for retry
4. after support approves or an automated system fixed the failed invoices, they are marked by the external system ready for retry.
5. As retry marked invoices will be reprocessed. API-endpoint for trigger (/rest/v1/rerun).


## Comments on possible Improvements:
- Not much thought put into the design of the added API-Endpoints. As they are considered out of scope for this task. Prob. Improvements needed.
- Batch processing of invoices could be implemented. Decreases loading time from DB and failover time improves.
- Process of handling failed Invoices
- No recovery mechanism is implemented in case of processing failure.
- writing most testcases was skipped due to lack of time

## time spent:
1. 1,5h looking through the code base, running the rest API and sketching out the first solution idea
2. 3h first iteration: no concurrency, no tests, no scheduler
3. 2,5h Added Concurrency and scheduler. Investigation on how kotlin coroutine works in detail. Upgrading of dependencies to use this feature fully (most of the time spent here :/)
4. 1,5h Fighting with gradle a bit more. & added some test (should be expanded on...). + improved comments

### Total time spent:
~ 1d familiarising myself with Kotlin. It is the first time I use it.
~ 1d working on this project

----

## Antaeus

Expand Down
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
base
kotlin("jvm") version "1.3.70" apply false
kotlin("jvm") version "1.7.21" apply false
}

allprojects {
Expand All @@ -15,7 +15,7 @@ allprojects {
}

tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.jvmTarget = "11"
kotlinOptions.jvmTarget = "17"
}

tasks.withType<Test> {
Expand Down
9 changes: 6 additions & 3 deletions buildSrc/src/main/kotlin/utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@ fun Project.kotlinProject() {
// Kotlin libs
"implementation"(kotlin("stdlib"))

// coroutines
"implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")

// Logging
"implementation"("org.slf4j:slf4j-simple:1.7.30")
"implementation"("org.slf4j:slf4j-simple:2.0.7")
"implementation"("io.github.microutils:kotlin-logging:1.7.8")

// Mockk
"testImplementation"("io.mockk:mockk:1.9.3")
"testImplementation"("io.mockk:mockk:1.13.4")

// JUnit 5
"testImplementation"("org.junit.jupiter:junit-jupiter-api:$junitVersion")
"testImplementation"("org.junit.jupiter:junit-jupiter-params:$junitVersion")
"runtime"("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
"testRuntimeOnly"("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
}
}

Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package io.pleo.antaeus.app

import getPaymentProvider
import io.pleo.antaeus.core.scheduler.BillingScheduler
import io.pleo.antaeus.core.services.BillingService
import io.pleo.antaeus.core.services.CustomerService
import io.pleo.antaeus.core.services.InvoiceService
Expand Down Expand Up @@ -61,11 +62,16 @@ fun main() {
val customerService = CustomerService(dal = dal)

// This is _your_ billing service to be included where you see fit
val billingService = BillingService(paymentProvider = paymentProvider)
val billingService = BillingService(paymentProvider = paymentProvider, invoiceService = invoiceService)

val billingSchedule = BillingScheduler(billingService)
billingSchedule.scheduleExecution()
billingService.setScheduler(billingSchedule)

// Create REST web service
AntaeusRest(
invoiceService = invoiceService,
customerService = customerService
customerService = customerService,
billingService = billingService
).run()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.pleo.antaeus.core.scheduler

import io.pleo.antaeus.core.services.BillingService
import mu.KotlinLogging
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*

const val TIMEZONE = "Europe/Berlin" //TODO: move into config file and/or make set automatically
private val logger = KotlinLogging.logger {}

class BillingScheduler(private val billingService: BillingService?) {

fun scheduleExecution(){
val executionDay = LocalDateTime.now().withDayOfMonth(1)
.plusMonths(1).withHour(1).withMinute(0).withSecond(0).withNano(0).atZone(ZoneId.of(TIMEZONE))
val date : Date = Date.from(Instant.from(executionDay))

//Schedule with period not used, because time between two firsts of the months is not consistent
Timer("BillingService-$date", true).schedule(billingService, date)
logger.info { "The next planned BillingService execution is on the $date." }
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,126 @@
package io.pleo.antaeus.core.services

import io.pleo.antaeus.core.exceptions.CurrencyMismatchException
import io.pleo.antaeus.core.exceptions.CustomerNotFoundException
import io.pleo.antaeus.core.exceptions.NetworkException
import io.pleo.antaeus.core.external.PaymentProvider
import io.pleo.antaeus.core.scheduler.BillingScheduler
import io.pleo.antaeus.models.Invoice
import io.pleo.antaeus.models.InvoiceStatus

import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import mu.KotlinLogging
import java.util.*

// All of these values could/should be moved to a config file and adjusted depending on the PaymentProvider system
const val MAX_RETRIES = 3
const val THROTTLE_MULTIPLIER = 7L
const val MAX_THROTTLE = 5L

private val logger = KotlinLogging.logger {}

/**
* Billing Service:
*
* With the assumptions 2, 3 and 4 in mind, when getting an NetworkException, throttling kicks in, in form of reduction in requests per second.
*/
class BillingService(
private val paymentProvider: PaymentProvider
) {
// TODO - Add code e.g. here
private val paymentProvider: PaymentProvider,
private val invoiceService: InvoiceService
) : TimerTask() {

//Not atomic even though it can be used by multiple coroutines at the same time.
//If the value is slightly above or below target values or a value that is not a 100% up to date,
// it will not influence the result in a significant way...
private var networkThrottle = 0L
private var failedInvoices = 0L

private lateinit var scheduler: BillingScheduler

fun setScheduler(schedulerService: BillingScheduler){
scheduler = schedulerService
}

override fun run(){
processInvoices(invoiceService.fetchPending()) //with large data-sets this should be pulled and processed in batches
scheduler.scheduleExecution() //Schedules the next execution
}

fun processRetryInvoices(){
processInvoices(invoiceService.fetchRetry()) //with large data-sets this should be pulled and processed in batches
}

private fun processInvoices(invoices: List<Invoice>) = runBlocking{

logger.info { "Started processing ${invoices.size} of invoices." }
for (invoice in invoices){
// The IO bound Dispatcher was chosen, because sending out the payment request and updating the DB entry are IO bound.
launch(Dispatchers.IO){
processSingleInvoice(invoice)

//In cases of failures between these two functions. Some Invoices could be processed double.
// To avoid this, the recovery mechanism would need to check bank transactions of customers who have
// pending invoices and update the invoices accordingly.
invoiceService.updateStatus(invoice)
}
delay(networkThrottle * THROTTLE_MULTIPLIER)
}

logger.info { "Finished processing Invoices." +
"Number of Invoices failed: $failedInvoices ." }
}

/**
* IncreaseThrottle and decreaseThrottle could be more sophisticated and
* use methodologies borrowed from Networking protocols like TCP.
*/
private fun increaseThrottle(){
if (networkThrottle < MAX_THROTTLE){
networkThrottle += 1
}
}

private fun decreaseThrottle(){
if (0 < networkThrottle){
networkThrottle -= 1
}
}

private suspend fun processSingleInvoice(invoice: Invoice, retriesLeft: Int = MAX_RETRIES){
try {
if (paymentProvider.charge(invoice)){
invoice.status = InvoiceStatus.PAID
}else{
invoice.status = InvoiceStatus.FAILED_NO_BALANCE
failedInvoices += 1
}
// It is called here, assuming that after success, the PaymentProvider had time to scale.
// If decreaseThrottle is never called we most likely will use maximum delay throughout the whole process.
decreaseThrottle()
}catch (e: NetworkException){
/**
* Even though NetworkException is very general and can have multifaceted reasons; connection refused,
* timed out, addr. unreachable, etc.
* Waiting and retrying, resolves most issues...
*/
increaseThrottle()
if (0 < retriesLeft){
delay(networkThrottle * THROTTLE_MULTIPLIER)
processSingleInvoice(invoice, retriesLeft - 1)
}else{
invoice.status = InvoiceStatus.TO_RETRY
failedInvoices += 1
}
}catch (e: CurrencyMismatchException){
invoice.status = InvoiceStatus.FAILED_CURRENCY
logger.error { "Currency mismatch on invoice with id: ${invoice.id} and currency: ${invoice.amount.currency} " +
"for customer with id: ${invoice.customerId}" }
failedInvoices += 1
}catch (e: CustomerNotFoundException){
invoice.status = InvoiceStatus.FAILED_NO_CUSTOMER
logger.error { "Customer with id: ${invoice.customerId} not found." }
failedInvoices += 1
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package io.pleo.antaeus.core.services
import io.pleo.antaeus.core.exceptions.InvoiceNotFoundException
import io.pleo.antaeus.data.AntaeusDal
import io.pleo.antaeus.models.Invoice
import io.pleo.antaeus.models.InvoiceStatus

class InvoiceService(private val dal: AntaeusDal) {
fun fetchAll(): List<Invoice> {
Expand All @@ -16,4 +17,25 @@ class InvoiceService(private val dal: AntaeusDal) {
fun fetch(id: Int): Invoice {
return dal.fetchInvoice(id) ?: throw InvoiceNotFoundException(id)
}

fun fetchPending(): List<Invoice>{
return dal.fetchInvoices(InvoiceStatus.PENDING)
}

fun fetchRetry(): List<Invoice>{
return dal.fetchInvoices(InvoiceStatus.TO_RETRY)
}

fun fetchFixableFailedInvoices(): List<Invoice>{
return dal.fetchFixableFailedInvoices()
}

fun updateStatus(invoice: Invoice): Int{
return dal.updateInvoiceStatus(invoice)
}

fun markReadyForRetry(id: Int): Boolean{
dal.markInvoiceForRetry(id)
return true
}
}
Loading