diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 2982f22ad23..6f0aa5e7235 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -18,3 +18,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md). - Example command for the migration: `PG_PASSWORD=myPassword python main.py --src localhost:7500 --dst localhost:7155 --num_threads 20 --postgres webknossos@localhost:5430/webknossos` ### Postgres Evolutions: +- [126-credit-transactions.sql](conf/evolutions/126-credit-transactions.sql) diff --git a/app/controllers/CreditTransactionController.scala b/app/controllers/CreditTransactionController.scala new file mode 100755 index 00000000000..53aff2859fe --- /dev/null +++ b/app/controllers/CreditTransactionController.scala @@ -0,0 +1,77 @@ +package controllers + +import com.scalableminds.util.objectid.ObjectId +import com.scalableminds.util.time.Instant +import com.scalableminds.util.tools.{Fox, FoxImplicits} +import models.organization.{ + CreditTransaction, + CreditTransactionDAO, + CreditTransactionService, + CreditTransactionState, + OrganizationService +} +import models.user.UserService +import net.liftweb.common.Box.tryo +import play.api.mvc.{Action, AnyContent} +import play.silhouette.api.Silhouette +import security.WkEnv + +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class CreditTransactionController @Inject()(organizationService: OrganizationService, + creditTransactionService: CreditTransactionService, + creditTransactionDAO: CreditTransactionDAO, + userService: UserService, + sil: Silhouette[WkEnv])(implicit ec: ExecutionContext) + extends Controller + with FoxImplicits { + + def chargeUpCredits(organizationId: String, + creditAmount: Int, + moneySpent: String, + comment: Option[String], + expiresAt: Option[String]): Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + _ <- userService.assertIsSuperUser(request.identity) ?~> "Only super users can charge up credits" + moneySpentInDecimal <- tryo(BigDecimal(moneySpent)) ?~> s"moneySpent $moneySpent is not a valid decimal" + _ <- bool2Fox(moneySpentInDecimal > 0 || expiresAt.nonEmpty) ?~> "moneySpent must be a positive number" + _ <- bool2Fox(creditAmount > 0) ?~> "creditAmount must be a positive number" + commentNoOptional = comment.getOrElse(s"Charge up for $creditAmount credits for $moneySpent Euro.") + _ <- organizationService.ensureOrganizationHasPaidPlan(organizationId) + expirationDateOpt <- Fox.runOptional(expiresAt)(Instant.fromString) + chargeUpTransaction = CreditTransaction( + ObjectId.generate, + organizationId, + BigDecimal(creditAmount), + BigDecimal(0), // Charge up transactions are not refundable per default and do not need a marker on how much refundable credits are left. + None, + Some(moneySpentInDecimal), + commentNoOptional, + None, + CreditTransactionState.Completed, + expirationDateOpt + ) + _ <- creditTransactionService.doCreditTransaction(chargeUpTransaction) + } yield Ok + } + + def refundCreditTransaction(organizationId: String, transactionId: String): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + _ <- userService.assertIsSuperUser(request.identity) ?~> "Only super users can manually refund credits" + transaction <- creditTransactionDAO.findOne(transactionId) + _ <- bool2Fox(transaction._organization == organizationId) ?~> "Transaction is not for this organization" + _ <- organizationService.ensureOrganizationHasPaidPlan(organizationId) + _ <- creditTransactionDAO.refundTransaction(transaction._id) + } yield Ok + } + + def revokeExpiredCredits(): Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + _ <- userService.assertIsSuperUser(request.identity) ?~> "Only super users can manually revoke expired credits" + _ <- creditTransactionService.revokeExpiredCredits() + } yield Ok + } + +} diff --git a/app/controllers/JobController.scala b/app/controllers/JobController.scala index 20f1462076e..40e9add5ecf 100644 --- a/app/controllers/JobController.scala +++ b/app/controllers/JobController.scala @@ -2,12 +2,12 @@ package controllers import play.silhouette.api.Silhouette import com.scalableminds.util.geometry.{BoundingBox, Vec3Double, Vec3Int} -import com.scalableminds.util.accesscontext.GlobalAccessContext +import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext} import com.scalableminds.util.tools.Fox import models.dataset.{DataStoreDAO, DatasetDAO, DatasetLayerAdditionalAxesDAO, DatasetService} -import models.job._ -import models.organization.OrganizationDAO -import models.user.MultiUserDAO +import models.job.{JobCommand, _} +import models.organization.{CreditTransactionService, OrganizationDAO, OrganizationService} +import models.user.{MultiUserDAO, User} import play.api.i18n.Messages import play.api.libs.json._ import play.api.mvc.{Action, AnyContent, PlayBodyParsers} @@ -24,7 +24,9 @@ import com.scalableminds.webknossos.datastore.models.{LengthUnit, VoxelSize} import com.scalableminds.webknossos.datastore.dataformats.zarr.Zarr3OutputHelper import com.scalableminds.webknossos.datastore.datareaders.{AxisOrder, FullAxisOrder, NDBoundingBox} import com.scalableminds.webknossos.datastore.models.AdditionalCoordinate +import models.job.JobCommand.JobCommand import models.team.PricingPlan +import net.liftweb.common.Full object MovieResolutionSetting extends ExtendedEnumeration { val SD, HD = Value @@ -64,6 +66,8 @@ class JobController @Inject()( wkSilhouetteEnvironment: WkSilhouetteEnvironment, slackNotificationService: SlackNotificationService, organizationDAO: OrganizationDAO, + organizationService: OrganizationService, + creditTransactionService: CreditTransactionService, dataStoreDAO: DataStoreDAO)(implicit ec: ExecutionContext, playBodyParsers: PlayBodyParsers) extends Controller with Zarr3OutputHelper { @@ -85,6 +89,7 @@ class JobController @Inject()( for { _ <- bool2Fox(wkconf.Features.jobsEnabled) ?~> "job.disabled" jobs <- jobDAO.findAll + // TODO: Consider adding paid credits to job public writes. jobsJsonList <- Fox.serialCombined(jobs.sortBy(_.created).reverse)(jobService.publicWrites) } yield Ok(Json.toJson(jobsJsonList)) } @@ -243,9 +248,11 @@ class JobController @Inject()( _ <- datasetService.assertValidDatasetName(newDatasetName) _ <- datasetService.assertValidLayerNameLax(layerName) multiUser <- multiUserDAO.findOne(request.identity._multiUser) - _ <- Fox.runIf(!multiUser.isSuperUser)(jobService.assertBoundingBoxLimits(bbox, None)) annotationIdParsed <- Fox.runIf(doSplitMergerEvaluation)(annotationId.toFox) ?~> "job.inferNeurons.annotationIdEvalParamsMissing" command = JobCommand.infer_neurons + parsedBoundingBox <- jobService.parseBoundingBoxWithMagOpt(bbox, None) + // TODO: Disable this check. Credits should be enough to guard this. + _ <- Fox.runIf(!multiUser.isSuperUser)(jobService.assertBoundingBoxLimits(bbox, None)) commandArgs = Json.obj( "organization_id" -> organization._id, "dataset_name" -> dataset.name, @@ -260,9 +267,14 @@ class JobController @Inject()( "eval_sparse_tube_threshold_nm" -> evalSparseTubeThresholdNm, "eval_min_merger_path_length_nm" -> evalMinMergerPathLengthNm, ) - job <- jobService.submitJob(command, commandArgs, request.identity, dataset._dataStore) ?~> "job.couldNotRunNeuronInferral" - js <- jobService.publicWrites(job) - } yield Ok(js) + creditTransactionComment = s"Run for AI neuron segmentation for dataset ${dataset.name}" + jobAsJs <- runPaidJob(command, + commandArgs, + parsedBoundingBox, + creditTransactionComment, + request.identity, + dataset._dataStore) + } yield Ok(jobAsJs) } } @@ -282,9 +294,11 @@ class JobController @Inject()( _ <- datasetService.assertValidDatasetName(newDatasetName) _ <- datasetService.assertValidLayerNameLax(layerName) multiUser <- multiUserDAO.findOne(request.identity._multiUser) + command = JobCommand.infer_mitochondria + parsedBoundingBox <- jobService.parseBoundingBoxWithMagOpt(bbox, None) + // TODO: Disable this check. Credits should be enough to guard this. _ <- bool2Fox(multiUser.isSuperUser) ?~> "job.inferMitochondria.notAllowed.onlySuperUsers" _ <- Fox.runIf(!multiUser.isSuperUser)(jobService.assertBoundingBoxLimits(bbox, None)) - command = JobCommand.infer_mitochondria commandArgs = Json.obj( "organization_id" -> dataset._organization, "dataset_name" -> dataset.name, @@ -293,9 +307,14 @@ class JobController @Inject()( "layer_name" -> layerName, "bbox" -> bbox, ) - job <- jobService.submitJob(command, commandArgs, request.identity, dataset._dataStore) ?~> "job.couldNotRunInferMitochondria" - js <- jobService.publicWrites(job) - } yield Ok(js) + creditTransactionComment = s"Run for AI mitochondria segmentation for dataset ${dataset.name}" + jobAsJs <- runPaidJob(command, + commandArgs, + parsedBoundingBox, + creditTransactionComment, + request.identity, + dataset._dataStore) + } yield Ok(jobAsJs) } } @@ -314,6 +333,10 @@ class JobController @Inject()( _ <- bool2Fox(request.identity._organization == organization._id) ?~> "job.alignSections.notAllowed.organization" ~> FORBIDDEN _ <- datasetService.assertValidDatasetName(newDatasetName) _ <- datasetService.assertValidLayerNameLax(layerName) + datasetBoundingBox <- datasetService + .dataSourceFor(dataset) + .flatMap(_.toUsable) + .map(_.boundingBox) ?~> "dataset.boundingBox.unset" _ <- Fox.runOptional(annotationId)(ObjectId.fromString) command = JobCommand.align_sections commandArgs = Json.obj( @@ -324,9 +347,14 @@ class JobController @Inject()( "layer_name" -> layerName, "annotation_id" -> annotationId ) - job <- jobService.submitJob(command, commandArgs, request.identity, dataset._dataStore) ?~> "job.couldNotRunAlignSections" - js <- jobService.publicWrites(job) - } yield Ok(js) + creditTransactionComment = s"Run AI neuron segmentation for dataset ${dataset.name}" + jobAsJs <- runPaidJob(command, + commandArgs, + datasetBoundingBox, + creditTransactionComment, + request.identity, + dataset._dataStore) + } yield Ok(jobAsJs) } } @@ -468,10 +496,10 @@ class JobController @Inject()( _ <- bool2Fox(request.identity._organization == organization._id) ?~> "job.renderAnimation.notAllowed.organization" ~> FORBIDDEN userOrganization <- organizationDAO.findOne(request.identity._organization) animationJobOptions = request.body - _ <- Fox.runIf(userOrganization.pricingPlan == PricingPlan.Basic) { + _ <- Fox.runIf(PricingPlan.isPaidPlan(userOrganization.pricingPlan)) { bool2Fox(animationJobOptions.includeWatermark) ?~> "job.renderAnimation.mustIncludeWatermark" } - _ <- Fox.runIf(userOrganization.pricingPlan == PricingPlan.Basic) { + _ <- Fox.runIf(PricingPlan.isPaidPlan(userOrganization.pricingPlan)) { bool2Fox(animationJobOptions.movieResolution == MovieResolutionSetting.SD) ?~> "job.renderAnimation.resolutionMustBeSD" } layerName = animationJobOptions.layerName @@ -511,4 +539,46 @@ class JobController @Inject()( } yield Redirect(uri, Map(("token", Seq(userAuthToken.id)))) } + def getJobCosts(command: String, boundingBoxInMag: String): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + boundingBox <- BoundingBox.fromLiteral(boundingBoxInMag).toFox + jobCommand <- JobCommand.fromString(command).toFox + jobCosts = jobService.calculateJobCosts(boundingBox, jobCommand) + js = Json.obj( + "costsInCredits" -> jobCosts.toString(), + ) + } yield Ok(js) + } + + private def runPaidJob(command: JobCommand, + commandArgs: JsObject, + jobBoundingBox: BoundingBox, + creditTransactionComment: String, + user: User, + datastoreName: String)(implicit ctx: DBAccessContext): Fox[JsObject] = { + val costsInCredits = jobService.calculateJobCosts(jobBoundingBox, command) + for { + _ <- organizationService.ensureOrganizationHasPaidPlan(user._organization) ?~> "job.paidJob.notAllowed.noPaidPlan" + _ <- Fox.assertTrue(creditTransactionService.hasEnoughCredits(user._organization, costsInCredits)) ?~> "job.notEnoughCredits" + creditTransaction <- creditTransactionService.reserveCredits(user._organization, + costsInCredits, + creditTransactionComment, + None) + job <- jobService + .submitJob(command, commandArgs, user, datastoreName) + .futureBox + .flatMap { + case Full(job) => Fox.successful(job) + case _ => + creditTransactionService.refundTransactionWhenStartingJobFailed(creditTransaction) + Fox.failure("job.couldNotRunAlignSections") + + } + .toFox + _ <- creditTransactionService.addJobIdToTransaction(creditTransaction, job._id) + js <- jobService.publicWrites(job) + } yield js + } + } diff --git a/app/controllers/OrganizationController.scala b/app/controllers/OrganizationController.scala index ac987757fd3..203a85d10ed 100755 --- a/app/controllers/OrganizationController.scala +++ b/app/controllers/OrganizationController.scala @@ -88,7 +88,7 @@ class OrganizationController @Inject()( for { allOrgs <- organizationDAO.findAll(GlobalAccessContext) ?~> "organization.list.failed" org <- allOrgs.headOption.toFox ?~> "organization.list.failed" - js <- organizationService.publicWrites(org) + js <- organizationService.publicWrites(org)(GlobalAccessContext) } yield { if (allOrgs.length > 1) // Cannot list organizations publicly if there are multiple ones, due to privacy reasons Ok(JsNull) @@ -252,6 +252,24 @@ class OrganizationController @Inject()( } yield Ok } + def sendOrderCreditsEmail(requestedCredits: Int): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + _ <- bool2Fox(requestedCredits > 0) ?~> Messages("organization.creditOrder.notPositive") + _ <- bool2Fox(request.identity.isOrganizationOwner) ?~> Messages("organization.creditOrder.notAuthorized") + organization <- organizationDAO.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND + userEmail <- userService.emailFor(request.identity) + _ = logger.info( + s"Received credit order for organization ${organization.name} with $requestedCredits credits by user $userEmail") + _ = Mailer ! Send(defaultMails.orderCreditsMail(request.identity, userEmail, requestedCredits)) + _ = Mailer ! Send( + defaultMails.orderCreditsRequestMail(request.identity, + userEmail, + organization.name, + s"Purchase $requestedCredits WEBKNOSSOS credits.")) + } yield Ok + } + def pricingStatus: Action[AnyContent] = sil.SecuredAction.async { implicit request => for { diff --git a/app/controllers/WKRemoteWorkerController.scala b/app/controllers/WKRemoteWorkerController.scala index b9edd6bce67..c96cc3467ba 100644 --- a/app/controllers/WKRemoteWorkerController.scala +++ b/app/controllers/WKRemoteWorkerController.scala @@ -9,6 +9,7 @@ import models.job.JobCommand.JobCommand import javax.inject.Inject import models.job._ +import models.organization.CreditTransactionService import models.voxelytics.VoxelyticsDAO import net.liftweb.common.{Empty, Failure, Full} import play.api.libs.json.Json @@ -20,6 +21,7 @@ import scala.concurrent.ExecutionContext class WKRemoteWorkerController @Inject()(jobDAO: JobDAO, jobService: JobService, workerDAO: WorkerDAO, + creditTransactionService: CreditTransactionService, voxelyticsDAO: VoxelyticsDAO, aiInferenceDAO: AiInferenceDAO, datasetDAO: DatasetDAO, @@ -82,6 +84,12 @@ class WKRemoteWorkerController @Inject()(jobDAO: JobDAO, jobAfterChange <- jobDAO.findOne(jobIdParsed)(GlobalAccessContext) _ = jobService.trackStatusChange(jobBeforeChange, jobAfterChange) _ <- jobService.cleanUpIfFailed(jobAfterChange) + _ <- Fox.runIf(request.body.state == JobState.SUCCESS) { + creditTransactionService.completeTransactionOfJob(jobAfterChange._id)(GlobalAccessContext) + } + _ <- Fox.runIf(request.body.state == JobState.FAILURE || request.body.state == JobState.CANCELLED) { + creditTransactionService.refundTransactionForJob(jobAfterChange._id)(GlobalAccessContext) + } } yield Ok } diff --git a/app/mail/DefaultMails.scala b/app/mail/DefaultMails.scala index 59276c8936f..faf67465153 100755 --- a/app/mail/DefaultMails.scala +++ b/app/mail/DefaultMails.scala @@ -153,6 +153,23 @@ class DefaultMails @Inject()(conf: WkConf) { recipients = List("hello@webknossos.org") ) + def orderCreditsMail(user: User, userEmail: String, requestedCredits: Int): Mail = + Mail( + from = defaultSender, + subject = "Request to order WEBKNOSSOS credits", + bodyHtml = html.mail.orderCredits(user.name, requestedCredits, additionalFooter).body, + recipients = List(userEmail) + ) + + def orderCreditsRequestMail(user: User, userEmail: String, organizationName: String, messageBody: String): Mail = + Mail( + from = defaultSender, + subject = "Request to buy WEBKNOSSOS credits", + bodyHtml = + html.mail.orderCreditsRequest(user.name, userEmail, organizationName, messageBody, additionalFooter).body, + recipients = List("hello@webknossos.org") + ) + def jobSuccessfulGenericMail(user: User, userEmail: String, datasetName: String, diff --git a/app/models/job/JobService.scala b/app/models/job/JobService.scala index 5384ac256f1..b2043af8fc7 100644 --- a/app/models/job/JobService.scala +++ b/app/models/job/JobService.scala @@ -219,11 +219,31 @@ class JobService @Inject()(wkConf: WkConf, def assertBoundingBoxLimits(boundingBox: String, mag: Option[String]): Fox[Unit] = for { - parsedBoundingBox <- BoundingBox.fromLiteral(boundingBox).toFox ?~> "job.invalidBoundingBox" - parsedMag <- Vec3Int.fromMagLiteral(mag.getOrElse("1-1-1"), allowScalar = true) ?~> "job.invalidMag" - boundingBoxInMag = parsedBoundingBox / parsedMag + boundingBoxInMag <- parseBoundingBoxWithMagOpt(boundingBox, mag) _ <- bool2Fox(boundingBoxInMag.volume <= wkConf.Features.exportTiffMaxVolumeMVx * 1024 * 1024) ?~> "job.volumeExceeded" _ <- bool2Fox(boundingBoxInMag.size.maxDim <= wkConf.Features.exportTiffMaxEdgeLengthVx) ?~> "job.edgeLengthExceeded" } yield () + private def getJobCostsPerGVx(jobCommand: JobCommand): BigDecimal = + jobCommand match { + case JobCommand.infer_neurons => wkConf.Features.neuronInferralCostsPerGVx + case JobCommand.infer_mitochondria => wkConf.Features.mitochondriaInferralCostsPerGVx + case JobCommand.align_sections => wkConf.Features.alignmentCostsPerGVx + case _ => throw new IllegalArgumentException(s"Unsupported job command $jobCommand") + } + + def calculateJobCosts(boundingBoxInTargetMag: BoundingBox, jobCommand: JobCommand): BigDecimal = { + val costsPerGVx = getJobCostsPerGVx(jobCommand) + val volumeInGVx = boundingBoxInTargetMag.volume / math.pow(10, 9) + val costs = BigDecimal(volumeInGVx) * costsPerGVx + // TODO: Make the decimal round up after 4 places behind comma. + if (costs < BigDecimal(0.001)) BigDecimal(0.001) else costs + } + + def parseBoundingBoxWithMagOpt(boundingBox: String, mag: Option[String]): Fox[BoundingBox] = + for { + parsedBoundingBox <- BoundingBox.fromLiteral(boundingBox).toFox ?~> "job.invalidBoundingBox" + parsedMag <- Vec3Int.fromMagLiteral(mag.getOrElse("1-1-1"), allowScalar = true) ?~> "job.invalidMag" + } yield parsedBoundingBox / parsedMag + } diff --git a/app/models/organization/CreditTransaction.scala b/app/models/organization/CreditTransaction.scala new file mode 100644 index 00000000000..4c07e2fbbb8 --- /dev/null +++ b/app/models/organization/CreditTransaction.scala @@ -0,0 +1,366 @@ +package models.organization + +import com.scalableminds.util.accesscontext.DBAccessContext +import com.scalableminds.util.objectid.ObjectId +import com.scalableminds.util.time.Instant +import com.scalableminds.util.tools.Fox +import com.scalableminds.webknossos.schema.Tables.OrganizationCreditTransactionsRow +import com.scalableminds.webknossos.schema.Tables.OrganizationCreditTransactions +import models.organization.CreditTransactionState.TransactionState +import slick.dbio.DBIO +import slick.jdbc.GetResult +import slick.jdbc.PostgresProfile.api._ +import slick.lifted.Rep +import telemetry.SlackNotificationService +import utils.WkConf +import utils.sql.{SQLDAO, SqlClient, SqlToken} + +import javax.inject.Inject +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +case class CreditTransaction(_id: ObjectId = ObjectId.generate, + _organization: String, + creditChange: BigDecimal, + refundableCreditChange: BigDecimal, + refundedTransactionId: Option[ObjectId] = None, + spentMoney: Option[BigDecimal], + comment: String, + _paidJob: Option[ObjectId], + state: TransactionState, + expirationDate: Option[Instant], + createdAt: Instant = Instant.now, + updatedAt: Instant = Instant.now, + isDeleted: Boolean = false) + +class CreditTransactionDAO @Inject()(organizationDAO: OrganizationDAO, + conf: WkConf, + slackNotificationService: SlackNotificationService, + sqlClient: SqlClient)(implicit ec: ExecutionContext) + extends SQLDAO[CreditTransaction, OrganizationCreditTransactionsRow, OrganizationCreditTransactions](sqlClient) { + + protected val collection = OrganizationCreditTransactions + + protected def idColumn(x: OrganizationCreditTransactions): Rep[String] = x._Id + + override protected def isDeletedColumn(x: OrganizationCreditTransactions): Rep[Boolean] = x.isDeleted + + override protected def parse(row: OrganizationCreditTransactionsRow): Fox[CreditTransaction] = + for { + state <- CreditTransactionState.fromString(row.state).toFox + id <- ObjectId.fromString(row._Id) + jobIdOpt <- Fox.runOptional(row._PaidJob)(ObjectId.fromStringSync) + refundedTransactionIdOpt <- Fox.runOptional(row.refundedTransactionId)(ObjectId.fromStringSync) + } yield { + CreditTransaction( + id, + row._Organization, + row.creditChange, + row.refundableCreditChange, + refundedTransactionIdOpt, + row.spentMoney, + row.comment, + jobIdOpt, + state, + row.expirationDate.map(Instant.fromDate), + Instant.fromSql(row.createdAt), + Instant.fromSql(row.updatedAt), + row.isDeleted + ) + } + + implicit val getOrganizationCreditTransactions: GetResult[CreditTransaction] = GetResult { prs => + import prs._ + val transactionId = <<[ObjectId] + val organizationId = <<[String] + val creditChange = <<[BigDecimal] + val refundableCreditChange = <<[BigDecimal] + val refundedTransactionId = < s"Refund for failed job $jobId.") + .getOrElse(s"Refund for transaction $transactionId.") + setToRefunded = q"""UPDATE webknossos.organization_credit_transactions + SET state = ${CreditTransactionState.Refunded}, updated_at = NOW() + WHERE _id = $transactionId AND state = ${CreditTransactionState.Pending} + AND credit_change < 0 + """.asUpdate + insertRefundTransaction = q""" + INSERT INTO webknossos.organization_credit_transactions + (_id, _organization, credit_change, refundable_credit_change, refunded_transaction_id, comment, _paid_job, state) + VALUES ( + ${ObjectId.generate}, + ${transactionToRefund._organization}, + ( + SELECT refundable_credit_change * -1 + FROM webknossos.organization_credit_transactions + WHERE _id = $transactionId + AND state = ${CreditTransactionState.Refunded} + AND credit_change < 0 + ), + 0::DECIMAL, + $transactionId, + $refundComment, + ${transactionToRefund._paidJob}, + ${CreditTransactionState.Completed} + ) + """.asUpdate + updatedRows <- run(DBIO.sequence(List(setToRefunded, insertRefundTransaction)).transactionally) + _ <- bool2Fox(updatedRows.forall(_ == 1)) ?~> s"Failed to refund transaction ${transactionToRefund._id} properly." + } yield () + + def findTransactionForJob(jobId: ObjectId)(implicit ctx: DBAccessContext): Fox[CreditTransaction] = + for { + accessQuery <- readAccessQuery + r <- run( + q"SELECT $columns FROM $existingCollectionName WHERE _paid_job = $jobId AND $accessQuery" + .as[OrganizationCreditTransactionsRow]) + parsed <- parseFirst(r, jobId) + } yield parsed + +// TODO: Maybe remove this outer while loop here. + private def findAllOrganizationsWithExpiredCredits: Fox[List[String]] = + for { + r <- run(q"""SELECT DISTINCT _organization + FROM webknossos.organization_credit_transactions + WHERE expiration_date <= NOW() + AND state = ${CreditTransactionState.Completed} + AND credit_change > 0""".as[String]) + } yield r.toList + + private def revokeExpiredCreditsForOrganization(organizationId: String): DBIO[List[CreditTransaction]] = { + // TODO: Make this a transaction to have either all credits revoked of the organization or none + logger.info(s"revokeExpiredCreditsForOrganization for organization $organizationId") + + for { + transactionsWithExpiredCredits <- q"""SELECT * + FROM webknossos.organization_credit_transactions + WHERE _organization = $organizationId + AND expiration_date <= NOW() + AND state = 'Completed' + AND credit_change > 0 + ORDER BY created_at DESC + """.as[CreditTransaction] + transactionsWhereRevokingFailed <- transactionsWithExpiredCredits.foldLeft( + DBIO.successful(List()): DBIO[List[CreditTransaction]]) { (previousTransactionRevocation, transaction) => + previousTransactionRevocation.flatMap { transactionsWhereRevokingFailed => + revokeExpiredCreditsTransaction(transaction).asTry.flatMap { + case Success(_) => DBIO.successful(transactionsWhereRevokingFailed) + case Failure(e) => + logger.error(s"Failed to revoke some expired credits for organization ${transaction._organization}", e) + DBIO.successful(transactionsWhereRevokingFailed :+ transaction) + case _ => DBIO.successful(transactionsWhereRevokingFailed) + } + } + } + } yield transactionsWhereRevokingFailed + } + + private def revokeExpiredCreditsTransaction(transaction: CreditTransaction): DBIO[Unit] = { + logger.info(s"revokeExpiredCreditsTransaction for transaction ${transaction._id}") + for { + // Query: Sums up all spent credits since the transaction which are completed and subtracts refunded transactions. + spentCreditsSinceTransactionNegResult <- q""" + SELECT COALESCE(SUM(credit_change), 0) + FROM webknossos.organization_credit_transactions + WHERE _organization = ${transaction._organization} + AND created_at > ${transaction.createdAt} + AND (credit_change < 0) + OR (credit_change > 0 AND refunded_transaction_id IS NOT NULL) -- Counts also revoked transactions + OR (credit_change > 0 AND expiration_date <= NOW()) -- Counts also expired transactions + """.as[BigDecimal] + + // Extract values from query results + spentCreditsSinceTransactionNegative = spentCreditsSinceTransactionNegResult.headOption.getOrElse(BigDecimal(0)) + spentCreditsSinceTransactionPositive = spentCreditsSinceTransactionNegative * -1 + freeCreditsAvailable = transaction.creditChange - spentCreditsSinceTransactionPositive + + _ <- if (freeCreditsAvailable <= 0) { + // Fully spent, update state to 'Spent' + q""" + UPDATE webknossos.organization_credit_transactions + SET state = ${CreditTransactionState.Spent}, updated_at = NOW() + WHERE _id = ${transaction._id} + """.asUpdate + } else { + val stateOfExpiredTransaction = if (freeCreditsAvailable == transaction.creditChange) { + CreditTransactionState.Revoked + } else { CreditTransactionState.PartiallyRevoked } + val revokingTransaction = CreditTransaction( + ObjectId.generate, + transaction._organization, + -freeCreditsAvailable, + 0, + None, + None, + s"Revoked expired credits for transaction ${transaction._id}", + None, + CreditTransactionState.Completed, + None, + ) + for { + _ <- q""" + UPDATE webknossos.organization_credit_transactions + SET state = $stateOfExpiredTransaction, updated_at = NOW() + WHERE _id = ${transaction._id} + """.asUpdate + _ <- insertRevokingTransaction(revokingTransaction) + } yield () + } + _ = logger.info(s"revokeExpiredCreditsTransaction for transaction ${transaction._id} finished") + } yield () + } + + def runRevokeExpiredCredits(): Fox[Unit] = + for { + orgas <- findAllOrganizationsWithExpiredCredits + failedTransactionsToRevoke <- Fox.foldLeft(orgas.iterator, List(): List[CreditTransaction]) { + case (failedTransactions, organizationId) => + run(revokeExpiredCreditsForOrganization(organizationId).transactionally).map(failedTransactions ++ _) + } + _ = if (failedTransactionsToRevoke.nonEmpty) { + val failedTransactions = failedTransactionsToRevoke.map(transaction => + s"Failed to revoke credits for transaction ${transaction._id} for organization ${transaction._organization}.") + slackNotificationService.warn("Failed to revoke some expired credits for organizations", + s"${failedTransactions.mkString("\n")}") + } + } yield () + + def handOutMonthlyFreeCredits(): Fox[Unit] = + run(q"SELECT webknossos.hand_out_monthly_free_credits(${conf.Jobs.monthlyFreeCredits}::DECIMAL)".as[Unit]).map(_ => + ()) +} diff --git a/app/models/organization/CreditTransactionService.scala b/app/models/organization/CreditTransactionService.scala new file mode 100644 index 00000000000..7c9da1578e3 --- /dev/null +++ b/app/models/organization/CreditTransactionService.scala @@ -0,0 +1,113 @@ +package models.organization + +import com.scalableminds.util.accesscontext.DBAccessContext +import com.scalableminds.util.objectid.ObjectId +import com.scalableminds.util.tools.{Fox, FoxImplicits} +import com.scalableminds.webknossos.datastore.helpers.IntervalScheduler +import com.typesafe.scalalogging.LazyLogging +import org.apache.pekko.actor.ActorSystem +import play.api.inject.ApplicationLifecycle +import play.api.libs.json.{JsObject, Json} + +import javax.inject.Inject +import scala.concurrent.ExecutionContext +import scala.concurrent.duration.{DurationInt, FiniteDuration} + +class CreditTransactionService @Inject()(creditTransactionDAO: CreditTransactionDAO, + organizationService: OrganizationService, + val lifecycle: ApplicationLifecycle, + val system: ActorSystem)(implicit val ec: ExecutionContext) + extends FoxImplicits + with LazyLogging + with IntervalScheduler { + + def hasEnoughCredits(organizationId: String, creditsToSpent: BigDecimal)( + implicit ctx: DBAccessContext): Fox[Boolean] = + creditTransactionDAO.getCreditBalance(organizationId).map(balance => balance >= creditsToSpent) + + def reserveCredits(organizationId: String, creditsToSpent: BigDecimal, comment: String, paidJob: Option[ObjectId])( + implicit ctx: DBAccessContext): Fox[CreditTransaction] = + for { + _ <- organizationService.ensureOrganizationHasPaidPlan(organizationId) + pendingCreditTransaction = CreditTransaction(ObjectId.generate, + organizationId, + -creditsToSpent, + creditsToSpent, + None, + None, + comment, + paidJob, + CreditTransactionState.Pending, + None) + _ <- creditTransactionDAO.insertNewPendingTransaction(pendingCreditTransaction) + insertedTransaction <- creditTransactionDAO.findOne(pendingCreditTransaction._id) + } yield insertedTransaction + + def doCreditTransaction(creditTransaction: CreditTransaction)(implicit ctx: DBAccessContext): Fox[Unit] = + for { + _ <- organizationService.ensureOrganizationHasPaidPlan(creditTransaction._organization) + _ <- creditTransactionDAO.insertTransaction(creditTransaction) + } yield () + + def completeTransactionOfJob(jobId: ObjectId)(implicit ctx: DBAccessContext): Fox[Unit] = + for { + transaction <- creditTransactionDAO.findTransactionForJob(jobId) + _ <- organizationService.ensureOrganizationHasPaidPlan(transaction._organization) + _ <- creditTransactionDAO.commitTransaction(transaction._id) + } yield () + + def refundTransactionForJob(jobId: ObjectId)(implicit ctx: DBAccessContext): Fox[Unit] = + for { + transaction <- creditTransactionDAO.findTransactionForJob(jobId) + _ <- refundTransaction(transaction) + } yield () + + private def refundTransaction(creditTransaction: CreditTransaction)(implicit ctx: DBAccessContext): Fox[Unit] = + for { + _ <- organizationService.ensureOrganizationHasPaidPlan(creditTransaction._organization) + _ <- creditTransactionDAO.refundTransaction(creditTransaction._id) + } yield () + + // This method is explicitly named this way to warn that this method should only be called when starting a job has failed. + // Else refunding should be done via jobId. + def refundTransactionWhenStartingJobFailed(creditTransaction: CreditTransaction)( + implicit ctx: DBAccessContext): Fox[Unit] = refundTransaction(creditTransaction) + + def addJobIdToTransaction(creditTransaction: CreditTransaction, jobId: ObjectId)( + implicit ctx: DBAccessContext): Fox[Unit] = + creditTransactionDAO.addJobIdToTransaction(creditTransaction, jobId) + + def publicWrites(transaction: CreditTransaction): Fox[JsObject] = + Fox.successful( + Json.obj( + "id" -> transaction._id, + "organization_id" -> transaction._organization, + "creditChange" -> transaction.creditChange, + "spentMoney" -> transaction.spentMoney, + "comment" -> transaction.comment, + "paidJobId" -> transaction._paidJob, + "state" -> transaction.state, + "expirationDate" -> transaction.expirationDate, + "createdAt" -> transaction.createdAt, + "updatedAt" -> transaction.updatedAt, + "isDeleted" -> transaction.isDeleted + )) + + def revokeExpiredCredits(): Fox[Unit] = creditTransactionDAO.runRevokeExpiredCredits() + def handOutMonthlyFreeCredits(): Fox[Unit] = creditTransactionDAO.handOutMonthlyFreeCredits() + + override protected def tickerInterval: FiniteDuration = 1 minute + + // TODO: make this class a singleton or put this somewhere else as this is executed for each instance of the service + override protected def tick(): Unit = + for { + _ <- Fox.successful(()) + _ = logger.info("Starting revoking expired credits...") + _ <- revokeExpiredCredits() + _ = logger.info("Finished revoking expired credits.") + _ = logger.info("Staring handing out free monthly credits.") + _ <- handOutMonthlyFreeCredits() + _ = logger.info("Finished handing out free monthly credits.") + } yield () + () +} diff --git a/app/models/organization/CreditTransactionState.scala b/app/models/organization/CreditTransactionState.scala new file mode 100644 index 00000000000..0ba3d282c89 --- /dev/null +++ b/app/models/organization/CreditTransactionState.scala @@ -0,0 +1,8 @@ +package models.organization + +import com.scalableminds.util.enumeration.ExtendedEnumeration + +object CreditTransactionState extends ExtendedEnumeration { + type TransactionState = Value + val Pending, Completed, Refunded, Revoked, PartiallyRevoked, Spent = Value +} diff --git a/app/models/organization/OrganizationService.scala b/app/models/organization/OrganizationService.scala index 4f26e7b87b3..e91baf12bf2 100644 --- a/app/models/organization/OrganizationService.scala +++ b/app/models/organization/OrganizationService.scala @@ -22,6 +22,7 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, multiUserDAO: MultiUserDAO, userDAO: UserDAO, teamDAO: TeamDAO, + creditTransactionDAO: CreditTransactionDAO, dataStoreDAO: DataStoreDAO, folderDAO: FolderDAO, folderService: FolderService, @@ -37,7 +38,8 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, "name" -> organization.name ) - def publicWrites(organization: Organization, requestingUser: Option[User] = None): Fox[JsObject] = { + def publicWrites(organization: Organization, requestingUser: Option[User] = None)( + implicit ctx: DBAccessContext): Fox[JsObject] = { val adminOnlyInfo = if (requestingUser.exists(_.isAdminOf(organization._id))) { Json.obj( @@ -49,6 +51,7 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, for { usedStorageBytes <- organizationDAO.getUsedStorage(organization._id) ownerBox <- userDAO.findOwnerByOrg(organization._id).futureBox + creditBalance <- creditTransactionDAO.getCreditBalance(organization._id) ownerNameOpt = ownerBox.toOption.map(o => s"${o.firstName} ${o.lastName}") } yield Json.obj( @@ -62,7 +65,8 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, "includedUsers" -> organization.includedUsers, "includedStorageBytes" -> organization.includedStorageBytes, "usedStorageBytes" -> usedStorageBytes, - "ownerName" -> ownerNameOpt + "ownerName" -> ownerNameOpt, + "creditBalance" -> creditBalance ) ++ adminOnlyInfo } @@ -176,4 +180,9 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, _ <- organizationDAO.acceptTermsOfService(organizationId, version, Instant.now) } yield () + def ensureOrganizationHasPaidPlan(organizationId: String): Fox[Unit] = + for { + organization <- organizationDAO.findOne(organizationId)(GlobalAccessContext) + _ <- bool2Fox(PricingPlan.isPaidPlan(organization.pricingPlan)) ?~> "creditTransaction.notPaidPlan" + } yield () } diff --git a/app/models/team/PricingPlan.scala b/app/models/team/PricingPlan.scala index e3d9d150f7e..4d066cf48ce 100644 --- a/app/models/team/PricingPlan.scala +++ b/app/models/team/PricingPlan.scala @@ -5,4 +5,6 @@ import com.scalableminds.util.enumeration.ExtendedEnumeration object PricingPlan extends ExtendedEnumeration { type PricingPlan = Value val Basic, Team, Power, Team_Trial, Power_Trial, Custom = Value + + def isPaidPlan(plan: PricingPlan): Boolean = plan != Basic } diff --git a/app/utils/WkConf.scala b/app/utils/WkConf.scala index c36c9abf5ed..09cff962bce 100644 --- a/app/utils/WkConf.scala +++ b/app/utils/WkConf.scala @@ -119,6 +119,10 @@ class WkConf @Inject()(configuration: Configuration, certificateValidationServic val isWkorgInstance: Boolean = get[Boolean]("features.isWkorgInstance") val jobsEnabled: Boolean = get[Boolean]("features.jobsEnabled") val voxelyticsEnabled: Boolean = get[Boolean]("features.voxelyticsEnabled") + val neuronInferralCostsPerGVx: BigDecimal = BigDecimal(get[String]("features.neuronInferralCostsPerGVx")) + val mitochondriaInferralCostsPerGVx: BigDecimal = BigDecimal( + get[String]("features.mitochondriaInferralCostsPerGVx")) + val alignmentCostsPerGVx: BigDecimal = BigDecimal(get[String]("features.alignmentCostsPerGVx")) val taskReopenAllowed: FiniteDuration = get[Int]("features.taskReopenAllowedInSeconds") seconds val allowDeleteDatasets: Boolean = get[Boolean]("features.allowDeleteDatasets") val publicDemoDatasetUrl: String = get[String]("features.publicDemoDatasetUrl") @@ -200,6 +204,7 @@ class WkConf @Inject()(configuration: Configuration, certificateValidationServic object Jobs { val workerLivenessTimeout: FiniteDuration = get[FiniteDuration]("jobs.workerLivenessTimeout") + val monthlyFreeCredits: Int = get[Int]("jobs.monthlyFreeCredits") } object Airbrake { diff --git a/app/utils/sql/SqlTypeImplicits.scala b/app/utils/sql/SqlTypeImplicits.scala index a77ec936d7b..51725091e78 100644 --- a/app/utils/sql/SqlTypeImplicits.scala +++ b/app/utils/sql/SqlTypeImplicits.scala @@ -16,6 +16,10 @@ trait SqlTypeImplicits { override def apply(v1: PositionedResult): ObjectId = ObjectId(v1.<<) } + implicit protected object GetObjectIdOpt extends GetResult[Option[ObjectId]] { + override def apply(v1: PositionedResult): Option[ObjectId] = v1.nextStringOption().map(ObjectId(_)) + } + implicit protected object GetInstant extends GetResult[Instant] { override def apply(v1: PositionedResult): Instant = Instant.fromSql(v1.<<) } diff --git a/app/views/mail/orderCredits.scala.html b/app/views/mail/orderCredits.scala.html new file mode 100644 index 00000000000..fb0b998ba67 --- /dev/null +++ b/app/views/mail/orderCredits.scala.html @@ -0,0 +1,10 @@ +@(name: String, requestedCredits: Int, additionalFooter: String) + +@emailBaseTemplate(additionalFooter) { +

