diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/main/kotlin/space/luminic/finance/dtos/SpaceDTO.kt b/src/main/kotlin/space/luminic/finance/dtos/SpaceDTO.kt index e71cea9..82e1ff9 100644 --- a/src/main/kotlin/space/luminic/finance/dtos/SpaceDTO.kt +++ b/src/main/kotlin/space/luminic/finance/dtos/SpaceDTO.kt @@ -8,6 +8,7 @@ data class SpaceDTO( val name: String, val owner: UserDTO, val participants: Set = emptySet(), + val isGoogleDriveConnected: Boolean = false, val createdBy: UserDTO? = null, val createdAt: Instant, var updatedBy: UserDTO? = null, diff --git a/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt b/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt index cd0a014..10f722d 100644 --- a/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt +++ b/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt @@ -9,7 +9,8 @@ data class UserDTO( var tgId: Long? = null, var tgUserName: String? = null, var photoUrl: String? = null, - var roles: List + var roles: List, + var isGoogleDriveConnected: Boolean = false ) { diff --git a/src/main/kotlin/space/luminic/finance/mappers/SpaceMapper.kt b/src/main/kotlin/space/luminic/finance/mappers/SpaceMapper.kt index fb701e5..ffd3f6e 100644 --- a/src/main/kotlin/space/luminic/finance/mappers/SpaceMapper.kt +++ b/src/main/kotlin/space/luminic/finance/mappers/SpaceMapper.kt @@ -10,6 +10,7 @@ object SpaceMapper { name = this.name, owner = this.owner.toDto(), participants = this.participants.map { it.toDto() }.toSet(), + isGoogleDriveConnected = this.owner.googleRefreshToken != null, createdBy = this.createdBy?.toDto(), createdAt = this.createdAt ?: throw IllegalArgumentException("createdAt is not provided"), updatedBy = this.updatedBy?.toDto(), diff --git a/src/main/kotlin/space/luminic/finance/mappers/UserMapper.kt b/src/main/kotlin/space/luminic/finance/mappers/UserMapper.kt index 4856ba8..7aff0eb 100644 --- a/src/main/kotlin/space/luminic/finance/mappers/UserMapper.kt +++ b/src/main/kotlin/space/luminic/finance/mappers/UserMapper.kt @@ -13,7 +13,8 @@ object UserMapper { tgId = this.tgId, tgUserName = this.tgUserName, photoUrl = this.photoUrl, - roles = this.roles + roles = this.roles, + isGoogleDriveConnected = this.googleRefreshToken != null ) fun TelegramAuthDTO.toTelegramMap(): Map = diff --git a/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt index 83c9b18..4f5ac33 100644 --- a/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt @@ -42,6 +42,7 @@ class SpaceRepoImpl( rs.getString("s_owner_username"), rs.getString("s_owner_firstname"), tgId = rs.getLong("s_owner_tg_id"), + googleRefreshToken = rs.getString("s_owner_google_refresh_token") ), participant = User(rs.getInt("sp_uid"), rs.getString("sp_username"), rs.getString("sp_first_name")), createdAt = rs.getTimestamp("s_created_at").toInstant(), @@ -111,6 +112,7 @@ class SpaceRepoImpl( ou.username as s_owner_username, ou.first_name as s_owner_firstname, ou.tg_id as s_owner_tg_id, + ou.google_refresh_token as s_owner_google_refresh_token, sp.participants_id as sp_uid, u.username as sp_username, u.first_name as sp_first_name, @@ -131,7 +133,7 @@ class SpaceRepoImpl( left join finance.users uau on s.updated_by_id = uau.id left join finance.transactions t on t.space_id = s.id where s.is_deleted = false and t.created_at >= :lastRun - group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, + group by s.id, ou.username, ou.first_name, ou.tg_id, ou.google_refresh_token, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, uau.username, uau.first_name; """.trimMargin() val params = mapOf("lastRun" to lastRun) @@ -150,6 +152,7 @@ class SpaceRepoImpl( ou.username as s_owner_username, ou.first_name as s_owner_firstname, ou.tg_id as s_owner_tg_id, + ou.google_refresh_token as s_owner_google_refresh_token, sp.participants_id as sp_uid, u.username as sp_username, u.first_name as sp_first_name, @@ -171,7 +174,7 @@ class SpaceRepoImpl( where (s.owner_id = :user_id or sp.participants_id = :user_id) and s.is_deleted = false - group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, + group by s.id, ou.username, ou.first_name, ou.tg_id, ou.google_refresh_token, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, uau.username, uau.first_name; """.trimMargin() val params = mapOf( @@ -191,6 +194,7 @@ class SpaceRepoImpl( ou.username as s_owner_username, ou.first_name as s_owner_firstname, ou.tg_id as s_owner_tg_id, + ou.google_refresh_token as s_owner_google_refresh_token, sp.participants_id as sp_uid, u.username as sp_username, u.first_name as sp_first_name, @@ -212,7 +216,7 @@ from finance.spaces s where (s.owner_id = :user_id or sp.participants_id = :user_id) and s.is_deleted = false and s.id = :spaceId -group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, +group by s.id, ou.username, ou.first_name, ou.tg_id, ou.google_refresh_token, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, uau.username, uau.first_name; """.trimMargin() val params = mapOf( diff --git a/src/main/kotlin/space/luminic/finance/services/GoogleDriveService.kt b/src/main/kotlin/space/luminic/finance/services/GoogleDriveService.kt new file mode 100644 index 0000000..1febfcf --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/GoogleDriveService.kt @@ -0,0 +1,68 @@ +package space.luminic.finance.services + +import com.google.api.client.auth.oauth2.AuthorizationCodeFlow +import com.google.api.client.auth.oauth2.BearerToken +import com.google.api.client.auth.oauth2.Credential +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class GoogleDriveService( + @Value("\${google.client-id:}") private val clientId: String, + @Value("\${google.client-secret:}") private val clientSecret: String, + @Value("\${google.redirect-uri:}") private val redirectUri: String +) { + private val jsonFactory = GsonFactory.getDefaultInstance() + private val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + + fun exchangeCodeForRefreshToken(authCode: String): String { + if (clientId.isEmpty() || clientSecret.isEmpty()) return "" + + val flow = GoogleAuthorizationCodeFlow.Builder( + httpTransport, + jsonFactory, + clientId, + clientSecret, + listOf(DriveScopes.DRIVE_FILE) + ) + .setAccessType("offline") + .build() + + val response = flow.newTokenRequest(authCode).setRedirectUri(redirectUri).execute() + return response.refreshToken ?: "" + } + + fun uploadExcelFile(refreshToken: String, fileName: String, content: ByteArray) { + if (refreshToken.isEmpty() || clientId.isEmpty() || clientSecret.isEmpty()) return + + val credential = GoogleCredential.Builder() + .setTransport(httpTransport) + .setJsonFactory(jsonFactory) + .setClientSecrets(clientId, clientSecret) + .build() + .setRefreshToken(refreshToken) + + val driveService = Drive.Builder(httpTransport, jsonFactory, credential) + .setApplicationName("Luminic Space") + .build() + + val fileMetadata = File() + fileMetadata.name = fileName + fileMetadata.mimeType = "application/vnd.google-apps.spreadsheet" + + val mediaContent = ByteArrayContent("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content) + + driveService.files().create(fileMetadata, mediaContent) + .setFields("id") + .execute() + } +} diff --git a/src/main/kotlin/space/luminic/finance/services/MonthlyExportScheduler.kt b/src/main/kotlin/space/luminic/finance/services/MonthlyExportScheduler.kt new file mode 100644 index 0000000..b4500ea --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/MonthlyExportScheduler.kt @@ -0,0 +1,50 @@ +package space.luminic.finance.services + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import space.luminic.finance.repos.SpaceRepo +import java.time.LocalDate + +@Service +class MonthlyExportScheduler( + private val spaceRepo: SpaceRepo, + private val transactionService: TransactionService, + private val googleDriveService: GoogleDriveService +) { + private val logger = LoggerFactory.getLogger(javaClass) + + // Каждое 9 число в 23:59 + @Scheduled(cron = "0 59 23 9 * ?") + fun exportMonthlyTransactions() { + logger.info("Starting monthly transaction export to Google Drive") + + val spaces = spaceRepo.findAll() + val endDate = LocalDate.now() // 9th of current month + val startDate = endDate.minusMonths(1).plusDays(1) // 10th of previous month + + val filter = TransactionService.TransactionsFilter( + dateFrom = startDate, + dateTo = endDate + ) + + for (space in spaces) { + val owner = space.owner + val refreshToken = owner.googleRefreshToken + + if (refreshToken != null && refreshToken.isNotEmpty()) { + logger.info("Exporting for space \${space.id} (\${space.name}), owner \${owner.username}") + try { + val excelBytes = transactionService.generateExcel(space.id!!, filter) + val fileName = "Выгрузка_\${space.name}_\${startDate}_\${endDate}.xlsx" + googleDriveService.uploadExcelFile(refreshToken, fileName, excelBytes) + logger.info("Successfully exported to Google Drive for space \${space.id}") + } catch (e: Exception) { + logger.error("Failed to export space \${space.id} to Google Drive", e) + } + } else { + logger.debug("Skipping space \${space.id} because owner hasn't linked Google Drive") + } + } + } +} diff --git a/src/main/resources/db/migration/V36__.sql b/src/main/resources/db/migration/V36__.sql new file mode 100644 index 0000000..95aa4f3 --- /dev/null +++ b/src/main/resources/db/migration/V36__.sql @@ -0,0 +1 @@ +ALTER TABLE finance.users ADD COLUMN google_refresh_token VARCHAR(255); \ No newline at end of file