Skip to content
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
70 changes: 54 additions & 16 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches:
- develop
pull_request:
branches:
- develop

jobs:
deploy:
Expand All @@ -17,24 +20,59 @@ jobs:
mkdir -p app/src/main/resources
echo "${{ secrets.FIREBASE_CREDENTIALS_BASE64 }}" | base64 -d > app/src/main/resources/spendoo-firebase-adminsdk.json

- name: Build Docker image
run: docker build -t josephsameh/spendoo-backend:latest .

- name: Push Docker image
- name: Log in to Docker Hub
run: |
echo ${{ secrets.DOCKER_HUB_PASSWORD }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin

- name: Build and Push Docker Image
run: |
docker build \
-t josephsameh/spendoo-backend:${{ github.sha }} \
-t josephsameh/spendoo-backend:latest .
docker push josephsameh/spendoo-backend:${{ github.sha }}
docker push josephsameh/spendoo-backend:latest

- name: Deploy on server
uses: appleboy/ssh-action@v0.1.5
- name: Log in to Azure
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}

- name: Sync App Settings to Azure
uses: azure/CLI@v1
with:
inlineScript: |
az webapp config appsettings set \
--name ${{ secrets.AZURE_WEBAPP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--settings \
DATABASE_URL="${{ secrets.DATABASE_URL }}" \
DATABASE_USERNAME="${{ secrets.DATABASE_USERNAME }}" \
DATABASE_PASSWORD="${{ secrets.DATABASE_PASSWORD }}" \
JWT_SECRET="${{ secrets.JWT_SECRET }}" \
MAIL_USERNAME="${{ secrets.MAIL_USERNAME }}" \
MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" \
PROFILE_IMAGE_DIRECTORY="${{ secrets.PROFILE_IMAGE_DIRECTORY }}" \
STORAGE_SPENDOO_BUCKET="${{ secrets.STORAGE_SPENDOO_BUCKET }}" \
STORAGE_SPENDOO_CDN_ENDPOINT="${{ secrets.STORAGE_SPENDOO_CDN_ENDPOINT }}" \
STORAGE_SPENDOO_ENDPOINT="${{ secrets.STORAGE_SPENDOO_ENDPOINT }}" \
STORAGE_SPENDOO_KEY="${{ secrets.STORAGE_SPENDOO_KEY }}" \
STORAGE_SPENDOO_SECRET="${{ secrets.STORAGE_SPENDOO_SECRET }}" \
INTERNAL_API_BASE_URL="${{ secrets.INTERNAL_API_BASE_URL }}" \
AI_SERVICE_BASE_URL="${{ secrets.AI_SERVICE_BASE_URL }}" \
SPENDOO_HMAC_SECRET_KEY="${{ secrets.SPENDOO_HMAC_SECRET_KEY }}" \
DOCKER_ENABLE_CI="true"

- name: Deploy to Azure App Service
uses: azure/webapps-deploy@v2
with:
app-name: ${{ secrets.AZURE_WEBAPP_NAME }}
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
images: 'josephsameh/spendoo-backend:${{ github.sha }}'

- name: Restart Azure App Service
uses: azure/CLI@v1
with:
host: ${{ secrets.SERVER_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull josephsameh/spendoo-backend:latest
docker stop springboot-container || true
docker rm springboot-container || true
docker run -d --restart=always --name springboot-container -p 8080:8080 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" -e DATABASE_USERNAME="${{ secrets.DATABASE_USERNAME }}" -e DATABASE_PASSWORD="${{ secrets.DATABASE_PASSWORD }}" -e JWT_SECRET="${{ secrets.JWT_SECRET }}" -e MAIL_USERNAME="${{ secrets.MAIL_USERNAME }}" -e MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" -e PROFILE_IMAGE_DIRECTORY="${{ secrets.PROFILE_IMAGE_DIRECTORY }}" -e STORAGE_SPENDOO_BUCKET="${{ secrets.STORAGE_SPENDOO_BUCKET }}" -e STORAGE_SPENDOO_CDN_ENDPOINT="${{ secrets.STORAGE_SPENDOO_CDN_ENDPOINT }}" -e STORAGE_SPENDOO_ENDPOINT="${{ secrets.STORAGE_SPENDOO_ENDPOINT }}" -e STORAGE_SPENDOO_KEY="${{ secrets.STORAGE_SPENDOO_KEY }}" -e STORAGE_SPENDOO_SECRET="${{ secrets.STORAGE_SPENDOO_SECRET }}" -e INTERNAL_API_BASE_URL="${{ secrets.INTERNAL_API_BASE_URL }}" -e AI_SERVICE_BASE_URL="${{ secrets.AI_SERVICE_BASE_URL }}" josephsameh/spendoo-backend:latest
docker image prune -f
inlineScript: |
az webapp restart \
--name ${{ secrets.AZURE_WEBAPP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }}
6 changes: 5 additions & 1 deletion app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,8 @@ file-size.modules.identity=5
server.forward-headers-strategy=framework

internal.api.base-url=${INTERNAL_API_BASE_URL:http://localhost:8080}
ai.service.base-url=${AI_SERVICE_BASE_URL:http://127.0.0.1:8000}
ai.service.base-url=${AI_SERVICE_BASE_URL:http://127.0.0.1:8000}
spendoo.hmac.secret-key=${SPENDOO_HMAC_SECRET_KEY:}
server.port=${PORT:8080}
server.forward-headers-strategy=framework
server.ssl.enabled=false
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ subprojects {
plugins.withId("org.jetbrains.kotlin.jvm") {
kotlin {
jvmToolchain(javaVersion)
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
}
}
}

Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
46 changes: 32 additions & 14 deletions http-client/src/main/kotlin/org/spendoo/client/ApiClient.kt
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
package org.spendoo.client

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.spendoo.identity.security.JwtUtil
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

@Component
class ApiClient(
private val jwtUtil: JwtUtil,
@Value("\${internal.api.base-url:http://localhost:8080}") baseUrl: String,
@Value("\${ai.service.base-url:https://localhost:8000}") private val aiBaseUrl: String
@param:Value("\${ai.service.base-url:https://localhost:8000}") private val aiBaseUrl: String,
@param:Value("\${spendoo.hmac.secret-key:}") private val hmacSecretKey: String
) {
private val restClient: RestClient = RestClient.builder()
.baseUrl(baseUrl)
.messageConverters { converters ->
val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule())
val jacksonConverter = MappingJackson2HttpMessageConverter(objectMapper)
val index = converters.indexOfFirst { it is MappingJackson2HttpMessageConverter }
if (index != -1) {
converters[index] = jacksonConverter
} else {
converters.add(0, jacksonConverter)
}
.configureMessageConverters { builder ->
builder.registerDefaults()
builder.withJsonConverter(JacksonJsonHttpMessageConverter())
}
.build()

Expand Down Expand Up @@ -59,7 +55,29 @@ class ApiClient(
}
}

if (request.body != null) {
if (request.callAIService && hmacSecretKey.isNotEmpty()) {
val timestamp = java.time.Instant.now().epochSecond.toString()
val bodyBytes = if (request.body != null) {
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule())
mapper.writeValueAsBytes(request.body)
} else {
ByteArray(0)
}
val keySpec = SecretKeySpec(hmacSecretKey.toByteArray(Charsets.UTF_8), "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
mac.init(keySpec)
mac.update(timestamp.toByteArray(Charsets.UTF_8))
val rawHmac = mac.doFinal(bodyBytes)
val signature = rawHmac.joinToString("") { "%02x".format(it) }

requestSpec.header("X-Signature", signature)
requestSpec.header("X-Timestamp", timestamp)
if (request.body != null) {
requestSpec.contentType(MediaType.APPLICATION_JSON)
requestSpec.body(bodyBytes)
}
} else if (request.body != null) {
requestSpec.body(request.body!!)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ data class BudgetStatusBucketDto(
val spending: BigDecimal,
val status: BudgetStatus,
val percentage: BigDecimal,
@field:JsonAlias("start_date")
@JsonAlias("start_date")
val startDate: Instant
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ import java.math.BigDecimal

data class BudgetStatusResponse(
val buckets: List<BudgetStatusBucketDto>,
@field:JsonAlias("highest_spending")
@JsonAlias("highest_spending")
val highestSpending: BigDecimal
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import java.math.BigDecimal
import java.util.UUID

data class CategorySpendingDto(
@field:JsonAlias("category_id")
@JsonAlias("category_id")
val categoryId: UUID,
@field:JsonAlias("category_name")
@JsonAlias("category_name")
val categoryName: String,
@field:JsonAlias("category_icon")
@JsonAlias("category_icon")
val categoryIcon: String,
val spending: BigDecimal,
@field:JsonAlias("percentage_change")
@JsonAlias("percentage_change")
val percentageChange: BigDecimal,
@field:JsonAlias("contribution_percentage")
@JsonAlias("contribution_percentage")
val contributionPercentage: BigDecimal
)
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package org.spendoo.statistics.api.dto.response

import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty

data class CombinedStatsResponse(
@field:JsonAlias("financial_stats_forecast")
@JsonProperty("financialStats")
@JsonAlias("financial_stats_forecast", "financial_stats")
val financialStats: FinancialStatsResponse,
@field:JsonAlias("budget_status")
@JsonProperty("budgetStatus")
@JsonAlias("budget_status")
val budgetStatus: BudgetStatusResponse,
@field:JsonAlias("top_categories")
@JsonProperty("topCategories")
@JsonAlias("top_categories")
val topCategories: TopCategoriesResponse
)


Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import java.math.BigDecimal

data class FinancialStatsResponse(
val buckets: List<StatsBucketDto>,
@field:JsonAlias("highest_spending_bucket_index")
@JsonAlias("highest_spending_bucket_index")
val highestSpendingBucketIndex: Int,
@field:JsonAlias("highest_value")
@JsonAlias("highest_value")
val highestValue: BigDecimal,
@field:JsonAlias("predict")
@JsonAlias("predict")
val predicted: Boolean? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ data class StatsBucketDto(
val spending: BigDecimal,
val income: BigDecimal,
val budget: BigDecimal,
@field:JsonAlias("start_date")
@JsonAlias("start_date")
val startDate: Instant,
@field:JsonAlias("predicted")
@JsonAlias("predicted")
val predicted: Boolean? = null,
@field:JsonAlias("status")
@JsonAlias("status")
val status: BudgetStatus? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import com.fasterxml.jackson.annotation.JsonAlias
import java.math.BigDecimal

data class TopCategoriesResponse(
@field:JsonAlias("total_spending")
@JsonAlias("total_spending")
val totalSpending: BigDecimal,
@field:JsonAlias("top_categories")
@JsonAlias("top_categories")
val topCategories: List<CategorySpendingDto>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package org.spendoo.statistics

import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNotNull
import org.spendoo.statistics.api.dto.response.CombinedStatsResponse
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
import org.springframework.http.MediaType
import org.springframework.mock.http.MockHttpInputMessage

class CombinedStatsDeserializationTest {

@Test
fun `test deserialization of AI combined response`() {
val json = """
{
"financial_stats_forecast": {
"buckets": [
{
"spending": 150.50,
"income": 0.0,
"budget": 200.0,
"start_date": "2026-07-05T21:00:00Z",
"predicted": false,
"status": "WITHIN"
}
],
"highest_spending_bucket_index": 0,
"highest_value": 150.50,
"predict": true
},
"budget_status": {
"buckets": [
{
"spending": 150.50,
"status": "WITHIN",
"percentage": 75.25,
"start_date": "2026-07-05T21:00:00Z"
}
],
"highest_spending": 150.50
},
"top_categories": {
"total_spending": 150.50,
"top_categories": [
{
"category_id": "01851ec9-7a36-458f-8788-80799bf95216",
"category_name": "Food",
"category_icon": "food_icon",
"spending": 150.50,
"percentage_change": 5.0,
"contribution_percentage": 100.0
}
]
}
}
""".trimIndent()


val converter = JacksonJsonHttpMessageConverter()
val inputMessage = MockHttpInputMessage(json.toByteArray(Charsets.UTF_8))
inputMessage.headers.contentType = MediaType.APPLICATION_JSON

val result = converter.read(CombinedStatsResponse::class.java, inputMessage) as CombinedStatsResponse

assertNotNull(result)
assertEquals(0, result.financialStats.highestSpendingBucketIndex)
assertEquals(true, result.financialStats.predicted)
assertEquals(1, result.financialStats.buckets.size)
assertEquals(1, result.budgetStatus.buckets.size)
assertEquals(1, result.topCategories.topCategories.size)
}
}



Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.spendoo.transactions.repository.ScheduledPaymentRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.Instant
Expand Down Expand Up @@ -68,7 +69,7 @@ class ScheduledPaymentService (
paymentRepository.delete(payment)
}

@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun payScheduledItem(userId: UUID, paymentId: UUID){
val currentPayment = getPaymentEntity(paymentId, userId)

Expand Down
Loading