diff --git a/.vscode/launch.json b/.vscode/launch.json index 6eaa59f..fa3bca3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,12 +1,12 @@ { - "version": "0.2.0", - "configurations": [ - { - "type": "bun", - "request": "attach", - "name": "[Bun] Backend", - "url": "ws://localhost:6499", - "localRoot": "${workspaceFolder}" - }, - ] + "version": "0.2.0", + "configurations": [ + { + "type": "bun", + "request": "attach", + "name": "[Bun] Backend", + "url": "ws://localhost:6499", + "localRoot": "${workspaceFolder}" + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 44891a0..5473acd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -125,6 +125,7 @@ "manashield", "manaspent", "mariadb", + "mercadopago", "miforge", "mitg", "mitgdev", @@ -176,11 +177,13 @@ "roboto", "safelist", "serverinfo", + "serviceid", "sessionkey", "showrewardnews", "SKRS", "skulltime", "stayloggedin", + "stonebar", "synchronisation", "tailwindcss", "tanstack", @@ -192,6 +195,7 @@ "tibiaclient", "tibiacoin", "tibiacointrusted", + "tibiora", "Toplayers", "torsocolor", "tournamentticketpurchasestate", @@ -212,6 +216,7 @@ "warid", "wheeldata", "worldid", + "xlarge", "xpboost" ], } \ No newline at end of file diff --git a/apps/api/prisma/migrations/20251212000952_shop_services_and_transactions/migration.sql b/apps/api/prisma/migrations/20251212000952_shop_services_and_transactions/migration.sql new file mode 100644 index 0000000..6e6458c --- /dev/null +++ b/apps/api/prisma/migrations/20251212000952_shop_services_and_transactions/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE `miforge_shop_service` ( + `id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + `type` ENUM('COINS') NOT NULL, + `slug` VARCHAR(100) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `price` INTEGER UNSIGNED NOT NULL DEFAULT 0, + `quantity` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `is_active` BOOLEAN NOT NULL DEFAULT true, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `miforge_shop_service_slug_key`(`slug`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `miforge_shop_transactions` ( + `id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + `status` ENUM('PENDING', 'COMPLETED', 'FAILED', 'CANCELED', 'REFUNDED') NOT NULL DEFAULT 'PENDING', + `method` ENUM('MERCADO_PAGO_PIX') NOT NULL, + `units` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `total` INTEGER UNSIGNED NOT NULL DEFAULT 0, + `service_id` INTEGER UNSIGNED NOT NULL, + `account_id` INTEGER UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `miforge_shop_transactions` ADD CONSTRAINT `miforge_shop_transactions_service_id_fkey` FOREIGN KEY (`service_id`) REFERENCES `miforge_shop_service`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `miforge_shop_transactions` ADD CONSTRAINT `miforge_shop_transactions_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20251212004820_proce_to_unit_price/migration.sql b/apps/api/prisma/migrations/20251212004820_proce_to_unit_price/migration.sql new file mode 100644 index 0000000..2a6d986 --- /dev/null +++ b/apps/api/prisma/migrations/20251212004820_proce_to_unit_price/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `price` on the `miforge_shop_service` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `miforge_shop_service` DROP COLUMN `price`, + ADD COLUMN `unit_price` INTEGER UNSIGNED NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/migrations/20251213130619_add_id_in_transaction_from_provider/migration.sql b/apps/api/prisma/migrations/20251213130619_add_id_in_transaction_from_provider/migration.sql new file mode 100644 index 0000000..d7bbb05 --- /dev/null +++ b/apps/api/prisma/migrations/20251213130619_add_id_in_transaction_from_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `miforge_shop_transactions` ADD COLUMN `method_transaction_id` VARCHAR(255) NULL; diff --git a/apps/api/prisma/migrations/20251213140742_add_provider_with_method/migration.sql b/apps/api/prisma/migrations/20251213140742_add_provider_with_method/migration.sql new file mode 100644 index 0000000..dd069cf --- /dev/null +++ b/apps/api/prisma/migrations/20251213140742_add_provider_with_method/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - The values [MERCADO_PAGO_PIX] on the enum `miforge_shop_transactions_method` will be removed. If these variants are still used in the database, this will fail. + - Added the required column `provider` to the `miforge_shop_transactions` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `miforge_shop_transactions` ADD COLUMN `provider` ENUM('MERCADO_PAGO') NOT NULL, + MODIFY `method` ENUM('PIX') NOT NULL; + +-- CreateIndex +CREATE INDEX `idx_service_account_provider` ON `miforge_shop_transactions`(`service_id`, `account_id`); + +-- CreateIndex +CREATE INDEX `idx_method_transaction_id` ON `miforge_shop_transactions`(`method_transaction_id`); + +-- CreateIndex +CREATE INDEX `idx_status` ON `miforge_shop_transactions`(`status`); + +-- CreateIndex +CREATE INDEX `idx_provider` ON `miforge_shop_transactions`(`provider`); diff --git a/apps/api/prisma/migrations/20251213151549_migrate_provider_to_other_table/migration.sql b/apps/api/prisma/migrations/20251213151549_migrate_provider_to_other_table/migration.sql new file mode 100644 index 0000000..00feb91 --- /dev/null +++ b/apps/api/prisma/migrations/20251213151549_migrate_provider_to_other_table/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - You are about to drop the column `method` on the `miforge_shop_transactions` table. All the data in the column will be lost. + - You are about to drop the column `provider` on the `miforge_shop_transactions` table. All the data in the column will be lost. + - Added the required column `provider_id` to the `miforge_shop_transactions` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX `idx_provider` ON `miforge_shop_transactions`; + +-- AlterTable +ALTER TABLE `miforge_shop_transactions` DROP COLUMN `method`, + DROP COLUMN `provider`, + ADD COLUMN `provider_id` INTEGER UNSIGNED NOT NULL; + +-- CreateTable +CREATE TABLE `miforge_shop_providers` ( + `id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `description` TEXT NULL, + `method` ENUM('PIX') NOT NULL, + `provider` ENUM('MERCADO_PAGO') NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `miforge_shop_providers_method_provider_key`(`method`, `provider`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `miforge_shop_transactions` ADD CONSTRAINT `miforge_shop_transactions_provider_id_fkey` FOREIGN KEY (`provider_id`) REFERENCES `miforge_shop_providers`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20251213152454_method_provider_and_active/migration.sql b/apps/api/prisma/migrations/20251213152454_method_provider_and_active/migration.sql new file mode 100644 index 0000000..cccfd93 --- /dev/null +++ b/apps/api/prisma/migrations/20251213152454_method_provider_and_active/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `miforge_shop_providers` ADD COLUMN `is_active` BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/models/base.prisma b/apps/api/prisma/models/base.prisma index e1637fe..a4ad16e 100644 --- a/apps/api/prisma/models/base.prisma +++ b/apps/api/prisma/models/base.prisma @@ -101,6 +101,7 @@ model accounts { audits miforge_account_audit[] confirmations miforge_account_confirmations[] oauths miforge_account_oauths[] + transactions miforge_shop_transactions[] two_factor_enabled Boolean @default(false) two_factor_secret String? @db.VarChar(64) diff --git a/apps/api/prisma/models/shop.prisma b/apps/api/prisma/models/shop.prisma new file mode 100644 index 0000000..e31cec0 --- /dev/null +++ b/apps/api/prisma/models/shop.prisma @@ -0,0 +1,80 @@ +enum ShopServiceType { + COINS +} + +model miforge_shop_service { + id Int @id @default(autoincrement()) @db.UnsignedInt + type ShopServiceType + slug String @unique @db.VarChar(100) + title String @db.VarChar(255) + description String? @db.Text + + transactions miforge_shop_transactions[] + + unit_price Int @default(0) @db.UnsignedInt + quantity Int @default(1) @db.UnsignedInt + is_active Boolean @default(true) + + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt +} + +enum ShopTransactionStatus { + PENDING + COMPLETED + FAILED + CANCELED + REFUNDED +} + +enum ShopProviderMethod { + PIX +} + +enum ShopProvider { + MERCADO_PAGO +} + +model miforge_shop_providers { + id Int @id @default(autoincrement()) @db.UnsignedInt + + name String @db.VarChar(100) + description String? @db.Text + isActive Boolean @default(true) @map("is_active") + + method ShopProviderMethod + provider ShopProvider + + transactions miforge_shop_transactions[] + + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + @@unique([method, provider], name: "uniq_method_provider") +} + +model miforge_shop_transactions { + id Int @id @default(autoincrement()) @db.UnsignedInt + + status ShopTransactionStatus @default(PENDING) + methodTransactionId String? @map("method_transaction_id") @db.VarChar(255) + + units Int @default(1) @db.UnsignedInt + total Int @default(0) @db.UnsignedInt + + providerId Int @map("provider_id") @db.UnsignedInt + provider miforge_shop_providers @relation(fields: [providerId], references: [id], onDelete: Cascade) + + serviceId Int @map("service_id") @db.UnsignedInt + service miforge_shop_service @relation(fields: [serviceId], references: [id], onDelete: Cascade) + + accountId Int @map("account_id") @db.UnsignedInt + account accounts @relation(fields: [accountId], references: [id], onDelete: Cascade) + + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + @@index([serviceId, accountId], name: "idx_service_account_provider") + @@index([methodTransactionId], name: "idx_method_transaction_id") + @@index([status], name: "idx_status") +} diff --git a/apps/api/prisma/seed/index.ts b/apps/api/prisma/seed/index.ts index a41aa55..9f0853f 100644 --- a/apps/api/prisma/seed/index.ts +++ b/apps/api/prisma/seed/index.ts @@ -1,5 +1,5 @@ import { env } from '@/infra/env' -import {PrismaClient} from 'generated/client' +import {PrismaClient, type miforge_shop_service} from 'generated/client' import {PrismaMariaDb} from "@prisma/adapter-mariadb" import crypto from "node:crypto"; import { MiforgeConfigSchema } from '@/shared/schemas/Config'; @@ -39,6 +39,9 @@ const miforgeConfig = MiforgeConfigSchema.decode({ discord: { enabled: Boolean(env.DISCORD_ENABLED) }, + mercado_pago: { + enabled: Boolean(env.MERCADO_PAGO_ENABLED) + }, account: { emailConfirmationRequired: Boolean(env.MAILER_PROVIDER), emailChangeConfirmationRequired: Boolean(env.MAILER_PROVIDER), @@ -46,6 +49,50 @@ const miforgeConfig = MiforgeConfigSchema.decode({ } }) +const DEFAULT_SHOP_SERVICES: Pick[] = [{ + type: "COINS", + title: "250 Coins", + slug: "250-coins", + unit_price: 10, // cents = 0.10 = 10/100 = 0.1 + quantity: 250, + description: null +}, { + type: "COINS", + title: "750 Coins", + slug: "750-coins", + unit_price: 10, + quantity: 750, + description: null +}, { + type: "COINS", + title: "1500 Coins", + slug: "1500-coins", + unit_price: 10, + quantity: 1500, + description: null +}, { + type: "COINS", + title: "3000 Coins", + slug: "3000-coins", + unit_price: 10, + quantity: 3000, + description: null +}, { + type: "COINS", + title: "4000 Coins", + slug: "4000-coins", + unit_price: 10, + quantity: 4000, + description: null +}, { + type: "COINS", + title: "12000 Coins", + slug: "12000-coins", + unit_price: 10, + quantity: 12000, + description: null +}] + async function main() { console.log("[seed] Seeding miforge_configs") await prisma.miforge_config.upsert({ @@ -61,6 +108,33 @@ async function main() { } }) + console.log("[seed] Seeding default shop services") + for (const service of DEFAULT_SHOP_SERVICES) { + await prisma.miforge_shop_service.upsert({ + where: { + slug: service.slug + }, + create: service, + update: service + }) + } + + console.log("[seed] Seeding default payment providers") + await prisma.miforge_shop_providers.upsert({ + where: { + uniq_method_provider: { + method: "PIX", + provider: "MERCADO_PAGO" + } + }, + create: { + method: "PIX", + provider: "MERCADO_PAGO", + name: "Mercado Pago PIX" + }, + update: {} + }) + for (const config of server_configs) { const existing = await prisma.server_config.findUnique({ where: { diff --git a/apps/api/src/application/services/index.ts b/apps/api/src/application/services/index.ts index b5b0038..09a2b84 100644 --- a/apps/api/src/application/services/index.ts +++ b/apps/api/src/application/services/index.ts @@ -5,8 +5,10 @@ export * from "./accountTwoFactor"; export * from "./audit"; export * from "./config"; export * from "./lostAccount"; +export * from "./mercadoPago"; export * from "./players"; export * from "./recoveryKey"; export * from "./session"; +export * from "./shop"; export * from "./tibiaclient"; export * from "./worlds"; diff --git a/apps/api/src/application/services/mercadoPago/index.ts b/apps/api/src/application/services/mercadoPago/index.ts new file mode 100644 index 0000000..a84df61 --- /dev/null +++ b/apps/api/src/application/services/mercadoPago/index.ts @@ -0,0 +1,126 @@ +import { ORPCError } from "@orpc/client"; +import { + ShopProvider, + ShopProviderMethod, + ShopTransactionStatus, +} from "generated/client"; +import { inject, injectable } from "tsyringe"; +import { Catch } from "@/application/decorators/Catch"; +import type { MercadoPagoClient } from "@/domain/clients"; +import type { ExecutionContext } from "@/domain/context"; +import type { + AccountRepository, + ShopProvidersRepository, + ShopServicesRepository, + ShopTransactionsRepository, +} from "@/domain/repositories"; +import { TOKENS } from "@/infra/di/tokens"; +import type { ShopOrder } from "@/shared/schemas/ShopOrder"; +import { centsToFloat } from "@/shared/utils/money"; + +@injectable() +export class MercadoPagoService { + constructor( + @inject(TOKENS.ShopServicesRepository) + private readonly shopServicesRepository: ShopServicesRepository, + @inject(TOKENS.ShopTransactionsRepository) + private readonly shopTransactionsRepository: ShopTransactionsRepository, + @inject(TOKENS.ExecutionContext) + private readonly executionContext: ExecutionContext, + @inject(TOKENS.AccountRepository) + private readonly accountRepository: AccountRepository, + @inject(TOKENS.MercadoPagoClient) + private readonly mercadoPagoClient: MercadoPagoClient, + @inject(TOKENS.ShopProvidersRepository) + private readonly shopProvidersRepository: ShopProvidersRepository, + ) {} + + @Catch() + async createPixPayment( + serviceId: number, + providerMethod: ShopProviderMethod, + ): Promise { + const session = this.executionContext.session(); + + const account = await this.accountRepository.findByEmail(session.email); + + if (!account) { + throw new ORPCError("NOT_FOUND", { + message: `Account with email '${session.email}' not found.`, + }); + } + + const provider = await this.shopProvidersRepository.findByMethodAndProvider( + providerMethod, + "MERCADO_PAGO", + ); + + if (!provider || !provider.isActive) { + throw new ORPCError("NOT_FOUND", { + message: `Shop provider with method '${providerMethod}' not found. For provider 'MERCADO_PAGO'.`, + }); + } + + const service = await this.shopServicesRepository.findById(serviceId); + + if (!service || !service.is_active) { + throw new ORPCError("NOT_FOUND", { + message: `Shop service with id '${serviceId}' not found.`, + }); + } + + const totalCents = service.unit_price * service.quantity; + const totalAmount = (totalCents / 100).toFixed(2); + + const transaction = await this.shopTransactionsRepository.createTransaction( + { + accountId: account.id, + providerId: provider.id, + serviceId: service.id, + total: totalCents, + units: 1, + }, + ); + + const pix = await this.mercadoPagoClient.createPayment({ + x_idempotency_key: `${transaction.id}`, + external_reference: `${transaction.id}`, + payment_method_id: "pix", + installments: 1, + payer: { + email: account.email, + }, + transaction_amount: Number(totalAmount), + description: `Purchase of ${service.slug} in MiForge Store`, + }); + + if ( + !pix.point_of_interaction.transaction_data.qr_code || + !pix.point_of_interaction.transaction_data.qr_code_base64 + ) { + throw new ORPCError("PAYMENT_CREATION_FAILED", { + message: `Failed to create PIX payment for transaction '${transaction.id}'.`, + }); + } + + await this.shopTransactionsRepository.attachMethodTransactionId( + transaction.id, + pix.id.toString(), + ); + + return { + id: transaction.id, + price: centsToFloat(transaction.total), + status: ShopTransactionStatus.PENDING, + method: ShopProviderMethod.PIX, + provider: ShopProvider.MERCADO_PAGO, + interaction: { + transaction: { + qr_code: pix.point_of_interaction.transaction_data.qr_code, + qr_code_base64: + pix.point_of_interaction.transaction_data.qr_code_base64, + }, + }, + }; + } +} diff --git a/apps/api/src/application/services/shop/index.ts b/apps/api/src/application/services/shop/index.ts new file mode 100644 index 0000000..d8f00aa --- /dev/null +++ b/apps/api/src/application/services/shop/index.ts @@ -0,0 +1,71 @@ +import { ORPCError } from "@orpc/client"; +import { ShopProvider, ShopProviderMethod } from "generated/client"; +import { inject, injectable } from "tsyringe"; +import type { + ShopProvidersRepository, + ShopServicesRepository, +} from "@/domain/repositories"; +import { TOKENS } from "@/infra/di/tokens"; +import { centsToFloat } from "@/shared/utils/money"; +import type { MercadoPagoService } from "../mercadoPago"; + +@injectable() +export class ShopService { + constructor( + @inject(TOKENS.ShopServicesRepository) + private readonly shopServicesRepository: ShopServicesRepository, + @inject(TOKENS.ShopProvidersRepository) + private readonly shopProvidersRepository: ShopProvidersRepository, + @inject(TOKENS.MercadoPagoService) + private readonly mercadoPagoService: MercadoPagoService, + ) {} + + async getAllServices() { + const services = await this.shopServicesRepository.listAll(); + + return services.map((service) => ({ + ...service, + unit_price: centsToFloat(service.unit_price), + })); + } + + async getAllProviders() { + const providers = await this.shopProvidersRepository.findAll(); + + return providers; + } + + async createOrder(data: { serviceId: number; providerId: number }) { + const service = await this.shopServicesRepository.findById(data.serviceId); + const provider = await this.shopProvidersRepository.findById( + data.providerId, + ); + + if (!service || !provider) { + throw new ORPCError("NOT_FOUND", { + message: "Service or Provider not found", + }); + } + + const paymentMethod = provider.method; + const paymentProvider = provider.provider; + + if (paymentProvider === ShopProvider.MERCADO_PAGO) { + switch (paymentMethod) { + case ShopProviderMethod.PIX: + return this.mercadoPagoService.createPixPayment( + service.id, + paymentMethod, + ); + default: + throw new ORPCError("NOT_IMPLEMENTED", { + message: `Payment method ${paymentMethod} not implemented for provider ${paymentProvider}`, + }); + } + } + + throw new ORPCError("NOT_IMPLEMENTED", { + message: `Payment provider ${paymentProvider} not implemented`, + }); + } +} diff --git a/apps/api/src/application/usecases/factory.ts b/apps/api/src/application/usecases/factory.ts index 342be4b..7e7fb68 100644 --- a/apps/api/src/application/usecases/factory.ts +++ b/apps/api/src/application/usecases/factory.ts @@ -219,4 +219,16 @@ export class UseCasesFactory { update, } as const; } + + get shop() { + const services = this.di.resolve(TOKENS.ShopServicesUseCase); + const providers = this.di.resolve(TOKENS.ShopProvidersUserCase); + const orderCreate = this.di.resolve(TOKENS.ShopCreateOrderUseCase); + + return { + services, + providers, + orderCreate, + } as const; + } } diff --git a/apps/api/src/application/usecases/index.ts b/apps/api/src/application/usecases/index.ts index aa8e4bf..769d69e 100644 --- a/apps/api/src/application/usecases/index.ts +++ b/apps/api/src/application/usecases/index.ts @@ -3,5 +3,6 @@ export * from "./config"; export * from "./lostAccount"; export * from "./players"; export * from "./session"; +export * from "./shop"; export * from "./tibia"; export * from "./worlds"; diff --git a/apps/api/src/application/usecases/shop/createOrder/contract.ts b/apps/api/src/application/usecases/shop/createOrder/contract.ts new file mode 100644 index 0000000..8716694 --- /dev/null +++ b/apps/api/src/application/usecases/shop/createOrder/contract.ts @@ -0,0 +1,18 @@ +import z from "zod"; +import { ShopOrder } from "@/shared/schemas/ShopOrder"; + +export const ShopCreateOrderContractSchema = { + input: z.object({ + serviceId: z.number(), + providerId: z.number(), + }), + output: ShopOrder, +}; + +export type ShopCreateOrderContractInput = z.infer< + typeof ShopCreateOrderContractSchema.input +>; + +export type ShopCreateOrderContractOutput = z.infer< + typeof ShopCreateOrderContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/createOrder/index.ts b/apps/api/src/application/usecases/shop/createOrder/index.ts new file mode 100644 index 0000000..f705359 --- /dev/null +++ b/apps/api/src/application/usecases/shop/createOrder/index.ts @@ -0,0 +1,28 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopCreateOrderContractInput, + ShopCreateOrderContractOutput, +} from "./contract"; + +@injectable() +export class ShopCreateOrderUseCase + implements + UseCase +{ + constructor( + @inject(TOKENS.ShopService) + private readonly shopService: ShopService, + ) {} + + execute( + input: ShopCreateOrderContractInput, + ): Promise { + return this.shopService.createOrder({ + providerId: input.providerId, + serviceId: input.serviceId, + }); + } +} diff --git a/apps/api/src/application/usecases/shop/index.ts b/apps/api/src/application/usecases/shop/index.ts new file mode 100644 index 0000000..614843b --- /dev/null +++ b/apps/api/src/application/usecases/shop/index.ts @@ -0,0 +1,3 @@ +export * from "./createOrder"; +export * from "./providers"; +export * from "./services"; diff --git a/apps/api/src/application/usecases/shop/providers/contract.ts b/apps/api/src/application/usecases/shop/providers/contract.ts new file mode 100644 index 0000000..9834c84 --- /dev/null +++ b/apps/api/src/application/usecases/shop/providers/contract.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +import { ShopProvider } from "@/shared/schemas/ShopProvider"; + +export const ShopProvidersContractSchema = { + input: z.unknown(), + output: z.array(ShopProvider), +}; + +export type ShopProvidersContractInput = z.infer< + typeof ShopProvidersContractSchema.input +>; +export type ShopProvidersContractOutput = z.infer< + typeof ShopProvidersContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/providers/index.ts b/apps/api/src/application/usecases/shop/providers/index.ts new file mode 100644 index 0000000..d1f831c --- /dev/null +++ b/apps/api/src/application/usecases/shop/providers/index.ts @@ -0,0 +1,23 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopProvidersContractInput, + ShopProvidersContractOutput, +} from "./contract"; + +@injectable() +export class ShopProvidersUserCase + implements UseCase +{ + constructor( + @inject(TOKENS.ShopService) private readonly shopService: ShopService, + ) {} + + execute( + _input: ShopProvidersContractInput, + ): Promise { + return this.shopService.getAllProviders(); + } +} diff --git a/apps/api/src/application/usecases/shop/services/contract.ts b/apps/api/src/application/usecases/shop/services/contract.ts new file mode 100644 index 0000000..ebdef4d --- /dev/null +++ b/apps/api/src/application/usecases/shop/services/contract.ts @@ -0,0 +1,14 @@ +import z from "zod"; +import { ShopService } from "@/shared/schemas/ShopService"; + +export const ShopServicesContractSchema = { + input: z.unknown(), + output: z.array(ShopService), +}; + +export type ShopServicesContractInput = z.infer< + typeof ShopServicesContractSchema.input +>; +export type ShopServicesContractOutput = z.infer< + typeof ShopServicesContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/services/index.ts b/apps/api/src/application/usecases/shop/services/index.ts new file mode 100644 index 0000000..fed85a5 --- /dev/null +++ b/apps/api/src/application/usecases/shop/services/index.ts @@ -0,0 +1,23 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopServicesContractInput, + ShopServicesContractOutput, +} from "./contract"; + +@injectable() +export class ShopServicesUseCase + implements UseCase +{ + constructor( + @inject(TOKENS.ShopService) private readonly shopService: ShopService, + ) {} + + async execute( + _input: ShopServicesContractInput, + ): Promise { + return this.shopService.getAllServices(); + } +} diff --git a/apps/api/src/domain/clients/index.ts b/apps/api/src/domain/clients/index.ts index 599687e..d08e246 100644 --- a/apps/api/src/domain/clients/index.ts +++ b/apps/api/src/domain/clients/index.ts @@ -2,6 +2,7 @@ export * from "./discord"; export * from "./discord/api"; export * from "./http"; export * from "./mailer"; +export * from "./mercadopago"; export * from "./otsServer"; export * from "./prisma"; export * from "./redis"; diff --git a/apps/api/src/domain/clients/mercadopago/index.ts b/apps/api/src/domain/clients/mercadopago/index.ts new file mode 100644 index 0000000..90a0028 --- /dev/null +++ b/apps/api/src/domain/clients/mercadopago/index.ts @@ -0,0 +1,55 @@ +import { inject, injectable } from "tsyringe"; +import { TOKENS } from "@/infra/di/tokens"; +import { env } from "@/infra/env"; +import type { HttpClient } from "../http"; +import type { + MPCreatePaymentResponse, + MPCreatePixPayment, + MPCreatePreferenceInput, + MPGetPaymentResponse, +} from "./types"; + +@injectable() +export class MercadoPagoClient { + constructor( + @inject(TOKENS.MercadoPagoHttpClient) + private readonly httpClient: HttpClient, + ) {} + + async createPreference(input: MPCreatePreferenceInput) { + const response = await this.httpClient.post("/checkout/preferences", input); + + return response.data; + } + + async getPaymentById(id: number) { + const response = await this.httpClient.get( + `/v1/payments/${id}`, + ); + + return response.data; + } + + async createPayment( + input: Omit, + ): Promise { + const { x_idempotency_key, ...rest } = input; + + const inputWithNotification = { + ...rest, + notification_url: env.MERCADO_PAGO_WEBHOOK_URL, + }; + + const response = await this.httpClient.post( + "/v1/payments", + inputWithNotification, + { + headers: { + "X-Idempotency-Key": x_idempotency_key, + }, + }, + ); + + return response.data; + } +} diff --git a/apps/api/src/domain/clients/mercadopago/types.ts b/apps/api/src/domain/clients/mercadopago/types.ts new file mode 100644 index 0000000..e8254fb --- /dev/null +++ b/apps/api/src/domain/clients/mercadopago/types.ts @@ -0,0 +1,88 @@ +export type MPItem = { + title: string; + description?: string; + quantity: number; + currency_id: "BRL"; + unit_price: number; +}; + +export type MPCreatePreferenceInput = { + external_reference: string; + items: MPItem[]; + back_urls: { + success: string; + failure: string; + pending: string; + }; + notification_url: string; + payer?: { + email?: string; + name?: string; + }; +}; + +type MPStatus = + | "pending" + | "approved" + | "authorized" + | "in_process" + | "in_mediation" + | "rejected" + | "cancelled" + | "refunded" + | "charged_back"; + +export type MPCreatePixPayment = { + x_idempotency_key: string; + external_reference: string; + payment_method_id: "pix"; + notification_url: string; + status?: MPStatus; + payer: { + email: string; + first_name?: string; + last_name?: string; + }; + installments: 1; + transaction_amount: number; + description?: string; +}; + +export type MPCreatePaymentResponse = { + id: number; + external_reference: string; + date_created: string; + date_approved: string | null; + date_last_updated: string; + date_of_expiration: string | null; + operation_type: + | "investment" + | "regular_payment" + | "money_transfer" + | "recurring_payment" + | "account_fund" + | "payment_addition" + | "cellphone_recharge" + | "pos_payment" + | "money_exchange"; + payment_type_id: + | "ticket" + | "bank_transfer" + | "atm" + | "credit_card" + | "debit_card" + | "prepaid_card" + | "digital_currency" + | "digital_wallet" + | "voucher_card" + | "crypto_transfer"; + status: MPStatus; + point_of_interaction: { + transaction_data: { + qr_code: string | null; + qr_code_base64: string | null; + }; + }; +}; + +export type MPGetPaymentResponse = MPCreatePaymentResponse; diff --git a/apps/api/src/domain/repositories/index.ts b/apps/api/src/domain/repositories/index.ts index 29b2dd4..bacc93a 100644 --- a/apps/api/src/domain/repositories/index.ts +++ b/apps/api/src/domain/repositories/index.ts @@ -10,4 +10,7 @@ export * from "./live"; export * from "./otsServer"; export * from "./players"; export * from "./session"; +export * from "./shopProviders"; +export * from "./shopServices"; +export * from "./shopTransactions"; export * from "./worlds"; diff --git a/apps/api/src/domain/repositories/shopProviders/index.ts b/apps/api/src/domain/repositories/shopProviders/index.ts new file mode 100644 index 0000000..0cac363 --- /dev/null +++ b/apps/api/src/domain/repositories/shopProviders/index.ts @@ -0,0 +1,30 @@ +import type { ShopProvider, ShopProviderMethod } from "generated/client"; +import { inject, injectable } from "tsyringe"; +import type { Prisma } from "@/domain/clients"; +import { TOKENS } from "@/infra/di/tokens"; + +@injectable() +export class ShopProvidersRepository { + constructor(@inject(TOKENS.Prisma) private readonly database: Prisma) {} + + findById(id: number) { + return this.database.miforge_shop_providers.findUnique({ + where: { id }, + }); + } + + findByMethodAndProvider(method: ShopProviderMethod, provider: ShopProvider) { + return this.database.miforge_shop_providers.findUnique({ + where: { + uniq_method_provider: { + method, + provider, + }, + }, + }); + } + + findAll() { + return this.database.miforge_shop_providers.findMany(); + } +} diff --git a/apps/api/src/domain/repositories/shopServices/index.ts b/apps/api/src/domain/repositories/shopServices/index.ts new file mode 100644 index 0000000..1c75f14 --- /dev/null +++ b/apps/api/src/domain/repositories/shopServices/index.ts @@ -0,0 +1,28 @@ +import { inject, injectable } from "tsyringe"; +import type { Prisma } from "@/domain/clients"; +import { TOKENS } from "@/infra/di/tokens"; + +@injectable() +export class ShopServicesRepository { + constructor(@inject(TOKENS.Prisma) private readonly database: Prisma) {} + + async listAll() { + return this.database.miforge_shop_service.findMany(); + } + + async findById(id: number) { + return this.database.miforge_shop_service.findUnique({ + where: { + id, + }, + }); + } + + async findBySlug(slug: string) { + return this.database.miforge_shop_service.findUnique({ + where: { + slug, + }, + }); + } +} diff --git a/apps/api/src/domain/repositories/shopTransactions/index.ts b/apps/api/src/domain/repositories/shopTransactions/index.ts new file mode 100644 index 0000000..ad150bc --- /dev/null +++ b/apps/api/src/domain/repositories/shopTransactions/index.ts @@ -0,0 +1,41 @@ +import type { ShopTransactionStatus } from "generated/client"; +import { inject, injectable } from "tsyringe"; +import type { Prisma } from "@/domain/clients"; +import { TOKENS } from "@/infra/di/tokens"; + +@injectable() +export class ShopTransactionsRepository { + constructor(@inject(TOKENS.Prisma) private readonly database: Prisma) {} + + async createTransaction(data: { + serviceId: number; + accountId: number; + providerId: number; + status?: ShopTransactionStatus; + units?: number; + total?: number; + }) { + return this.database.miforge_shop_transactions.create({ + data: { + status: data.status ?? "PENDING", + providerId: data.providerId, + accountId: data.accountId, + serviceId: data.serviceId, + total: data.total ?? 0, + units: data.units ?? 1, + }, + }); + } + + async attachMethodTransactionId( + transactionId: number, + methodTransactionId: string, + ) { + return this.database.miforge_shop_transactions.update({ + where: { id: transactionId }, + data: { + methodTransactionId: methodTransactionId, + }, + }); + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f768b0f..4c2f7e6 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,11 @@ if (import.meta.main) { bootstrapDiscord(); bootstrapJobs(); + const configRepository = container.resolve(TOKENS.ConfigRepository); + + await configRepository.updateConfig({}); + logger.info("[Config]: Configuration initialized/updated"); + const app = appFactory(); const server = Bun.serve({ diff --git a/apps/api/src/infra/di/containers/clients.ts b/apps/api/src/infra/di/containers/clients.ts index 6b75b95..9f8033c 100644 --- a/apps/api/src/infra/di/containers/clients.ts +++ b/apps/api/src/infra/di/containers/clients.ts @@ -2,6 +2,7 @@ import { container, Lifecycle } from "tsyringe"; import { DiscordApiClient, HttpClient, + MercadoPagoClient, OtsServerClient, } from "@/domain/clients"; import { env } from "@/infra/env"; @@ -28,6 +29,24 @@ export function registerClients() { }, }); + container.register(TOKENS.MercadoPagoHttpClient, { + useFactory: (c) => { + const logger = c.resolve(TOKENS.Logger); + return new HttpClient(logger, { + baseURL: env.MERCADO_PAGO_BASE_URL, + headers: { + Authorization: `Bearer ${env.MERCADO_PAGO_ACCESS_TOKEN}`, + }, + }); + }, + }); + + container.register( + TOKENS.MercadoPagoClient, + { useClass: MercadoPagoClient }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( TOKENS.DiscordApiClient, { useClass: DiscordApiClient }, diff --git a/apps/api/src/infra/di/containers/repositories.ts b/apps/api/src/infra/di/containers/repositories.ts index a5507b8..ec4dbd7 100644 --- a/apps/api/src/infra/di/containers/repositories.ts +++ b/apps/api/src/infra/di/containers/repositories.ts @@ -10,6 +10,9 @@ import { OtsServerRepository, PlayersRepository, SessionRepository, + ShopProvidersRepository, + ShopServicesRepository, + ShopTransactionsRepository, } from "@/domain/repositories"; import { WorldsRepository } from "@/domain/repositories/worlds"; import { TOKENS } from "../tokens"; @@ -67,6 +70,21 @@ export function registerRepositories() { { useClass: AccountOauthRepository }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopServicesRepository, + { useClass: ShopServicesRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopTransactionsRepository, + { useClass: ShopTransactionsRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopProvidersRepository, + { useClass: ShopProvidersRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); // Repositories with singleton lifecycle container.register( diff --git a/apps/api/src/infra/di/containers/services.ts b/apps/api/src/infra/di/containers/services.ts index 5d79629..f71cb42 100644 --- a/apps/api/src/infra/di/containers/services.ts +++ b/apps/api/src/infra/di/containers/services.ts @@ -7,9 +7,11 @@ import { AuditService, ConfigService, LostAccountService, + MercadoPagoService, PlayersService, RecoveryKeyService, SessionService, + ShopService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -76,4 +78,15 @@ export function registerServices() { { useClass: AccountOauthService }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopService, + { useClass: ShopService }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + + container.register( + TOKENS.MercadoPagoService, + { useClass: MercadoPagoService }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/containers/usecases.ts b/apps/api/src/infra/di/containers/usecases.ts index 1b96a30..8976fc4 100644 --- a/apps/api/src/infra/di/containers/usecases.ts +++ b/apps/api/src/infra/di/containers/usecases.ts @@ -43,6 +43,9 @@ import { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopCreateOrderUseCase, + ShopProvidersUserCase, + ShopServicesUseCase, TibiaLoginUseCase, WorldsListUseCase, } from "@/application/usecases"; @@ -287,4 +290,20 @@ export function registerUseCases() { { useClass: AccountDiscordOauthUnlinkUseCase }, { lifecycle: Lifecycle.ResolutionScoped }, ); + + container.register( + TOKENS.ShopServicesUseCase, + { useClass: ShopServicesUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopProvidersUserCase, + { useClass: ShopProvidersUserCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopCreateOrderUseCase, + { useClass: ShopCreateOrderUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/tokens.ts b/apps/api/src/infra/di/tokens.ts index c0d5d17..8284390 100644 --- a/apps/api/src/infra/di/tokens.ts +++ b/apps/api/src/infra/di/tokens.ts @@ -8,9 +8,11 @@ import type { AuditService, ConfigService, LostAccountService, + MercadoPagoService, PlayersService, RecoveryKeyService, SessionService, + ShopService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -58,6 +60,9 @@ import type { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopCreateOrderUseCase, + ShopProvidersUserCase, + ShopServicesUseCase, TibiaLoginUseCase, WorldsListUseCase, } from "@/application/usecases"; @@ -79,6 +84,7 @@ import type { DiscordClient, HttpClient, Mailer, + MercadoPagoClient, OtsServerClient, Prisma, Redis, @@ -114,6 +120,9 @@ import type { OtsServerRepository, PlayersRepository, SessionRepository, + ShopProvidersRepository, + ShopServicesRepository, + ShopTransactionsRepository, } from "@/domain/repositories"; import type { WorldsRepository } from "@/domain/repositories/worlds"; import type { EmailQueue } from "@/jobs/queue/email"; @@ -174,6 +183,15 @@ const REPOSITORIES_TOKENS = { AccountOauthRepository: token( "AccountOauthRepository", ), + ShopServicesRepository: token( + "ShopServicesRepository", + ), + ShopTransactionsRepository: token( + "ShopTransactionsRepository", + ), + ShopProvidersRepository: token( + "ShopProvidersRepository", + ), }; const UTILS_TOKENS = { @@ -321,6 +339,11 @@ const USECASES_TOKENS = { PlayerOutfitUseCase: token("PlayerOutfitUseCase"), PlayerOutfitsUseCase: token("PlayerOutfitsUseCase"), TibiaLoginUseCase: token("TibiaLoginUseCase"), + ShopServicesUseCase: token("ShopServicesUseCase"), + ShopProvidersUserCase: token("ShopProvidersUserCase"), + ShopCreateOrderUseCase: token( + "ShopCreateOrderUseCase", + ), }; const SERVICES_TOKENS = { @@ -340,6 +363,8 @@ const SERVICES_TOKENS = { LostAccountService: token("LostAccountService"), RecoveryKeyService: token("RecoveryKeyService"), AccountOauthService: token("AccountOauthService"), + ShopService: token("ShopService"), + MercadoPagoService: token("MercadoPagoService"), }; const QUEUE_AND_WORKERS_TOKENS = { @@ -357,6 +382,8 @@ const CLIENTS_TOKENS = { AppLivePublisher: token("AppLivePublisher"), OtsServerClient: token("OtsServerClient"), HttpClient: token("HttpClient"), + MercadoPagoHttpClient: token("MercadoPagoHttpClient"), + MercadoPagoClient: token("MercadoPagoClient"), }; export const TOKENS = { diff --git a/apps/api/src/infra/env/index.ts b/apps/api/src/infra/env/index.ts index bcd79c8..2869d7a 100644 --- a/apps/api/src/infra/env/index.ts +++ b/apps/api/src/infra/env/index.ts @@ -91,6 +91,17 @@ const DISCORD_CONFIG_SCHEMA = z.object({ DISCORD_API_URL: z.url().default("https://discord.com"), }); +const MERCADO_PAGO_CONFIG_SCHEMA = z.object({ + MERCADO_PAGO_ENABLED: z + .string() + .transform((val) => val === "true" || val === "1") + .default(false), + MERCADO_PAGO_BASE_URL: z.url().default("https://api.mercadopago.com"), + MERCADO_PAGO_ACCESS_TOKEN: z.string().optional(), + MERCADO_PAGO_WEBHOOK_URL: z.url().optional(), + MERCADO_PAGO_WEBHOOK_SECRET: z.string().optional(), +}); + const envSchema = z.object({ ...FRONTEND_CONFIG_SCHEMA.shape, ...SERVER_CONFIG_SCHEMA.shape, @@ -100,6 +111,7 @@ const envSchema = z.object({ ...MAILER_CONFIG_SCHEMA.shape, ...OUTFIT_CONFIG_SCHEMA.shape, ...DISCORD_CONFIG_SCHEMA.shape, + ...MERCADO_PAGO_CONFIG_SCHEMA.shape, LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), SERVICE_NAME: z.string().default("miforge-api"), PORT: z.coerce.number().default(4000), @@ -172,4 +184,22 @@ export const env = envSchema } } }) + .superRefine((env, ctx) => { + if (!env.MERCADO_PAGO_ENABLED) return; + + const requiredFields: (keyof typeof env)[] = [ + "MERCADO_PAGO_ACCESS_TOKEN", + "MERCADO_PAGO_WEBHOOK_URL", + "MERCADO_PAGO_WEBHOOK_SECRET", + ]; + + for (const field of requiredFields) { + if (!env[field]) { + ctx.addIssue({ + code: "custom", + message: `${field} is required when MERCADO_PAGO_ENABLED is true`, + }); + } + } + }) .parse(process.env); diff --git a/apps/api/src/presentation/v1/routes/index.ts b/apps/api/src/presentation/v1/routes/index.ts index e55021c..e8ff000 100644 --- a/apps/api/src/presentation/v1/routes/index.ts +++ b/apps/api/src/presentation/v1/routes/index.ts @@ -4,8 +4,10 @@ import { clientRouter } from "./client"; import { configRouter } from "./config"; import { lostAccountRouter } from "./lost"; import { outfitRouter } from "./outfit"; +import { paymentsRouter } from "./payments"; import { pingRoute } from "./ping"; import { sessionRouter } from "./session"; +import { shopRouter } from "./shop"; import { worldsRouter } from "./worlds"; export const router = base.router({ @@ -17,4 +19,6 @@ export const router = base.router({ config: configRouter, outfit: outfitRouter, lost: lostAccountRouter, + payments: paymentsRouter, + shop: shopRouter, }); diff --git a/apps/api/src/presentation/v1/routes/payments/index.ts b/apps/api/src/presentation/v1/routes/payments/index.ts new file mode 100644 index 0000000..7ca0de4 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/payments/index.ts @@ -0,0 +1,6 @@ +import { base } from "@/infra/rpc/base"; +import { mercadoPagoRouter } from "./mercadopago"; + +export const paymentsRouter = base.tag("Payments").prefix("/payments").router({ + mercadopago: mercadoPagoRouter, +}); diff --git a/apps/api/src/presentation/v1/routes/payments/mercadopago/index.ts b/apps/api/src/presentation/v1/routes/payments/mercadopago/index.ts new file mode 100644 index 0000000..3d1e043 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/payments/mercadopago/index.ts @@ -0,0 +1,8 @@ +import { base } from "@/infra/rpc/base"; +import { mercadopagoPaymentRoute } from "./payment"; +import { mercadopagoWebhookRoute } from "./webhook"; + +export const mercadoPagoRouter = base.prefix("/mercadopago").router({ + webhook: mercadopagoWebhookRoute, + payment: mercadopagoPaymentRoute, +}); diff --git a/apps/api/src/presentation/v1/routes/payments/mercadopago/payment/index.ts b/apps/api/src/presentation/v1/routes/payments/mercadopago/payment/index.ts new file mode 100644 index 0000000..12323e2 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/payments/mercadopago/payment/index.ts @@ -0,0 +1,12 @@ +import { publicProcedure } from "@/presentation/procedures/public"; + +export const mercadopagoPaymentRoute = publicProcedure + .route({ + method: "POST", + path: "/payment", + summary: "Create MercadoPago Payment", + description: "Endpoint to create a test payment in MercadoPago.", + }) + .handler(async ({ input, context }) => { + return { message: "Payment test endpoint" }; + }); diff --git a/apps/api/src/presentation/v1/routes/payments/mercadopago/test/index.ts b/apps/api/src/presentation/v1/routes/payments/mercadopago/test/index.ts new file mode 100644 index 0000000..346bf78 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/payments/mercadopago/test/index.ts @@ -0,0 +1,31 @@ +// import { TOKENS } from "@/infra/di/tokens"; + +// import { publicProcedure } from "@/presentation/procedures/public"; + +// export const mercadopagoPaymentTestRoute = publicProcedure +// .route({ +// method: "POST", +// path: "/payment-test", +// summary: "MercadoPago Webhook", +// description: +// "Endpoint to receive notifications from MercadoPago about payment events.", +// }) +// .handler(async ({ input, context }) => { +// const mercadoPagoClient = context.di.resolve(TOKENS.MercadoPagoClient); + +// const response = await mercadoPagoClient.createPayment({ +// x_idempotency_key: "TEST_PIX_KEY_6", +// external_reference: "TEST_PIX_6", +// payment_method_id: "pix", +// installments: 1, +// notification_url: +// "https://05c9e00e89ac.ngrok-free.app/v1/payments/mercadopago/webhook", +// payer: { +// email: "test@example.com", +// }, +// transaction_amount: 1.0, +// description: "Test PIX Payment", +// }); + +// return response; +// }); diff --git a/apps/api/src/presentation/v1/routes/payments/mercadopago/webhook/index.ts b/apps/api/src/presentation/v1/routes/payments/mercadopago/webhook/index.ts new file mode 100644 index 0000000..268e2b8 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/payments/mercadopago/webhook/index.ts @@ -0,0 +1,127 @@ +import crypto from "node:crypto"; +import z from "zod"; +import { TOKENS } from "@/infra/di/tokens"; +import { env } from "@/infra/env"; +import { publicProcedure } from "@/presentation/procedures/public"; + +export function validateMpSignatureFromRequest( + xSignature: string | null, + xRequestId: string | null, + dataId: string | null, +): boolean { + if (!xSignature || !xRequestId || !dataId) return false; + if (!env.MERCADO_PAGO_WEBHOOK_SECRET) return false; + + const parts = xSignature.split(","); + let ts: string | undefined; + let hash: string | undefined; + + for (const part of parts) { + const [rawKey, rawValue] = part.split("="); + if (!rawKey || !rawValue) continue; + + const key = rawKey.trim(); + const value = rawValue.trim(); + + if (key === "ts") ts = value; + if (key === "v1") hash = value; + } + + if (!ts || !hash) return false; + + const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`; + + const sha = crypto + .createHmac("sha256", env.MERCADO_PAGO_WEBHOOK_SECRET) + .update(manifest) + .digest("hex"); + + return crypto.timingSafeEqual(Buffer.from(sha), Buffer.from(hash)); +} + +const MPSchemaMerchantOrder = z.object({ + query: z.object({ + id: z.coerce.string(), + topic: z.literal("merchant_order"), + }), + body: z.object({ + resource: z.url(), + topic: z.literal("merchant_order"), + }), +}); + +const MPSchemaOthers = z.object({ + query: z.object({ + "data.id": z.string(), + type: z.enum([ + "payment", + "stop_delivery_op_wh", + "delivery_cancellation", + "topic_claims_integration_wh", + "topic_chargebacks_wh", + ]), + }), + body: z.object({ + action: z.string().optional(), + api_version: z.string().optional(), + application_id: z.string().optional(), + id: z.coerce.string().optional(), + live_mode: z.boolean().optional(), + type: z.string().optional(), + user_id: z.coerce.string().optional(), + data: z + .object({ + id: z.string().optional(), + }) + .optional(), + }), +}); + +const Schema = { + input: z.union([MPSchemaMerchantOrder, MPSchemaOthers]), + output: z.object({ + ok: z.boolean(), + }), +}; + +export const mercadopagoWebhookRoute = publicProcedure + .route({ + method: "POST", + path: "/webhook", + summary: "MercadoPago Webhook", + inputStructure: "detailed", + description: + "Endpoint to receive notifications from MercadoPago about payment events.", + }) + .input(Schema.input) + .output(Schema.output) + .handler(async ({ input, context }) => { + const httpContext = context.di.resolve(TOKENS.HttpContext); + console.log("input", input); + + const signatureHeader = httpContext.req.header("x-signature") as + | string + | null; + + const requestId = httpContext.req.header("x-request-id") as string | null; + + let paymentId: string | null = null; + + if ("data.id" in input.query) { + paymentId = input.query["data.id"]; + } else if ("id" in input.query) { + paymentId = input.query.id; + } + + const isValid = validateMpSignatureFromRequest( + signatureHeader, + requestId, + paymentId, + ); + + console.log("Received MercadoPago webhook:", isValid); + + return { + ok: true, + }; + }); diff --git a/apps/api/src/presentation/v1/routes/shop/index.ts b/apps/api/src/presentation/v1/routes/shop/index.ts new file mode 100644 index 0000000..fc91538 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/index.ts @@ -0,0 +1,10 @@ +import { base } from "@/infra/rpc/base"; +import { ordersRouter } from "./orders"; +import { providersRoute } from "./providers"; +import { servicesRoute } from "./services"; + +export const shopRouter = base.tag("Shop").prefix("/shop").router({ + services: servicesRoute, + providers: providersRoute, + orders: ordersRouter, +}); diff --git a/apps/api/src/presentation/v1/routes/shop/orders/create/index.ts b/apps/api/src/presentation/v1/routes/shop/orders/create/index.ts new file mode 100644 index 0000000..abbb80b --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orders/create/index.ts @@ -0,0 +1,15 @@ +import { ShopCreateOrderContractSchema } from "@/application/usecases/shop/createOrder/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const createOrderRoute = isAuthenticatedProcedure + .route({ + method: "POST", + path: "/", + summary: "Create Order", + description: "Create a new order", + }) + .input(ShopCreateOrderContractSchema.input) + .output(ShopCreateOrderContractSchema.output) + .handler(async ({ input, context }) => { + return context.usecases.shop.orderCreate.execute(input); + }); diff --git a/apps/api/src/presentation/v1/routes/shop/orders/index.ts b/apps/api/src/presentation/v1/routes/shop/orders/index.ts new file mode 100644 index 0000000..a84977a --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orders/index.ts @@ -0,0 +1,6 @@ +import { base } from "@/infra/rpc/base"; +import { createOrderRoute } from "./create"; + +export const ordersRouter = base.prefix("/orders").router({ + create: createOrderRoute, +}); diff --git a/apps/api/src/presentation/v1/routes/shop/providers/index.ts b/apps/api/src/presentation/v1/routes/shop/providers/index.ts new file mode 100644 index 0000000..54561c4 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/providers/index.ts @@ -0,0 +1,15 @@ +import { ShopProvidersContractSchema } from "@/application/usecases/shop/providers/contract"; +import { publicProcedure } from "@/presentation/procedures/public"; + +export const providersRoute = publicProcedure + .route({ + method: "GET", + path: "/providers", + summary: "Providers", + description: "Get a list of available providers", + }) + .input(ShopProvidersContractSchema.input) + .output(ShopProvidersContractSchema.output) + .handler(async ({ input, context }) => { + return context.usecases.shop.providers.execute(input); + }); diff --git a/apps/api/src/presentation/v1/routes/shop/services/index.ts b/apps/api/src/presentation/v1/routes/shop/services/index.ts new file mode 100644 index 0000000..b24b764 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/services/index.ts @@ -0,0 +1,15 @@ +import { ShopServicesContractSchema } from "@/application/usecases/shop/services/contract"; +import { publicProcedure } from "@/presentation/procedures/public"; + +export const servicesRoute = publicProcedure + .route({ + method: "GET", + path: "/services", + summary: "Services", + description: "Get a list of available services", + }) + .input(ShopServicesContractSchema.input) + .output(ShopServicesContractSchema.output) + .handler(async ({ input, context }) => { + return context.usecases.shop.services.execute(input); + }); diff --git a/apps/api/src/shared/schemas/Config.ts b/apps/api/src/shared/schemas/Config.ts index b916060..1a54939 100644 --- a/apps/api/src/shared/schemas/Config.ts +++ b/apps/api/src/shared/schemas/Config.ts @@ -17,6 +17,13 @@ export const MiforgeConfigSchema = z.object({ .default({ enabled: false, }), + mercado_pago: z + .object({ + enabled: z.boolean().default(false), + }) + .default({ + enabled: false, + }), mailer: z .object({ enabled: z.boolean().default(false), diff --git a/apps/api/src/shared/schemas/ShopOrder.ts b/apps/api/src/shared/schemas/ShopOrder.ts new file mode 100644 index 0000000..fe39019 --- /dev/null +++ b/apps/api/src/shared/schemas/ShopOrder.ts @@ -0,0 +1,22 @@ +import { + ShopProvider, + ShopProviderMethod, + ShopTransactionStatus, +} from "generated/client"; +import z from "zod"; + +export const ShopOrder = z.object({ + id: z.number(), + price: z.number(), + status: z.enum(ShopTransactionStatus), + method: z.enum(ShopProviderMethod), + provider: z.enum(ShopProvider), + interaction: z.object({ + transaction: z.object({ + qr_code: z.string().nullable(), + qr_code_base64: z.string().nullable(), + }), + }), +}); + +export type ShopOrder = z.infer; diff --git a/apps/api/src/shared/schemas/ShopProvider.ts b/apps/api/src/shared/schemas/ShopProvider.ts new file mode 100644 index 0000000..a21349a --- /dev/null +++ b/apps/api/src/shared/schemas/ShopProvider.ts @@ -0,0 +1,23 @@ +import { + ShopProvider as ShopProviderEnum, + ShopProviderMethod as ShopProviderMethodEnum, +} from "generated/client"; +import z from "zod"; + +const ProviderMethod = z.enum(ShopProviderMethodEnum); +const Provider = z.enum(ShopProviderEnum); + +export const ShopProvider = z.object({ + id: z.number().int().nonnegative(), + name: z.string(), + description: z.string().nullable(), + isActive: z.boolean(), + method: ProviderMethod, + provider: Provider, + created_at: z.date(), + updated_at: z.date(), +}); + +export type ShopProvider = z.infer; +export type ShopProviderMethod = z.infer; +export type Provider = z.infer; diff --git a/apps/api/src/shared/schemas/ShopService.ts b/apps/api/src/shared/schemas/ShopService.ts new file mode 100644 index 0000000..42f9a12 --- /dev/null +++ b/apps/api/src/shared/schemas/ShopService.ts @@ -0,0 +1,17 @@ +import { ShopServiceType } from "generated/client"; +import z from "zod"; + +export const ShopService = z.object({ + id: z.number().int().nonnegative(), + type: z.enum(ShopServiceType), + slug: z.string().max(100), + title: z.string().max(255), + description: z.string().nullable(), + unit_price: z.number().nonnegative(), + quantity: z.number().int().nonnegative(), + is_active: z.boolean().default(true), + created_at: z.date(), + updated_at: z.date(), +}); + +export type ShopService = z.infer; diff --git a/apps/api/src/shared/schemas/ShopTransactions.ts b/apps/api/src/shared/schemas/ShopTransactions.ts new file mode 100644 index 0000000..7b0497a --- /dev/null +++ b/apps/api/src/shared/schemas/ShopTransactions.ts @@ -0,0 +1,17 @@ +import { ShopTransactionStatus as ShopTransactionStatusEnum } from "generated/client"; +import z from "zod"; + +export const ShopTransactionStatus = z.enum(ShopTransactionStatusEnum); + +export const ShopTransaction = z.object({ + id: z.int(), + status: ShopTransactionStatus, + methodTransactionId: z.string().max(255).nullable(), + units: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + providerId: z.int(), + serviceId: z.int(), + accountId: z.int(), + createdAt: z.date(), + updatedAt: z.date(), +}); diff --git a/apps/api/src/shared/utils/money.ts b/apps/api/src/shared/utils/money.ts new file mode 100644 index 0000000..2adba93 --- /dev/null +++ b/apps/api/src/shared/utils/money.ts @@ -0,0 +1,3 @@ +export const centsToFloat = (cents: number): number => { + return cents / 100; +}; diff --git a/apps/web/public/assets/icons/32/tibiora_box.gif b/apps/web/public/assets/icons/32/tibiora_box.gif new file mode 100644 index 0000000..2782a2b Binary files /dev/null and b/apps/web/public/assets/icons/32/tibiora_box.gif differ diff --git a/apps/web/public/assets/payments/coins/serviceid_1.png b/apps/web/public/assets/payments/coins/serviceid_1.png new file mode 100644 index 0000000..68fb9b3 Binary files /dev/null and b/apps/web/public/assets/payments/coins/serviceid_1.png differ diff --git a/apps/web/public/assets/payments/coins/serviceid_2.png b/apps/web/public/assets/payments/coins/serviceid_2.png new file mode 100644 index 0000000..b3c1bcb Binary files /dev/null and b/apps/web/public/assets/payments/coins/serviceid_2.png differ diff --git a/apps/web/public/assets/payments/coins/serviceid_3.png b/apps/web/public/assets/payments/coins/serviceid_3.png new file mode 100644 index 0000000..c84246e Binary files /dev/null and b/apps/web/public/assets/payments/coins/serviceid_3.png differ diff --git a/apps/web/public/assets/payments/coins/serviceid_4.png b/apps/web/public/assets/payments/coins/serviceid_4.png new file mode 100644 index 0000000..793e2ac Binary files /dev/null and b/apps/web/public/assets/payments/coins/serviceid_4.png differ diff --git a/apps/web/public/assets/payments/coins/serviceid_5.png b/apps/web/public/assets/payments/coins/serviceid_5.png new file mode 100644 index 0000000..28c1344 Binary files /dev/null and b/apps/web/public/assets/payments/coins/serviceid_5.png differ diff --git a/apps/web/public/assets/payments/coins/serviceid_6.png b/apps/web/public/assets/payments/coins/serviceid_6.png new file mode 100644 index 0000000..c506981 Binary files /dev/null and b/apps/web/public/assets/payments/coins/serviceid_6.png differ diff --git a/apps/web/public/assets/payments/methods/mercado-pago.png b/apps/web/public/assets/payments/methods/mercado-pago.png new file mode 100644 index 0000000..33a2c76 Binary files /dev/null and b/apps/web/public/assets/payments/methods/mercado-pago.png differ diff --git a/apps/web/public/assets/payments/methods/pag-seguro.png b/apps/web/public/assets/payments/methods/pag-seguro.png new file mode 100644 index 0000000..28c8899 Binary files /dev/null and b/apps/web/public/assets/payments/methods/pag-seguro.png differ diff --git a/apps/web/public/assets/payments/methods/payment_icon_hover.webp b/apps/web/public/assets/payments/methods/payment_icon_hover.webp new file mode 100644 index 0000000..1183361 Binary files /dev/null and b/apps/web/public/assets/payments/methods/payment_icon_hover.webp differ diff --git a/apps/web/public/assets/payments/methods/payment_icon_normal.webp b/apps/web/public/assets/payments/methods/payment_icon_normal.webp new file mode 100644 index 0000000..f3c9c11 Binary files /dev/null and b/apps/web/public/assets/payments/methods/payment_icon_normal.webp differ diff --git a/apps/web/public/assets/payments/methods/payment_icon_selected.webp b/apps/web/public/assets/payments/methods/payment_icon_selected.webp new file mode 100644 index 0000000..af62df0 Binary files /dev/null and b/apps/web/public/assets/payments/methods/payment_icon_selected.webp differ diff --git a/apps/web/public/assets/payments/methods/pix.webp b/apps/web/public/assets/payments/methods/pix.webp new file mode 100644 index 0000000..dd6cd7e Binary files /dev/null and b/apps/web/public/assets/payments/methods/pix.webp differ diff --git a/apps/web/public/assets/payments/service/serviceid_deactivated.png b/apps/web/public/assets/payments/service/serviceid_deactivated.png new file mode 100644 index 0000000..b91c921 Binary files /dev/null and b/apps/web/public/assets/payments/service/serviceid_deactivated.png differ diff --git a/apps/web/public/assets/payments/service/serviceid_icon_normal.png b/apps/web/public/assets/payments/service/serviceid_icon_normal.png new file mode 100644 index 0000000..4745481 Binary files /dev/null and b/apps/web/public/assets/payments/service/serviceid_icon_normal.png differ diff --git a/apps/web/public/assets/payments/service/serviceid_icon_over.png b/apps/web/public/assets/payments/service/serviceid_icon_over.png new file mode 100644 index 0000000..daabf00 Binary files /dev/null and b/apps/web/public/assets/payments/service/serviceid_icon_over.png differ diff --git a/apps/web/public/assets/payments/service/serviceid_icon_selected.png b/apps/web/public/assets/payments/service/serviceid_icon_selected.png new file mode 100644 index 0000000..6e2eb79 Binary files /dev/null and b/apps/web/public/assets/payments/service/serviceid_icon_selected.png differ diff --git a/apps/web/src/components/Menu/Item/index.tsx b/apps/web/src/components/Menu/Item/index.tsx index 3ee4908..a5aa3e0 100644 --- a/apps/web/src/components/Menu/Item/index.tsx +++ b/apps/web/src/components/Menu/Item/index.tsx @@ -1,26 +1,36 @@ import { Link, type LinkProps, useRouterState } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { useSession } from "@/sdk/contexts/session"; import { cn } from "@/sdk/utils/cn"; const Icons = { news: "/assets/icons/32/news-menu.gif", sphere: "/assets/icons/32/armillary_sphere.gif", munster: "/assets/icons/32/baby_munster.gif", + shop: "/assets/icons/32/tibiora_box.gif", }; type Props = { label: string; icon: keyof typeof Icons; + needsSession?: boolean; menus: Array<{ label: string; to: LinkProps["to"]; + needsSession?: boolean; new?: boolean; hot?: boolean; }>; }; -export const MenuItem = ({ icon, label, menus = [] }: Props) => { +export const MenuItem = ({ + icon, + label, + menus = [], + needsSession = false, +}: Props) => { const routerState = useRouterState(); + const { session } = useSession(); const [show, setShow] = useState(() => { return menus.some( (subMenu) => subMenu.to === routerState.location.pathname, @@ -34,7 +44,16 @@ export const MenuItem = ({ icon, label, menus = [] }: Props) => { setShow((prev) => (prev ? prev : shouldShow)); }, [routerState.location.pathname, menus]); - const heightTotal = menus.length * 18.8; + const heightTotal = menus.reduce((acc, subMenu) => { + if (subMenu.needsSession && !session) { + return acc; + } + return acc + 18.8; + }, 0); + + if (needsSession && !session) { + return null; + } return (
@@ -75,6 +94,10 @@ export const MenuItem = ({ icon, label, menus = [] }: Props) => { {menus.map((subMenu) => { const isActive = routerState.location.pathname === subMenu.to; + if (subMenu.needsSession && !session) { + return null; + } + return ( { icon="sphere" menus={[{ label: "Updates", to: "/", hot: true }]} /> +
); diff --git a/apps/web/src/components/Payments/CoinsPackage/index.tsx b/apps/web/src/components/Payments/CoinsPackage/index.tsx new file mode 100644 index 0000000..6ead3a6 --- /dev/null +++ b/apps/web/src/components/Payments/CoinsPackage/index.tsx @@ -0,0 +1,57 @@ +import { ServiceItem, type ServiceItemProps } from "../ServiceItem"; + +type PackageSize = "tiny" | "small" | "medium" | "large" | "xlarge" | "mega"; + +const PACKAGE_META: Record = { + tiny: { + label: "Tiny", + icon: "/assets/payments/coins/serviceid_1.png", + }, + small: { + label: "Small", + icon: "/assets/payments/coins/serviceid_2.png", + }, + medium: { + label: "Medium", + icon: "/assets/payments/coins/serviceid_3.png", + }, + large: { + label: "Large", + icon: "/assets/payments/coins/serviceid_4.png", + }, + xlarge: { + label: "X-Large", + icon: "/assets/payments/coins/serviceid_5.png", + }, + mega: { + label: "Mega", + icon: "/assets/payments/coins/serviceid_6.png", + }, +}; + +type Props = Omit & { + amount: number; + size?: PackageSize; +}; + +export const CoinsPackageItem = ({ + size = "mega", + amount = 0, + onClick, + price, + selected, + disabled = false, +}: Props) => { + const icon = PACKAGE_META[size].icon; + + return ( + + ); +}; diff --git a/apps/web/src/components/Payments/PaymentMethod/index.tsx b/apps/web/src/components/Payments/PaymentMethod/index.tsx new file mode 100644 index 0000000..fc1f55d --- /dev/null +++ b/apps/web/src/components/Payments/PaymentMethod/index.tsx @@ -0,0 +1,97 @@ +import type { ShopProviderMethod } from "@miforge/api/shared/schemas/ShopProvider"; +import { cn } from "@/sdk/utils/cn"; + +const METHOD_META: Record< + ShopProviderMethod, + { icon: string; className?: string } +> = { + // "mercado-pago": { + // icon: "/assets/payments/methods/mercado-pago.png", + // }, + PIX: { + icon: "/assets/payments/methods/pix.webp", + className: " mx-auto rounded-md w-[80px]", + }, + // "pag-seguro": { + // icon: "/assets/payments/methods/pag-seguro.png", + // }, +}; + +type PaymentMethodItemProps = { + selected: boolean; + onClick: () => void; + title: string; + speed?: "instant" | "medium" | "slow"; + method: ShopProviderMethod; +}; + +export const PaymentMethodItem = ({ + onClick, + selected, + title, + speed = "instant", + method, +}: PaymentMethodItemProps) => { + const methodMeta = METHOD_META[method]; + + return ( + + ); +}; diff --git a/apps/web/src/components/Payments/ServiceItem/index.tsx b/apps/web/src/components/Payments/ServiceItem/index.tsx new file mode 100644 index 0000000..b9b6266 --- /dev/null +++ b/apps/web/src/components/Payments/ServiceItem/index.tsx @@ -0,0 +1,87 @@ +import { cn } from "@/sdk/utils/cn"; + +export type ServiceItemProps = { + selected: boolean; + onClick: () => void; + title: string; + price: string; + disabled?: boolean; + icon?: React.ReactNode | string; +}; + +export const ServiceItem = ({ + title, + onClick, + price, + selected, + disabled = true, + icon, +}: ServiceItemProps) => { + return ( + + ); +}; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 492f0cf..74127af 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -36,6 +36,9 @@ const Not_authAccountLostIndexLazyRouteImport = createFileRoute( const Not_authAccountCreateIndexLazyRouteImport = createFileRoute( '/_not_auth/account/create/', )() +const AuthShopDonateIndexLazyRouteImport = createFileRoute( + '/_auth/shop/donate/', +)() const AuthAccountReset_passwordIndexLazyRouteImport = createFileRoute( '/_auth/account/reset_password/', )() @@ -138,6 +141,13 @@ const Not_authAccountCreateIndexLazyRoute = } as any).lazy(() => import('./routes/_not_auth/account/create/index.lazy').then((d) => d.Route), ) +const AuthShopDonateIndexLazyRoute = AuthShopDonateIndexLazyRouteImport.update({ + id: '/shop/donate/', + path: '/shop/donate/', + getParentRoute: () => AuthRouteRoute, +} as any).lazy(() => + import('./routes/_auth/shop/donate/index.lazy').then((d) => d.Route), +) const AuthAccountReset_passwordIndexLazyRoute = AuthAccountReset_passwordIndexLazyRouteImport.update({ id: '/account/reset_password/', @@ -354,6 +364,7 @@ export interface FileRoutesByFullPath { '/account/details': typeof AuthAccountDetailsIndexLazyRoute '/account/registration': typeof AuthAccountRegistrationIndexLazyRoute '/account/reset_password': typeof AuthAccountReset_passwordIndexLazyRoute + '/shop/donate': typeof AuthShopDonateIndexLazyRoute '/account/create': typeof Not_authAccountCreateIndexLazyRoute '/account/lost': typeof Not_authAccountLostIndexLazyRoute '/account/email/change/$token': typeof AuthAccountEmailChangeTokenRouteRouteWithChildren @@ -385,6 +396,7 @@ export interface FileRoutesByTo { '/account/details': typeof AuthAccountDetailsIndexLazyRoute '/account/registration': typeof AuthAccountRegistrationIndexLazyRoute '/account/reset_password': typeof AuthAccountReset_passwordIndexLazyRoute + '/shop/donate': typeof AuthShopDonateIndexLazyRoute '/account/create': typeof Not_authAccountCreateIndexLazyRoute '/account/lost': typeof Not_authAccountLostIndexLazyRoute '/account/email/change/$token': typeof AuthAccountEmailChangeTokenRouteRouteWithChildren @@ -421,6 +433,7 @@ export interface FileRoutesById { '/_auth/account/details/': typeof AuthAccountDetailsIndexLazyRoute '/_auth/account/registration/': typeof AuthAccountRegistrationIndexLazyRoute '/_auth/account/reset_password/': typeof AuthAccountReset_passwordIndexLazyRoute + '/_auth/shop/donate/': typeof AuthShopDonateIndexLazyRoute '/_not_auth/account/create/': typeof Not_authAccountCreateIndexLazyRoute '/_not_auth/account/lost/': typeof Not_authAccountLostIndexLazyRoute '/_auth/account/email/change/$token': typeof AuthAccountEmailChangeTokenRouteRouteWithChildren @@ -455,6 +468,7 @@ export interface FileRouteTypes { | '/account/details' | '/account/registration' | '/account/reset_password' + | '/shop/donate' | '/account/create' | '/account/lost' | '/account/email/change/$token' @@ -486,6 +500,7 @@ export interface FileRouteTypes { | '/account/details' | '/account/registration' | '/account/reset_password' + | '/shop/donate' | '/account/create' | '/account/lost' | '/account/email/change/$token' @@ -521,6 +536,7 @@ export interface FileRouteTypes { | '/_auth/account/details/' | '/_auth/account/registration/' | '/_auth/account/reset_password/' + | '/_auth/shop/donate/' | '/_not_auth/account/create/' | '/_not_auth/account/lost/' | '/_auth/account/email/change/$token' @@ -625,6 +641,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Not_authAccountCreateIndexLazyRouteImport parentRoute: typeof Not_authRouteRoute } + '/_auth/shop/donate/': { + id: '/_auth/shop/donate/' + path: '/shop/donate' + fullPath: '/shop/donate' + preLoaderRoute: typeof AuthShopDonateIndexLazyRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/account/reset_password/': { id: '/_auth/account/reset_password/' path: '/account/reset_password' @@ -821,6 +844,7 @@ interface AuthRouteRouteChildren { AuthAccountDetailsIndexLazyRoute: typeof AuthAccountDetailsIndexLazyRoute AuthAccountRegistrationIndexLazyRoute: typeof AuthAccountRegistrationIndexLazyRoute AuthAccountReset_passwordIndexLazyRoute: typeof AuthAccountReset_passwordIndexLazyRoute + AuthShopDonateIndexLazyRoute: typeof AuthShopDonateIndexLazyRoute AuthAccount2faLinkIndexRoute: typeof AuthAccount2faLinkIndexRoute AuthAccount2faUnlinkIndexRoute: typeof AuthAccount2faUnlinkIndexRoute AuthAccountPlayerCreateIndexRoute: typeof AuthAccountPlayerCreateIndexRoute @@ -840,6 +864,7 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthAccountRegistrationIndexLazyRoute: AuthAccountRegistrationIndexLazyRoute, AuthAccountReset_passwordIndexLazyRoute: AuthAccountReset_passwordIndexLazyRoute, + AuthShopDonateIndexLazyRoute: AuthShopDonateIndexLazyRoute, AuthAccount2faLinkIndexRoute: AuthAccount2faLinkIndexRoute, AuthAccount2faUnlinkIndexRoute: AuthAccount2faUnlinkIndexRoute, AuthAccountPlayerCreateIndexRoute: AuthAccountPlayerCreateIndexRoute, diff --git a/apps/web/src/routes/_auth/shop/donate/index.lazy.tsx b/apps/web/src/routes/_auth/shop/donate/index.lazy.tsx new file mode 100644 index 0000000..09c03e9 --- /dev/null +++ b/apps/web/src/routes/_auth/shop/donate/index.lazy.tsx @@ -0,0 +1,10 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { ShopDonateSection } from "@/sections/shop_donate"; + +export const Route = createLazyFileRoute("/_auth/shop/donate/")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/web/src/sdk/hooks/useMoney.ts b/apps/web/src/sdk/hooks/useMoney.ts new file mode 100644 index 0000000..6beb80a --- /dev/null +++ b/apps/web/src/sdk/hooks/useMoney.ts @@ -0,0 +1,61 @@ +import { useCallback, useMemo, useState } from "react"; + +export type FormatterOptions = { + locale?: string; + currency?: string; + // Quando true, o valor é tratado como inteiro em centavos. + cents?: boolean; +}; + +export const formatter = ( + value: number, + options: FormatterOptions & { + cents?: boolean; + } = {}, +): string => { + const { locale = "pt-BR", currency = "BRL", cents = false } = options; + + const displayValue = cents ? value / 100 : value; + + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits: 2, + }).format(displayValue); +}; + +export const useFormatter = ( + initialValue = 0, + options: FormatterOptions = {}, +) => { + const { cents = false } = options; + + // Quando cents=true, armazenamos o estado como inteiro (centavos). + const [value, setValue] = useState(initialValue); + + const formatted = useMemo(() => formatter(value, options), [value, options]); + + const onChange = useCallback( + (raw: string) => { + const onlyNumbers = raw.replace(/[^\d]/g, ""); + if (cents) { + // Mantém valor como inteiro em centavos + const num = Number(onlyNumbers); + setValue(num); + } else { + // Valor em reais com duas casas, armazenado como float + const num = Number(onlyNumbers) / 100; + setValue(num); + } + }, + [cents], + ); + + return { + // value será inteiro (centavos) quando cents=true, ou float quando cents=false + value, + formatted, + onChange, + setValue, + }; +}; diff --git a/apps/web/src/sections/account_2fa_link/form/index.tsx b/apps/web/src/sections/account_2fa_link/form/index.tsx index 06c57f9..6ddb29b 100644 --- a/apps/web/src/sections/account_2fa_link/form/index.tsx +++ b/apps/web/src/sections/account_2fa_link/form/index.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { useCallback } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -39,6 +39,7 @@ type FormConfirmValues = z.infer; export const Account2FALinkForm = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); + const router = useRouter(); const { data: setupData, @@ -212,9 +213,12 @@ export const Account2FALinkForm = () => {
{!isSetupSuccessful && ( - + router.history.back()} + > Back - + )} val === true, { + message: "You must give consent to proceed", + }), +}); + +export type FormValues = z.infer; + +export const ShopDonateForm = () => { + const { session } = useSession(); + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + step: "providers", + }, + }); + + const step = form.watch("step"); + + const title = useMemo(() => { + switch (step) { + case "providers": + return "Select Payment Method"; + case "services": + return "Select Products"; + case "review": + return "Confirm Your Order"; + default: + return ""; + } + }, [step]); + + const handleSubmit = useCallback(async (data: FormValues) => { + console.log("Form submitted:", data); + }, []); + + return ( +
+
+ + Order for: {session?.email} + +
+ +
+ + + + + + +
+
+ ); +}; diff --git a/apps/web/src/sections/shop_donate/form/providers/index.tsx b/apps/web/src/sections/shop_donate/form/providers/index.tsx new file mode 100644 index 0000000..2a14f18 --- /dev/null +++ b/apps/web/src/sections/shop_donate/form/providers/index.tsx @@ -0,0 +1,81 @@ +import { useQuery } from "@tanstack/react-query"; +import { useFormContext } from "react-hook-form"; +import { PaymentMethodItem } from "@/components/Payments/PaymentMethod"; +import { api } from "@/sdk/lib/api/factory"; +import { ButtonImage } from "@/ui/Buttons/ButtonImage"; +import { InnerContainer } from "@/ui/Container/Inner"; +import { FormField, FormItem, FormMessage } from "@/ui/Form"; +import type { FormValues } from ".."; + +export const ShopDonateStepProviders = () => { + const { data: providers = [] } = useQuery( + api.query.miforge.shop.providers.queryOptions(), + ); + + const form = useFormContext(); + const step = form.watch("step"); + const paymentId = form.watch("providerId"); + + if (step !== "providers") { + return null; + } + + return ( + <> + + { + return ( + +
+ {providers.map((provider) => { + return ( + { + form.clearErrors("providerId"); + onChange( + value === provider.id ? undefined : provider.id, + ); + }} + /> + ); + })} +
+ +
+ ); + }} + /> +
+ + +
+ { + if (!paymentId) { + form.setError("providerId", { + type: "required", + message: "Please select a payment method.", + }); + return; + } + + form.setValue("step", "services"); + }} + > + Next + +
+
+ + ); +}; diff --git a/apps/web/src/sections/shop_donate/form/review/index.tsx b/apps/web/src/sections/shop_donate/form/review/index.tsx new file mode 100644 index 0000000..06ea933 --- /dev/null +++ b/apps/web/src/sections/shop_donate/form/review/index.tsx @@ -0,0 +1,140 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useFormContext } from "react-hook-form"; +import { List } from "@/components/List"; +import { useSession } from "@/sdk/contexts/session"; +import { formatter } from "@/sdk/hooks/useMoney"; +import { api } from "@/sdk/lib/api/factory"; +import { ButtonImage } from "@/ui/Buttons/ButtonImage"; +import { Checkbox } from "@/ui/Checkbox"; +import { InnerContainer } from "@/ui/Container/Inner"; +import { FormControl, FormField, FormItem, FormMessage } from "@/ui/Form"; +import { Label } from "@/ui/Label"; +import type { FormValues } from ".."; + +export const ShopDonateStepReview = () => { + const { data: providers = [] } = useQuery( + api.query.miforge.shop.providers.queryOptions(), + ); + + const { data: services } = useQuery( + api.query.miforge.shop.services.queryOptions(), + ); + const { session } = useSession(); + const form = useFormContext(); + const step = form.watch("step"); + + const providerId = form.watch("providerId"); + const serviceId = form.watch("serviceId"); + + const selectedService = useMemo(() => { + return services?.find((service) => service.id === serviceId); + }, [serviceId, services]); + + const selectedProvider = useMemo(() => { + return providers.find((provider) => provider.id === providerId); + }, [providerId, providers]); + + if (step !== "review") { + return null; + } + + return ( + <> + + + + {session?.email} + + + + + + + {selectedProvider?.name} + + + {selectedService?.type} + + + + {formatter( + (selectedService?.unit_price ?? 0) * + (selectedService?.quantity ?? 0), + )} + + + + {selectedService?.quantity} + + + + + { + return ( + + + + + + + ); + }} + /> + + +
+ { + form.setValue("step", "services"); + }} + > + Back + + + Confirm Order + +
+
+ + ); +}; diff --git a/apps/web/src/sections/shop_donate/form/services/index.tsx b/apps/web/src/sections/shop_donate/form/services/index.tsx new file mode 100644 index 0000000..9891c62 --- /dev/null +++ b/apps/web/src/sections/shop_donate/form/services/index.tsx @@ -0,0 +1,115 @@ +import { useQuery } from "@tanstack/react-query"; +import { useFormContext } from "react-hook-form"; +import { CoinsPackageItem } from "@/components/Payments/CoinsPackage"; +import { formatter } from "@/sdk/hooks/useMoney"; +import { api } from "@/sdk/lib/api/factory"; +import { ButtonImage } from "@/ui/Buttons/ButtonImage"; +import { InnerContainer } from "@/ui/Container/Inner"; +import { FormField, FormItem, FormMessage } from "@/ui/Form"; +import type { FormValues } from ".."; + +export const ShopDonateStepServices = () => { + const { data: services = [] } = useQuery( + api.query.miforge.shop.services.queryOptions(), + ); + + const form = useFormContext(); + const step = form.watch("step"); + const serviceId = form.watch("serviceId"); + + // Defina a ordem de tamanhos + const SIZE_ORDER = ["tiny", "small", "medium", "large", "xlarge"] as const; + + // Ordena por preço total e atribui tamanho em ordem, caindo em "mega" + const servicesWithSize = services + .map((service) => ({ + ...service, + totalPrice: service.unit_price * service.quantity, + })) + .sort((a, b) => a.totalPrice - b.totalPrice) + .map((service, index) => { + const autoSize = SIZE_ORDER[index] ?? "mega"; + return { ...service, autoSize }; + }); + + if (step !== "services") { + return null; + } + + return ( + <> + + { + return ( + +
+ {servicesWithSize.map((service) => { + const id = service.id; + const price = service.unit_price * service.quantity; + + if (service.type === "COINS") { + return ( + { + form.clearErrors("serviceId"); + onChange(value === id ? undefined : id); + }} + /> + ); + } + + return null; + })} +
+ +
+ ); + }} + /> + + * Please note that the prices may vary depending on the current + exchange rate. Different prices may apply depending on your selected + payment method. + +
+ +
+ { + form.setValue("step", "providers"); + }} + > + Back + + { + if (!serviceId) { + form.setError("serviceId", { + type: "required", + message: "Please select a service.", + }); + return; + } + + form.setValue("step", "review"); + }} + > + Next + +
+
+ + ); +}; diff --git a/apps/web/src/sections/shop_donate/index.tsx b/apps/web/src/sections/shop_donate/index.tsx new file mode 100644 index 0000000..4235674 --- /dev/null +++ b/apps/web/src/sections/shop_donate/index.tsx @@ -0,0 +1,17 @@ +import { Section } from "@/ui/Section"; +import { SectionHeader } from "@/ui/Section/Header"; +import { InnerSection } from "@/ui/Section/Inner"; +import { ShopDonateForm } from "./form"; + +export const ShopDonateSection = () => { + return ( +
+ +

Shop

+
+ + + +
+ ); +};