Hi @{name}

+

Thank you for requesting to buy more WEBKNOSSOS credits. Our sales team will be in contact with you shortly with a formal offer.

+ +

Requested additional credits: @{requestedCredits}

+ +

With best regards,
the WEBKNOSSOS team

+} diff --git a/app/views/mail/orderCreditsRequest.scala.html b/app/views/mail/orderCreditsRequest.scala.html new file mode 100644 index 00000000000..a22dfe607bd --- /dev/null +++ b/app/views/mail/orderCreditsRequest.scala.html @@ -0,0 +1,11 @@ +@(name: String, email: String, organizationName: String, messageBody: String, additionalFooter: String) + +@emailBaseTemplate(additionalFooter) { +

Hi WEBKNOSSOS sales team

+

There is a new request to purchase WEBKNOSSOS credits.

+

User: @{name} (@{email})

+

Organization: @{organizationName}

+

Request: @{messageBody}

+ +

With best regards,
WEBKNOSSOS

+} diff --git a/conf/application.conf b/conf/application.conf index a775b1ae0a3..88752f610fe 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -153,6 +153,9 @@ features { # to enable jobs for local development, use "yarn enable-jobs" to also activate it in the database jobsEnabled = false voxelyticsEnabled = false + neuronInferralCostsPerGVx = 1 + mitochondriaInferralCostsPerGVx = 0.5 + alignmentCostsPerGVx = 0.5 # For new users, the dashboard will show a banner which encourages the user to check out the following dataset. # If isWkorgInstance == true, `/createExplorative/hybrid/true` is appended to the URL so that a new tracing is opened. # If isWkorgInstance == false, `/view` is appended to the URL so that it's opened in view mode (since the user might not @@ -286,6 +289,7 @@ silhouette { # Execute long-running jobs jobs { workerLivenessTimeout = 1 minute + monthlyFreeCredits = 2 } # Front-end analytics diff --git a/conf/evolutions/126-credit-transactions.sql b/conf/evolutions/126-credit-transactions.sql new file mode 100644 index 00000000000..1a1930bb7b8 --- /dev/null +++ b/conf/evolutions/126-credit-transactions.sql @@ -0,0 +1,112 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 125, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + + +-- Create the enum type for transaction states +CREATE TYPE webknossos.credit_transaction_state AS ENUM ('Pending', 'Completed', 'Refunded', 'Revoked', 'PartiallyRevoked', 'Spent'); + +-- Create the transactions table +CREATE TABLE webknossos.organization_credit_transactions ( + _id CHAR(24) PRIMARY KEY, + _organization VARCHAR(256) NOT NULL, + credit_change DECIMAL(14, 4) NOT NULL, + refundable_credit_change DECIMAL(14, 4) NOT NULL CHECK (refundable_credit_change >= 0), -- Ensure non-negative values + refunded_transaction_id CHAR(24) DEFAULT NULL, + spent_money DECIMAL(14, 4), + comment TEXT NOT NULL, + _paid_job CHAR(24), + state webknossos.credit_transaction_state NOT NULL, + expiration_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +--- Create view +CREATE VIEW webknossos.organization_credit_transactions_ as SELECT * FROM webknossos.organization_credit_transactions WHERE NOT is_deleted; + +--- Create index (useful for stored procedures) +CREATE INDEX ON webknossos.organization_credit_transactions(state); + +--- Add foreign key constraints +ALTER TABLE webknossos.organization_credit_transactions + ADD CONSTRAINT organization_ref FOREIGN KEY(_organization) REFERENCES webknossos.organizations(_id) DEFERRABLE, + ADD CONSTRAINT paid_job_ref FOREIGN KEY(_paid_job) REFERENCES webknossos.jobs(_id) DEFERRABLE; + +CREATE FUNCTION webknossos.enforce_non_negative_balance() RETURNS TRIGGER AS $$ + BEGIN + -- Assert that the new balance is non-negative + ASSERT (SELECT COALESCE(SUM(credit_change), 0) + COALESCE(NEW.credit_change, 0) + FROM webknossos.organization_credit_transactions + WHERE _organization = NEW._organization AND _id != NEW._id) >= 0, 'Transaction would result in a negative credit balance for organization %', NEW._organization; + -- Allow the transaction + RETURN NEW; + END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER enforce_non_negative_balance_trigger +BEFORE INSERT OR UPDATE ON webknossos.organization_credit_transactions +FOR EACH ROW EXECUTE PROCEDURE webknossos.enforce_non_negative_balance(); + +-- ObjectId generation function taken and modified from https://thinhdanggroup.github.io/mongo-id-in-postgresql/ +CREATE SEQUENCE webknossos.objectid_sequence; + +CREATE FUNCTION webknossos.generate_object_id() RETURNS TEXT AS $$ +DECLARE + time_component TEXT; + machine_id TEXT; + process_id TEXT; + counter TEXT; + result TEXT; +BEGIN + -- Extract the current timestamp in seconds since the Unix epoch (4 bytes, 8 hex chars) + SELECT LPAD(TO_HEX(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT), 8, '0') INTO time_component; + -- Generate a machine identifier using the hash of the server IP (3 bytes, 6 hex chars) + SELECT SUBSTRING(md5(CAST(inet_server_addr() AS TEXT)) FROM 1 FOR 6) INTO machine_id; + -- Retrieve the current backend process ID, limited to 2 bytes (4 hex chars) + SELECT LPAD(TO_HEX(pg_backend_pid() % 65536), 4, '0') INTO process_id; + -- Generate a counter using a sequence, ensuring it's 3 bytes (6 hex chars) + SELECT LPAD(TO_HEX(nextval('webknossos.objectid_sequence')::BIGINT % 16777216), 6, '0') INTO counter; + -- Concatenate all parts to form a 24-character ObjectId + result := time_component || machine_id || process_id || counter; + + RETURN result; +END; +$$ LANGUAGE plpgsql; + + +CREATE FUNCTION webknossos.hand_out_monthly_free_credits(free_credits_amount DECIMAL) RETURNS VOID AS $$ +DECLARE + org_id VARCHAR(256); + next_month_first_day DATE; + existing_transaction_count INT; +BEGIN + -- Calculate the first day of the next month + next_month_first_day := DATE_TRUNC('MONTH', NOW()) + INTERVAL '1 MONTH'; + + -- Loop through all organizations + FOR org_id IN (SELECT _id FROM webknossos.organizations) LOOP + -- Check if there is already a free credit transaction for this organization in the current month + SELECT COUNT(*) INTO existing_transaction_count + FROM webknossos.organization_credit_transactions + WHERE _organization = org_id + AND DATE_TRUNC('MONTH', expiration_date) = next_month_first_day; + + -- Insert free credits only if no record exists for this month + IF existing_transaction_count = 0 THEN + INSERT INTO webknossos.organization_credit_transactions + (_id, _organization, credit_change, refundable_credit_change, refunded_transaction_id, spent_money, + comment, _paid_job, state, expiration_date) + VALUES + (webknossos.generate_object_id(), org_id, free_credits_amount, 0, NULL, 0, + 'Free credits for this month', NULL, 'Completed', next_month_first_day); + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +UPDATE webknossos.releaseInformation SET schemaVersion = 126; + +COMMIT TRANSACTION; diff --git a/conf/evolutions/reversions/126-credit-transactions.sql b/conf/evolutions/reversions/126-credit-transactions.sql new file mode 100644 index 00000000000..603a8c3d9f4 --- /dev/null +++ b/conf/evolutions/reversions/126-credit-transactions.sql @@ -0,0 +1,30 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +-- Drop the trigger and its associated function asserting non negative balance +DROP TRIGGER IF EXISTS enforce_non_negative_balance_trigger ON webknossos.organization_credit_transactions; +DROP FUNCTION IF EXISTS webknossos.enforce_non_negative_balance(); + +DROP FUNCTION IF EXISTS webknossos.generate_object_id(); +DROP FUNCTION IF EXISTS FUNCTION webknossos.hand_out_monthly_free_credits(free_credits_amount DECIMAL); +DROP SEQUENCE webknossos.objectid_sequence; + + +-- Drop the foreign key constraints +ALTER TABLE webknossos.organization_credit_transactions + DROP CONSTRAINT IF EXISTS organization_ref, + DROP CONSTRAINT IF EXISTS paid_job_ref; + +-- Drop the view +DROP VIEW IF EXISTS webknossos.organization_credit_transactions_; + +-- Drop the table +DROP TABLE IF EXISTS webknossos.organization_credit_transactions; + +-- Drop the enum type +DROP TYPE IF EXISTS webknossos.credit_transaction_state; + +UPDATE webknossos.releaseInformation SET schemaVersion = 125; + +COMMIT TRANSACTION; diff --git a/conf/messages b/conf/messages index d22b5b9c376..8cc630b1d96 100644 --- a/conf/messages +++ b/conf/messages @@ -40,6 +40,8 @@ organization.id.alreadyInUse=This id is already claimed by a different organizat organization.alreadyJoined=Your account is already associated with the selected organization. organization.users.userLimitReached=Cannot add new user to this organization because it would exceed the organization’s user limit. Please ask the organization owner to upgrade. organization.pricingUpgrades.notAuthorized=You are not authorized to request any changes to your organization WEBKNOSSOS plan. Please ask the organization owner for permission. +organization.creditOrder.notAuthorized=You are not authorized to order WEBKNOSSOS credits for your organization. Please ask the organization owner to do this instead. +organization.creditOrder.notPositive=You cannot order a negative number of WEBKNOSSOS credits. termsOfService.versionMismatch=Terms of service version mismatch. Current version is {0}, received acceptance for {1} @@ -115,6 +117,7 @@ dataset.explore.magDtypeMismatch=Element class must be the same for all mags of dataset.explore.autoAdd.failed=Failed to automatically import the explored dataset. dataset.additionalCoordinates.different=Additional coordinates differ in merged units. dataset.metadata.duplicateKeys=Metadata keys must be unique. +dataset.boundingBox.unset=This dataset has no bounding box. Please make sure this dataset is imported correctly. dataVault.insert.failed=Failed to store remote file system credential dataVault.setup.failed=Failed to set up remote file system @@ -343,6 +346,10 @@ job.alignSections.notAllowed.organization = Aligning sections is only allowed fo job.additionalCoordinates.invalid = The passed additional coordinates are invalid. job.trainModel.notAllowed.organization = Training AI models is only allowed for datasets of your own organization. job.runInference.notAllowed.organization = Running inference is only allowed for datasets of your own organization. +job.paidJob.notAllowed.noPaidPlan = You are not allowed to run this job because your organization does not have a paid plan. +job.notEnoughCredits = Your organization does not have enough WEBKNOSSOS credits to run this job. + +creditTransaction.notPaidPlan = Your organization does not have a paid plan. voxelytics.disabled = Voxelytics workflow reporting and logging are not enabled for this WEBKNOSSOS instance. voxelytics.runNotFound = Workflow runs not found diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 6284bc3227d..a3d3ba0a4c8 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -19,6 +19,7 @@ PUT /maintenances/:id DELETE /maintenances/:id controllers.MaintenanceController.delete(id) POST /maintenances controllers.MaintenanceController.create() POST /maintenances/adHoc controllers.MaintenanceController.createAdHocMaintenance() +GET /maintenance/revokeExpiredCredits controllers.CreditTransactionController.revokeExpiredCredits() # Authentication POST /auth/register controllers.AuthenticationController.register() @@ -240,6 +241,7 @@ POST /pricing/requestExtension POST /pricing/requestUpgrade controllers.OrganizationController.sendUpgradePricingPlanEmail(requestedPlan: String) POST /pricing/requestUsers controllers.OrganizationController.sendUpgradePricingPlanUsersEmail(requestedUsers: Int) POST /pricing/requestStorage controllers.OrganizationController.sendUpgradePricingPlanStorageEmail(requestedStorage: Int) +POST /pricing/requestCredits controllers.OrganizationController.sendOrderCreditsEmail(requestedCredits: Int) GET /pricing/status controllers.OrganizationController.pricingStatus() GET /termsOfService controllers.OrganizationController.getTermsOfService() POST /termsOfService/accept controllers.OrganizationController.acceptTermsOfService(version: Int) @@ -255,6 +257,7 @@ GET /time/overview GET /jobs/request controllers.WKRemoteWorkerController.requestJobs(key: String) GET /jobs controllers.JobController.list() GET /jobs/status controllers.JobController.status() +GET /jobs/getCosts controllers.JobController.getJobCosts(command: String, boundingBoxInMag: String) POST /jobs/run/convertToWkw/:datasetId controllers.JobController.runConvertToWkwJob(datasetId: String, scale: String, unit: Option[String]) POST /jobs/run/computeMeshFile/:datasetId controllers.JobController.runComputeMeshFileJob(datasetId: String, layerName: String, mag: String, agglomerateView: Option[String]) POST /jobs/run/computeSegmentIndexFile/:datasetId controllers.JobController.runComputeSegmentIndexFileJob(datasetId: String, layerName: String) @@ -284,6 +287,10 @@ GET /aiModels/:id PUT /aiModels/:id controllers.AiModelController.updateAiModelInfo(id: String) DELETE /aiModels/:id controllers.AiModelController.deleteAiModel(id: String) +# CreditTransactions +POST /creditTransactions/chargeUpCredits controllers.CreditTransactionController.chargeUpCredits(organizationId: String, creditAmount: Int, moneySpent: String, comment: Option[String], expiresAt: Option[String]) +PATCH /creditTransactions/:transactionId controllers.CreditTransactionController.refundCreditTransaction(organizationId: String, transactionId: String) + # Publications GET /publications controllers.PublicationController.listPublications() GET /publications/:id controllers.PublicationController.read(id: String) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index f86b1cf31a7..07b7f445241 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1770,6 +1770,12 @@ export async function sendUpgradePricingPlanStorageEmail(requestedStorage: numbe }); } +export async function sendOrderCreditsEmail(requestedCredits: number): Promise { + return Request.receiveJSON(`/api/pricing/requestCredits?requestedCredits=${requestedCredits}`, { + method: "POST", + }); +} + export async function getPricingPlanStatus(): Promise { return Request.receiveJSON("/api/pricing/status"); } diff --git a/frontend/javascripts/admin/api/jobs.ts b/frontend/javascripts/admin/api/jobs.ts index db907374dd4..21fec85b80a 100644 --- a/frontend/javascripts/admin/api/jobs.ts +++ b/frontend/javascripts/admin/api/jobs.ts @@ -74,6 +74,15 @@ export async function cancelJob(jobId: string): Promise { }); } +export async function getJobCosts(command: string, boundingBoxInMag: Vector6): Promise { + const params = new URLSearchParams({ + command, + boundingBoxInMag: boundingBoxInMag.join(","), + }); + const response = await Request.receiveJSON(`/api/jobs/getCosts?${params}`); + return response.costsInCredits; +} + export async function startConvertToWkwJob( datasetId: string, scale: Vector3, diff --git a/frontend/javascripts/admin/organization/organization_cards.tsx b/frontend/javascripts/admin/organization/organization_cards.tsx index c8674546f7c..38bad4c4a88 100644 --- a/frontend/javascripts/admin/organization/organization_cards.tsx +++ b/frontend/javascripts/admin/organization/organization_cards.tsx @@ -8,6 +8,7 @@ import { Alert, Button, Card, Col, Progress, Row } from "antd"; import { formatDateInLocalTimeZone } from "components/formatted_date"; import dayjs from "dayjs"; import { formatCountToDataAmountUnit } from "libs/format_utils"; +import { roundTo } from "libs/utils"; import Constants from "oxalis/constants"; import type { OxalisState } from "oxalis/store"; import type React from "react"; @@ -103,13 +104,14 @@ export function PlanUpgradeCard({ organization }: { organization: APIOrganizatio Upgrading your WEBKNOSSOS plan will unlock more advanced features and increase your user and storage quotas.

-

+ {/* React complaint here:

    cannot appear as a descendant of

    . */} +

      {powerPlanFeatures.map((feature) => (
    • {feature}
    • ))}
    -

    + @@ -759,7 +889,7 @@ export function NucleiDetectionForm() { dispatch(setAIJobModalStateAction("invisible"))} buttonLabel="Start AI nuclei detection" - jobName={"nuclei_inferral"} + jobName={APIJobType.INFER_NUCLEI} title="AI Nuclei Segmentation" suggestedDatasetSuffix="with_nuclei" jobApiCall={async ({ newDatasetName, selectedLayer: colorLayer }) => @@ -787,17 +917,19 @@ export function NucleiDetectionForm() { } export function NeuronSegmentationForm() { const dataset = useSelector((state: OxalisState) => state.dataset); + const { neuronInferralCostsPerGVx } = features(); const hasSkeletonAnnotation = useSelector((state: OxalisState) => state.tracing.skeleton != null); const dispatch = useDispatch(); const [doSplitMergerEvaluation, setDoSplitMergerEvaluation] = React.useState(false); return ( dispatch(setAIJobModalStateAction("invisible"))} - jobName={"neuron_inferral"} + jobName={APIJobType.INFER_NEURONS} buttonLabel="Start AI neuron segmentation" title="AI Neuron Segmentation" suggestedDatasetSuffix="with_reconstructed_neurons" isBoundingBoxConfigurable + jobCreditCostsPerGVx={neuronInferralCostsPerGVx} jobApiCall={async ( { newDatasetName, selectedLayer: colorLayer, selectedBoundingBox, annotationId }, form: FormInstance, @@ -862,15 +994,17 @@ export function NeuronSegmentationForm() { export function MitochondriaSegmentationForm() { const dataset = useSelector((state: OxalisState) => state.dataset); + const { mitochondriaInferralCostsPerGVx } = features(); const dispatch = useDispatch(); return ( dispatch(setAIJobModalStateAction("invisible"))} - jobName={"mitochondria_inferral"} + jobName={APIJobType.INFER_MITOCHONDRIA} buttonLabel="Start AI mitochondria segmentation" title="AI Mitochondria Segmentation" suggestedDatasetSuffix="with_mitochondria_detected" isBoundingBoxConfigurable + jobCreditCostsPerGVx={mitochondriaInferralCostsPerGVx} jobApiCall={async ({ newDatasetName, selectedLayer: colorLayer, selectedBoundingBox }) => { if (!selectedBoundingBox) { return; @@ -919,7 +1053,7 @@ function CustomAiModelInferenceForm() { return ( dispatch(setAIJobModalStateAction("invisible"))} - jobName="inference" + jobName={APIJobType.INFER_WITH_MODEL} buttonLabel="Start inference with custom AI model" title="AI Inference" suggestedDatasetSuffix="with_custom_model" @@ -975,10 +1109,11 @@ function CustomAiModelInferenceForm() { export function AlignSectionsForm() { const dataset = useSelector((state: OxalisState) => state.dataset); const dispatch = useDispatch(); + const { alignmentCostsPerGVx } = features(); return ( dispatch(setAIJobModalStateAction("invisible"))} - jobName={"align_sections"} + jobName={APIJobType.ALIGN_SECTIONS} buttonLabel="Start section alignment job" title="Section Alignment" suggestedDatasetSuffix="aligned" @@ -987,6 +1122,7 @@ export function AlignSectionsForm() { jobApiCall={async ({ newDatasetName, selectedLayer: colorLayer, annotationId }) => startAlignSectionsJob(dataset.id, colorLayer.name, newDatasetName, annotationId) } + jobCreditCostsPerGVx={alignmentCostsPerGVx} description={ @@ -1081,7 +1217,7 @@ export function MaterializeVolumeAnnotationModal({ { return ( Store.dispatch(setAIJobModalStateAction("neuron_inferral"))} + onClick={() => Store.dispatch(setAIJobModalStateAction(APIJobType.INFER_NEURONS))} style={{ marginLeft: 12, pointerEvents: "auto" }} disabled={disabled} title={tooltipText} diff --git a/frontend/javascripts/test/fixtures/dummy_organization.ts b/frontend/javascripts/test/fixtures/dummy_organization.ts index b0ba1f8ba4c..2e5e3798512 100644 --- a/frontend/javascripts/test/fixtures/dummy_organization.ts +++ b/frontend/javascripts/test/fixtures/dummy_organization.ts @@ -12,6 +12,7 @@ const dummyOrga: APIOrganization = { includedUsers: 1, includedStorageBytes: 1200000, usedStorageBytes: 1000, + creditBalance: 0, ownerName: undefined, }; diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md index bfd3f3ae106..3002431eaed 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md @@ -68,6 +68,7 @@ Generated by [AVA](https://avajs.dev). > Snapshot 1 { + alignmentCostsPerGVx: 0.5, allowDeleteDatasets: true, defaultToLegacyBindings: false, discussionBoard: 'https://forum.image.sc/tag/webknossos', @@ -78,6 +79,8 @@ Generated by [AVA](https://avajs.dev). hideNavbarLogin: false, isWkorgInstance: false, jobsEnabled: false, + mitochondriaInferralCostsPerGVx: 0.5, + neuronInferralCostsPerGVx: 1, openIdConnectEnabled: false, optInTabs: [], publicDemoDatasetUrl: 'https://webknossos.org/datasets/scalable_minds/l4dense_motta_et_al_demo', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap index 393ac010f3f..533feb1e85b 100644 Binary files a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap and b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap differ diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index ab9fb2c48a8..899b4213794 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -649,6 +649,7 @@ export type APIOrganization = APIOrganizationCompact & { readonly includedStorageBytes: number; readonly usedStorageBytes: number; readonly ownerName?: string; + readonly creditBalance: number; }; export type APIPricingPlanStatus = { readonly pricingPlan: PricingPlanEnum; @@ -704,6 +705,9 @@ export type APIFeatureToggles = { readonly allowDeleteDatasets: boolean; readonly jobsEnabled: boolean; readonly voxelyticsEnabled: boolean; + readonly neuronInferralCostsPerGVx: number; + readonly mitochondriaInferralCostsPerGVx: number; + readonly alignmentCostsPerGVx: number; readonly publicDemoDatasetUrl: string; readonly exportTiffMaxVolumeMVx: number; readonly exportTiffMaxEdgeLengthVx: number; diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index ed5c00bf4d7..c80ab771bc2 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -20,7 +20,7 @@ CREATE TABLE webknossos.releaseInformation ( schemaVersion BIGINT NOT NULL ); -INSERT INTO webknossos.releaseInformation(schemaVersion) values(125); +INSERT INTO webknossos.releaseInformation(schemaVersion) values(126); COMMIT TRANSACTION; @@ -349,6 +349,26 @@ CREATE TABLE webknossos.organization_usedStorage( PRIMARY KEY(_organization, _dataStore, _dataset, layerName, magOrDirectoryName) ); +-- Create the enum type for transaction states +CREATE TYPE webknossos.credit_transaction_state AS ENUM ('Pending', 'Completed', 'Refunded', 'Revoked', 'PartiallyRevoked', 'Spent'); + +-- Create the transactions table +CREATE TABLE webknossos.organization_credit_transactions ( + _id CHAR(24) PRIMARY KEY, + _organization VARCHAR(256) NOT NULL, + credit_change DECIMAL(14, 4) NOT NULL, + refundable_credit_change DECIMAL(14, 4) NOT NULL CHECK (refundable_credit_change >= 0), -- Ensure non-negative values + refunded_transaction_id CHAR(24) DEFAULT NULL, + spent_money DECIMAL(14, 4), + comment TEXT NOT NULL, + _paid_job CHAR(24), + state webknossos.credit_transaction_state NOT NULL, + expiration_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TYPE webknossos.USER_PASSWORDINFO_HASHERS AS ENUM ('SCrypt', 'Empty'); CREATE TABLE webknossos.users( _id CHAR(24) PRIMARY KEY, @@ -723,6 +743,7 @@ CREATE VIEW webknossos.credentials_ as SELECT * FROM webknossos.credentials WHER CREATE VIEW webknossos.maintenances_ as SELECT * FROM webknossos.maintenances WHERE NOT isDeleted; CREATE VIEW webknossos.aiModels_ as SELECT * FROM webknossos.aiModels WHERE NOT isDeleted; CREATE VIEW webknossos.aiInferences_ as SELECT * FROM webknossos.aiInferences WHERE NOT isDeleted; +CREATE VIEW webknossos.organization_credit_transactions_ as SELECT * FROM webknossos.organization_credit_transactions WHERE NOT is_deleted; CREATE VIEW webknossos.userInfos AS SELECT @@ -759,6 +780,7 @@ CREATE INDEX ON webknossos.projects(_team, isDeleted); CREATE INDEX ON webknossos.invites(tokenValue); CREATE INDEX ON webknossos.annotation_privateLinks(accessToken); CREATE INDEX ON webknossos.shortLinks(key); +CREATE INDEX ON webknossos.organization_credit_transactions(state); ALTER TABLE webknossos.annotations ADD CONSTRAINT task_ref FOREIGN KEY(_task) REFERENCES webknossos.tasks(_id) ON DELETE SET NULL DEFERRABLE, @@ -814,6 +836,9 @@ ALTER TABLE webknossos.user_experiences ALTER TABLE webknossos.user_datasetConfigurations ADD CONSTRAINT user_ref FOREIGN KEY(_user) REFERENCES webknossos.users(_id) ON DELETE CASCADE DEFERRABLE, ADD CONSTRAINT dataset_ref FOREIGN KEY(_dataset) REFERENCES webknossos.datasets(_id) ON DELETE CASCADE DEFERRABLE; +ALTER TABLE webknossos.organization_credit_transactions + ADD CONSTRAINT organization_ref FOREIGN KEY(_organization) REFERENCES webknossos.organizations(_id) DEFERRABLE, + ADD CONSTRAINT paid_job_ref FOREIGN KEY(_paid_job) REFERENCES webknossos.jobs(_id) DEFERRABLE; ALTER TABLE webknossos.user_datasetLayerConfigurations ADD CONSTRAINT user_ref FOREIGN KEY(_user) REFERENCES webknossos.users(_id) ON DELETE CASCADE DEFERRABLE, ADD CONSTRAINT dataset_ref FOREIGN KEY(_dataset) REFERENCES webknossos.datasets(_id) ON DELETE CASCADE DEFERRABLE; @@ -931,7 +956,7 @@ AFTER UPDATE ON webknossos.annotations FOR EACH ROW EXECUTE PROCEDURE webknossos.onUpdateAnnotation(); -CREATE FUNCTION webknossos.onDeleteAnnotation() RETURNS trigger AS $$ +CREATE FUNCTION webknossos.onDeleteAnnotation() RETURNS TRIGGER AS $$ BEGIN IF (OLD.typ = 'Task') AND (OLD.isDeleted = false) AND (OLD.state != 'Cancelled') THEN UPDATE webknossos.tasks SET pendingInstances = pendingInstances + 1 WHERE _id = OLD._task; @@ -944,3 +969,79 @@ CREATE TRIGGER onDeleteAnnotationTrigger AFTER DELETE ON webknossos.annotations FOR EACH ROW EXECUTE PROCEDURE webknossos.onDeleteAnnotation(); +CREATE FUNCTION webknossos.enforce_non_negative_balance() RETURNS TRIGGER AS $$ + BEGIN + -- Assert that the new balance is non-negative + ASSERT (SELECT COALESCE(SUM(credit_change), 0) + COALESCE(NEW.credit_change, 0) + FROM webknossos.organization_credit_transactions + WHERE _organization = NEW._organization AND _id != NEW._id) >= 0, 'Transaction would result in a negative credit balance for organization %', NEW._organization; + -- Allow the transaction + RETURN NEW; + END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER enforce_non_negative_balance_trigger +BEFORE INSERT OR UPDATE ON webknossos.organization_credit_transactions +FOR EACH ROW EXECUTE PROCEDURE webknossos.enforce_non_negative_balance(); + +-- ObjectId generation function taken and modified from https://thinhdanggroup.github.io/mongo-id-in-postgresql/ +CREATE SEQUENCE webknossos.objectid_sequence; + +CREATE FUNCTION webknossos.generate_object_id() RETURNS TEXT AS $$ +DECLARE + time_component TEXT; + machine_id TEXT; + process_id TEXT; + counter TEXT; + result TEXT; +BEGIN + -- Extract the current timestamp in seconds since the Unix epoch (4 bytes, 8 hex chars) + SELECT LPAD(TO_HEX(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::BIGINT), 8, '0') INTO time_component; + -- Generate a machine identifier using the hash of the server IP (3 bytes, 6 hex chars) + SELECT SUBSTRING(md5(CAST(inet_server_addr() AS TEXT)) FROM 1 FOR 6) INTO machine_id; + -- Retrieve the current backend process ID, limited to 2 bytes (4 hex chars) + SELECT LPAD(TO_HEX(pg_backend_pid() % 65536), 4, '0') INTO process_id; + -- Generate a counter using a sequence, ensuring it's 3 bytes (6 hex chars) + SELECT LPAD(TO_HEX(nextval('webknossos.objectid_sequence')::BIGINT % 16777216), 6, '0') INTO counter; + -- Concatenate all parts to form a 24-character ObjectId + result := time_component || machine_id || process_id || counter; + + RETURN result; +END; +$$ LANGUAGE plpgsql; + + +CREATE FUNCTION webknossos.hand_out_monthly_free_credits(free_credits_amount DECIMAL) RETURNS VOID AS $$ +DECLARE + org_id VARCHAR(256); + next_month_first_day DATE; + existing_transaction_count INT; +BEGIN + -- Calculate the first day of the next month + next_month_first_day := DATE_TRUNC('MONTH', NOW()) + INTERVAL '1 MONTH'; + + -- Loop through all organizations + FOR org_id IN (SELECT _id FROM webknossos.organizations) LOOP + -- Check if there is already a free credit transaction for this organization in the current month + SELECT COUNT(*) INTO existing_transaction_count + FROM webknossos.organization_credit_transactions + WHERE _organization = org_id + AND DATE_TRUNC('MONTH', expiration_date) = next_month_first_day; + + -- Insert free credits only if no record exists for this month + IF existing_transaction_count = 0 THEN + INSERT INTO webknossos.organization_credit_transactions + (_id, _organization, credit_change, refundable_credit_change, refunded_transaction_id, spent_money, + comment, _paid_job, state, expiration_date) + VALUES + (webknossos.generate_object_id(), org_id, free_credits_amount, 0, NULL, 0, + 'Free credits for this month', NULL, 'Completed', next_month_first_day); + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; + + + + diff --git a/util/src/main/scala/com/scalableminds/util/time/Instant.scala b/util/src/main/scala/com/scalableminds/util/time/Instant.scala index d61f1e7108f..b8e93c63f79 100644 --- a/util/src/main/scala/com/scalableminds/util/time/Instant.scala +++ b/util/src/main/scala/com/scalableminds/util/time/Instant.scala @@ -57,6 +57,8 @@ object Instant extends FoxImplicits with LazyLogging with Formatter { def fromSql(sqlTime: java.sql.Timestamp): Instant = Instant(sqlTime.getTime) + def fromDate(sqlDate: java.sql.Date): Instant = Instant(sqlDate.getTime) + def fromLocalTimeString(localTimeLiteral: String)(implicit ec: ExecutionContext): Fox[Instant] = tryo(new java.text.SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss.SSS").parse(localTimeLiteral)) .map(date => Instant(date.getTime))