10 Commits

43 changed files with 1910 additions and 92 deletions

View File

@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:4200
INTEGRATION_SECRET=
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View File

@@ -898,7 +898,7 @@
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants",
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/productos/{{producto_id}}/variants",
"host": [
"{{base_url}}"
],
@@ -906,7 +906,9 @@
"api",
"tenants",
"{{tenant_codigo}}",
"product-variants"
"productos",
"{{producto_id}}",
"variants"
]
}
},
@@ -930,10 +932,10 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"producto_id\": {{producto_id}},\n \"slug\": \"remera-basica-blanca-m\",\n \"nombre\": \"Remera Basica Blanca - Talle M\",\n \"stock\": 8,\n \"descripcion\": \"Variante de prueba\",\n \"precio\": 26999,\n \"definitions\": [\n {\n \"products_attribute_id\": {{color_product_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"products_attribute_id\": {{talle_product_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}"
"raw": "{\n \"stock\": 8,\n \"definitions\": [\n {\n \"products_attribute_id\": {{color_product_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"products_attribute_id\": {{talle_product_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants",
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/productos/{{producto_id}}/variants",
"host": [
"{{base_url}}"
],
@@ -941,7 +943,9 @@
"api",
"tenants",
"{{tenant_codigo}}",
"product-variants"
"productos",
"{{producto_id}}",
"variants"
]
}
},
@@ -959,7 +963,7 @@
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants/{{product_variant_id}}",
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/productos/{{producto_id}}/variants/{{product_variant_id}}",
"host": [
"{{base_url}}"
],
@@ -967,7 +971,9 @@
"api",
"tenants",
"{{tenant_codigo}}",
"product-variants",
"productos",
"{{producto_id}}",
"variants",
"{{product_variant_id}}"
]
}
@@ -992,10 +998,10 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"slug\": \"remera-basica-blanca-m\",\n \"nombre\": \"Remera Basica Blanca - Talle M Updated\",\n \"stock\": 12,\n \"descripcion\": \"Variante actualizada\",\n \"precio\": 27999,\n \"definitions\": [\n {\n \"products_attribute_id\": {{color_product_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"products_attribute_id\": {{talle_product_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}"
"raw": "{\n \"stock\": 12,\n \"definitions\": [\n {\n \"products_attribute_id\": {{color_product_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"products_attribute_id\": {{talle_product_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants/{{product_variant_id}}",
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/productos/{{producto_id}}/variants/{{product_variant_id}}",
"host": [
"{{base_url}}"
],
@@ -1003,7 +1009,9 @@
"api",
"tenants",
"{{tenant_codigo}}",
"product-variants",
"productos",
"{{producto_id}}",
"variants",
"{{product_variant_id}}"
]
}
@@ -1022,7 +1030,7 @@
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants/{{product_variant_id}}",
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/productos/{{producto_id}}/variants/{{product_variant_id}}",
"host": [
"{{base_url}}"
],
@@ -1030,7 +1038,9 @@
"api",
"tenants",
"{{tenant_codigo}}",
"product-variants",
"productos",
"{{producto_id}}",
"variants",
"{{product_variant_id}}"
]
}
@@ -1403,6 +1413,454 @@
}
]
},
{
"name": "Bank Accounts",
"item": [
{
"name": "List Bank Accounts",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts"
]
}
},
"response": []
},
{
"name": "Create Bank Account",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"titular\": \"Ada Lovelace\",\n \"entidad\": \"Banco Demo\",\n \"alias\": \"ada.demo.alias\",\n \"cvu\": \"0000003100000000000001\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts"
]
}
},
"response": []
},
{
"name": "Show Selected Bank Account",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts/selected",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts",
"selected"
]
}
},
"response": []
},
{
"name": "Show Bank Account",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts/{{bank_account_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts",
"{{bank_account_id}}"
]
}
},
"response": []
},
{
"name": "Update Bank Account",
"request": {
"method": "PATCH",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"titular\": \"Ada Lovelace Updated\",\n \"entidad\": \"Banco Demo Updated\",\n \"alias\": \"ada.demo.updated\",\n \"cvu\": \"0000003100000000000002\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts/{{bank_account_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts",
"{{bank_account_id}}"
]
}
},
"response": []
},
{
"name": "Select Bank Account",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts/{{bank_account_id}}/select",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts",
"{{bank_account_id}}",
"select"
]
}
},
"response": []
},
{
"name": "Delete Bank Account",
"request": {
"method": "DELETE",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/bank-accounts/{{bank_account_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_codigo}}",
"bank-accounts",
"{{bank_account_id}}"
]
}
},
"response": []
}
]
},
{
"name": "Integrations",
"item": [
{
"name": "List Integrations",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations"
]
}
},
"response": []
},
{
"name": "Create Integration",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"integration_code\": \"mercadopago\",\n \"name\": \"Mercado Pago\",\n \"url\": \"https://api.mercadopago.com\",\n \"integration_data_schema\": {\n \"access_token\": \"required|string\",\n \"public_key\": \"nullable|string\"\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations"
]
}
},
"response": []
},
{
"name": "Show Integration",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/integrations/{{integration_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations",
"{{integration_id}}"
]
}
},
"response": []
},
{
"name": "Update Integration",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"integration_code\": \"mercadopago\",\n \"name\": \"Mercado Pago Updated\",\n \"url\": \"https://api.mercadopago.com\",\n \"integration_data_schema\": {\n \"access_token\": \"required|string\",\n \"public_key\": \"nullable|string\"\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/integrations/{{integration_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations",
"{{integration_id}}"
]
}
},
"response": []
},
{
"name": "Delete Integration",
"request": {
"method": "DELETE",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/integrations/{{integration_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations",
"{{integration_id}}"
]
}
},
"response": []
}
]
},
{
"name": "Tenant Integrations",
"item": [
{
"name": "List Tenant Integrations",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/{{tenant_codigo}}/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"{{tenant_codigo}}",
"integrations"
]
}
},
"response": []
},
{
"name": "Show Tenant Integration",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/{{tenant_codigo}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"{{tenant_codigo}}",
"integrations",
"{{integration_code}}"
]
}
},
"response": []
},
{
"name": "Configure Tenant Integration",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"access_token\": \"APP_USR-demo-token\",\n \"public_key\": \"APP_PUBLIC-demo-key\"\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/{{tenant_codigo}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"{{tenant_codigo}}",
"integrations",
"{{integration_code}}"
]
}
},
"response": []
}
]
},
{
"name": "Storage Test",
"item": [
@@ -1628,6 +2086,21 @@
"key": "compra_id",
"value": "1",
"type": "string"
},
{
"key": "integration_id",
"value": "1",
"type": "string"
},
{
"key": "integration_code",
"value": "mercadopago",
"type": "string"
},
{
"key": "bank_account_id",
"value": "1",
"type": "string"
}
]
}

View File

@@ -10,7 +10,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password'])]
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{

View File

@@ -21,6 +21,8 @@ class RegisterUserRequest extends FormRequest
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed'],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -19,6 +19,8 @@ class UserResource extends JsonResource
'id' => $this->id,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'dni' => $this->dni,
'telefono' => $this->telefono,
];
}
}

View File

@@ -7,7 +7,7 @@ use App\Domains\Auth\Models\User;
class RegisterUserService
{
/**
* @param array{nombre_apellido: string, email: string, password: string} $data
* @param array{nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
*/
public function register(array $data): User
{
@@ -15,6 +15,8 @@ class RegisterUserService
'nombre_apellido' => $data['nombre_apellido'],
'email' => $data['email'],
'password' => $data['password'],
'dni' => $data['dni'] ?? null,
'telefono' => $data['telefono'] ?? null,
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Domains\BankAccount\Controllers;
use App\Domains\BankAccount\Models\BankAccount;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\BankAccount\Requests\StoreBankAccountRequest;
use App\Domains\BankAccount\Requests\UpdateBankAccountRequest;
use App\Domains\BankAccount\Resources\BankAccountResource;
use App\Domains\Tenant\Resources\TenantResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class BankAccountController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$accounts = $tenant->bankAccounts()->latest()->get();
return BankAccountResource::collection($accounts)->response();
}
public function store(StoreBankAccountRequest $request, Tenant $tenant): JsonResponse
{
$data = $request->validated();
$data['tenant_code'] = $tenant->codigo;
$account = BankAccount::query()->create($data);
return BankAccountResource::make($account)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, BankAccount $bankAccount): BankAccountResource
{
abort_if($bankAccount->tenant_code !== $tenant->codigo, 404);
return BankAccountResource::make($bankAccount);
}
public function update(UpdateBankAccountRequest $request, Tenant $tenant, BankAccount $bankAccount): BankAccountResource
{
abort_if($bankAccount->tenant_code !== $tenant->codigo, 404);
$bankAccount->update($request->validated());
return BankAccountResource::make($bankAccount);
}
public function destroy(Tenant $tenant, BankAccount $bankAccount): Response
{
abort_if($bankAccount->tenant_code !== $tenant->codigo, 404);
$bankAccount->delete();
return response()->noContent();
}
public function select(Tenant $tenant, BankAccount $bankAccount): TenantResource
{
abort_if($bankAccount->tenant_code !== $tenant->codigo, 400, 'La cuenta bancaria no pertenece a este tenant.');
$tenant->selected_bank_account_id = $bankAccount->id;
$tenant->save();
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
}
public function selected(Tenant $tenant): JsonResponse
{
$account = $tenant->selectedBankAccount;
abort_if(!$account, 404, 'No hay ninguna cuenta bancaria seleccionada para este tenant.');
return BankAccountResource::make($account)->response();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\BankAccount\Models;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BankAccount extends Model
{
use HasFactory;
protected $fillable = [
'tenant_code',
'titular',
'entidad',
'alias',
'cvu',
];
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\BankAccount\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBankAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'titular' => ['required', 'string', 'max:255'],
'entidad' => ['required', 'string', 'max:255'],
'alias' => ['required', 'string', 'max:255'],
'cvu' => ['required', 'string', 'max:255'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'titular.required' => 'El campo titular es obligatorio.',
'titular.string' => 'El campo titular debe ser una cadena de texto.',
'titular.max' => 'El campo titular no debe superar los 255 caracteres.',
'entidad.required' => 'El campo entidad es obligatorio.',
'entidad.string' => 'El campo entidad debe ser una cadena de texto.',
'entidad.max' => 'El campo entidad no debe superar los 255 caracteres.',
'alias.required' => 'El campo alias es obligatorio.',
'alias.string' => 'El campo alias debe ser una cadena de texto.',
'alias.max' => 'El campo alias no debe superar los 255 caracteres.',
'cvu.required' => 'El campo cvu es obligatorio.',
'cvu.string' => 'El campo cvu debe ser una cadena de texto.',
'cvu.max' => 'El campo cvu no debe superar los 255 caracteres.',
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\BankAccount\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBankAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'titular' => ['sometimes', 'string', 'max:255'],
'entidad' => ['sometimes', 'string', 'max:255'],
'alias' => ['sometimes', 'string', 'max:255'],
'cvu' => ['sometimes', 'string', 'max:255'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'titular.string' => 'El campo titular debe ser una cadena de texto.',
'titular.max' => 'El campo titular no debe superar los 255 caracteres.',
'entidad.string' => 'El campo entidad debe ser una cadena de texto.',
'entidad.max' => 'El campo entidad no debe superar los 255 caracteres.',
'alias.string' => 'El campo alias debe ser una cadena de texto.',
'alias.max' => 'El campo alias no debe superar los 255 caracteres.',
'cvu.string' => 'El campo cvu debe ser una cadena de texto.',
'cvu.max' => 'El campo cvu no debe superar los 255 caracteres.',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\BankAccount\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\BankAccount\Models\BankAccount
*/
class BankAccountResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_code' => $this->tenant_code,
'titular' => $this->titular,
'entidad' => $this->entidad,
'alias' => $this->alias,
'cvu' => $this->cvu,
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
use App\Domains\BankAccount\Controllers\BankAccountController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::middleware('auth:sanctum')->group(function (): void {
Route::get('bank-accounts/selected', [BankAccountController::class, 'selected']);
Route::post('bank-accounts/{bank_account}/select', [BankAccountController::class, 'select']);
Route::apiResource('bank-accounts', BankAccountController::class)
->parameters(['bank-accounts' => 'bank_account']);
});
});

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Integration\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Exception;
class EncryptedIntegrationData implements CastsAttributes
{
protected function getEncrypter(): Encrypter
{
$secret = config('services.integrations.secret');
if (empty($secret)) {
throw new Exception('The integrations secret is not configured.');
}
// Laravel encrypter requires a key of exact length. Typically 32 bytes for AES-256-CBC.
// If the secret is base64 encoded like the APP_KEY:
if (str_starts_with($secret, 'base64:')) {
$key = base64_decode(substr($secret, 7));
} else {
// Otherwise, we hash it to ensure 32 bytes for AES-256-CBC.
$key = hash('sha256', $secret, true);
}
return new Encrypter($key, config('app.cipher', 'AES-256-CBC'));
}
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
try {
$decrypted = $this->getEncrypter()->decryptString($value);
return json_decode($decrypted, true);
} catch (Exception $e) {
// Return null or throw depending on how strict we want to be.
return null;
}
}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
$json = json_encode($value);
return $this->getEncrypter()->encryptString($json);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreIntegrationRequest;
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
use Illuminate\Routing\Controller;
class IntegrationController extends Controller
{
public function index()
{
return response()->json(Integration::all());
}
public function store(StoreIntegrationRequest $request)
{
$integration = Integration::create($request->validated());
return response()->json($integration, 201);
}
public function show(Integration $integration)
{
return response()->json($integration);
}
public function update(UpdateIntegrationRequest $request, Integration $integration)
{
$integration->update($request->validated());
return response()->json($integration->fresh());
}
public function destroy(Integration $integration)
{
$integration->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
use App\Domains\Integration\Services\TenantIntegrationService;
use Illuminate\Routing\Controller;
class TenantIntegrationController extends Controller
{
protected TenantIntegrationService $tenantIntegrationService;
public function __construct(TenantIntegrationService $tenantIntegrationService)
{
$this->tenantIntegrationService = $tenantIntegrationService;
}
public function index(string $tenantCode)
{
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
}
public function show(string $tenantCode, string $integrationCode)
{
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (!$integration) {
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
}
return response()->json($integration);
}
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
$integration,
$request->input('integration_data', [])
);
return response()->json($tenantIntegration);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model;
class Integration extends Model
{
protected $table = 'integrations';
protected $fillable = [
'integration_code',
'name',
'url',
'integration_data_schema',
];
protected $casts = [
'integration_data_schema' => 'array',
];
public function tenantIntegrations()
{
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
class TenantIntegration extends Model
{
protected $table = 'tenant_integration';
protected $fillable = [
'integration_code',
'tenant_code',
'integration_data',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
];
public function integration()
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreTenantIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation()
{
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
if (!$this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => 'Integration not found.'
]);
}
}
public function rules(): array
{
$rules = [];
// Dynamic validation rules based on the integration data schema
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.' . $field] = $rule;
}
}
return $rules;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$integration = $this->route('integration');
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
// the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
class TenantIntegrationService
{
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
{
return TenantIntegration::where('tenant_code', $tenantCode)
->where('integration_code', $integrationCode)
->first();
}
public function getAllForTenant(string $tenantCode)
{
return TenantIntegration::with('integration')
->where('tenant_code', $tenantCode)
->get();
}
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
{
return TenantIntegration::updateOrCreate(
[
'tenant_code' => $tenantCode,
'integration_code' => $integration->integration_code,
],
[
'integration_data' => $data,
]
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\TenantIntegrationController;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'integrations'], function () {
Route::get('/', [IntegrationController::class, 'index']);
Route::post('/', [IntegrationController::class, 'store']);
Route::get('/{integration}', [IntegrationController::class, 'show']);
Route::put('/{integration}', [IntegrationController::class, 'update']);
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
});
Route::group(['prefix' => '{tenant_code}/integrations'], function () {
Route::get('/', [TenantIntegrationController::class, 'index']);
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']);
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']);
});

View File

@@ -10,7 +10,7 @@ use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Domains\Purchase\Services\CheckoutService;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -28,28 +28,15 @@ class PurchaseController extends Controller
)->response();
}
public function store(StorePurchaseRequest $request, Tenant $tenant): JsonResponse
public function store(StorePurchaseRequest $request, Tenant $tenant, CheckoutService $checkoutService): JsonResponse
{
$data = $request->validated();
$items = $data['items'];
unset($data['items']);
$variants = $this->resolveTenantVariants($tenant, $items);
$purchaseItems = $this->buildPurchaseItemsPayload($items, $variants);
$purchase = DB::transaction(function () use ($request, $tenant, $data, $purchaseItems): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
'user_id' => $request->user()->id,
]);
$purchase->items()->createMany($purchaseItems);
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
});
$purchase = $checkoutService->processCheckout(
$tenant,
$request->user()->id,
$data
);
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
}
@@ -63,60 +50,7 @@ class PurchaseController extends Controller
);
}
/**
* @param array<int, array<string, mixed>> $items
* @return \Illuminate\Support\Collection<int, ProductVariant>
*/
protected function resolveTenantVariants(Tenant $tenant, array $items)
{
$variantIds = collect($items)
->pluck('producto_variante_id')
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
$variants = ProductVariant::query()
->with('product')
->whereIn('id', $variantIds)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
->get()
->keyBy('id');
if ($variants->count() !== $variantIds->count()) {
throw ValidationException::withMessages([
'items' => 'One or more product variants do not belong to the tenant.',
]);
}
return $variants;
}
/**
* @param array<int, array<string, mixed>> $items
* @param \Illuminate\Support\Collection<int, ProductVariant> $variants
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(array $items, $variants): array
{
return collect($items)
->map(function (array $item) use ($variants): array {
/** @var ProductVariant $variant */
$variant = $variants->get((int) $item['producto_variante_id']);
$quantity = (int) $item['cantidad'];
$unitPrice = (float) ($variant->product?->precio ?? 0);
return [
'producto_variante_id' => $variant->getKey(),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
];
})
->all();
}
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{

View File

@@ -16,6 +16,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'status',
'payment_status',
'payment_method',
'dni',
'telefono',
'nombre_apellido',
'email',
])]
class Purchase extends Model
{

View File

@@ -20,7 +20,11 @@ class StorePurchaseRequest extends FormRequest
return [
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])],
'payment_method' => ['nullable', 'string', 'max:255'],
'payment_method' => ['required', 'string'],
'dni' => ['required', 'string'],
'telefono' => ['required', 'string'],
'nombre_apellido' => ['required', 'string'],
'email' => ['required', 'string', 'email'],
'items' => ['required', 'array', 'min:1'],
'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'],
'items.*.cantidad' => ['required', 'integer', 'min:1'],

View File

@@ -36,6 +36,10 @@ class PurchaseResource extends JsonResource
'status' => $this->status,
'payment_status' => $this->payment_status,
'payment_method' => $this->payment_method,
'dni' => $this->dni,
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'items' => PurchaseItemResource::collection($items),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CheckoutService
{
public function processCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
// 1. Preparar items de la compra
$items = $purchaseData['items'];
unset($purchaseData['items']);
$variants = $this->resolveTenantVariants($tenant, $items);
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($items, $variants);
// 2. Crear la orden de compra y vaciar carrito
return DB::transaction(function () use ($tenant, $userId, $purchaseData, $purchaseItemsPayload): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()->create([
...$purchaseData,
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
]);
$purchase->items()->createMany($purchaseItemsPayload);
// Vaciar y eliminar el carrito activo del usuario
$cart = \App\Domains\Cart\Models\Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $userId)
->first();
if ($cart) {
$cart->items()->delete();
$cart->delete();
}
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
});
}
/**
* @param array<int, array<string, mixed>> $items
* @return \Illuminate\Support\Collection<int, ProductVariant>
*/
protected function resolveTenantVariants(Tenant $tenant, array $items)
{
$variantIds = collect($items)
->pluck('producto_variante_id')
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
$variants = ProductVariant::query()
->with('product')
->whereIn('id', $variantIds)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
->get()
->keyBy('id');
if ($variants->count() !== $variantIds->count()) {
throw ValidationException::withMessages([
'items' => 'One or more product variants do not belong to the tenant.',
]);
}
return $variants;
}
/**
* @param array<int, array<string, mixed>> $items
* @param \Illuminate\Support\Collection<int, ProductVariant> $variants
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(array $items, $variants): array
{
return collect($items)
->map(function (array $item) use ($variants): array {
/** @var ProductVariant $variant */
$variant = $variants->get((int) $item['producto_variante_id']);
$quantity = (int) $item['cantidad'];
$unitPrice = (float) ($variant->product?->precio ?? 0);
return [
'producto_variante_id' => $variant->getKey(),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
];
})
->all();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\BankAccount\Models\BankAccount;
use App\Domains\Catalog\Models\Product;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -55,5 +56,21 @@ class Tenant extends Model
{
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<BankAccount, $this>
*/
public function bankAccounts(): HasMany
{
return $this->hasMany(BankAccount::class, 'tenant_code', 'codigo');
}
/**
* @return BelongsTo<BankAccount, $this>
*/
public function selectedBankAccount(): BelongsTo
{
return $this->belongsTo(BankAccount::class, 'selected_bank_account_id');
}
}

View File

@@ -35,4 +35,8 @@ return [
],
],
'integrations' => [
'secret' => env('INTEGRATION_SECRET'),
],
];

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('integrations', function (Blueprint $table) {
$table->id();
$table->string('integration_code')->unique();
$table->string('name');
$table->string('url')->nullable();
$table->json('integration_data_schema')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('integrations');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_integration', function (Blueprint $table) {
$table->id();
$table->string('integration_code');
$table->string('tenant_code');
$table->longText('integration_data')->nullable(); // Stored as longText because it's encrypted JSON
$table->timestamps();
// Foreign keys
$table->foreign('integration_code')->references('integration_code')->on('integrations')->onDelete('cascade');
$table->foreign('tenant_code')->references('codigo')->on('tenants')->onDelete('cascade');
$table->unique(['integration_code', 'tenant_code']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_integration');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bank_accounts', function (Blueprint $table) {
$table->id();
$table->string('tenant_code');
$table->string('titular');
$table->string('entidad');
$table->string('alias');
$table->string('cvu');
$table->timestamps();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bank_accounts');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->unsignedBigInteger('selected_bank_account_id')->nullable()->after('footer_logo_id');
$table->foreign('selected_bank_account_id')
->references('id')
->on('bank_accounts')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropForeign(['selected_bank_account_id']);
$table->dropColumn('selected_bank_account_id');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('dni')->nullable()->after('email');
$table->string('telefono')->nullable()->after('dni');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['dni', 'telefono']);
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('compras', function (Blueprint $table) {
$table->string('dni')->nullable()->after('payment_method');
$table->string('telefono')->nullable()->after('dni');
$table->string('nombre_apellido')->nullable()->after('telefono');
$table->string('email')->nullable()->after('nombre_apellido');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('compras', function (Blueprint $table) {
$table->dropColumn(['dni', 'telefono', 'nombre_apellido', 'email']);
});
}
};

View File

@@ -28,6 +28,7 @@ class DatabaseSeeder extends Seeder
CategorySeeder::class,
BrandSeeder::class,
ProductCatalogFromImagesSeeder::class,
TelepagosIntegrationSeeder::class,
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Database\Seeders;
use App\Domains\Integration\Models\Integration;
use Illuminate\Database\Seeder;
class TelepagosIntegrationSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Integration::updateOrCreate(
['integration_code' => 'telepagos'],
[
'name' => 'Telepagos',
'url' => 'https://api.telepagos.com.ar',
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
]
);
Integration::updateOrCreate(
['integration_code' => 'telepagos_homo'],
[
'name' => 'Telepagos Homologación',
'url' => 'https://api.homo.telepagos.com.ar',
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
]
);
}
}

View File

@@ -72,7 +72,7 @@ class TenantSeeder extends Seeder
true
);
$this->tenantService->create([
$tenant = $this->tenantService->create([
'codigo' => 'sonder',
'nombre' => 'Sonder',
'dominio' => 'localhost',
@@ -85,5 +85,32 @@ class TenantSeeder extends Seeder
'header_logo' => $headerLogo,
'footer_logo' => $footerLogo,
]);
$bankAccount1 = \App\Domains\BankAccount\Models\BankAccount::query()->create([
'tenant_code' => $tenant->codigo,
'titular' => 'Sonder S.A. (Principal)',
'entidad' => 'Banco de la Nación Argentina',
'alias' => 'sonder.indumentaria',
'cvu' => '0110065420006540987654',
]);
\App\Domains\BankAccount\Models\BankAccount::query()->create([
'tenant_code' => $tenant->codigo,
'titular' => 'Sonder S.A. (Secundaria)',
'entidad' => 'Banco de Galicia y Buenos Aires',
'alias' => 'sonder.indumentaria.galicia',
'cvu' => '0070012345678901234567',
]);
\App\Domains\BankAccount\Models\BankAccount::query()->create([
'tenant_code' => $tenant->codigo,
'titular' => 'Sonder S.A. (Mercado Pago)',
'entidad' => 'Mercado Pago',
'alias' => 'sonder.indumentaria.mp',
'cvu' => '0000003100012345678901',
]);
$tenant->selected_bank_account_id = $bankAccount1->id;
$tenant->save();
}
}

View File

@@ -9,3 +9,5 @@ require __DIR__.'/../app/Domains/Cart/routes/api.php';
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/BankAccount/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php';

View File

@@ -36,7 +36,7 @@ class LoginControllerTest extends TestCase
$this->assertNotEmpty($response->json('token'));
$this->withHeader('Authorization', 'Bearer '.$response->json('token'))
->getJson('/api/user')
->getJson('/api/me')
->assertOk()
->assertJsonPath('id', $user->id)
->assertJsonPath('email', 'grace@example.com');
@@ -44,7 +44,7 @@ class LoginControllerTest extends TestCase
public function test_it_rejects_access_to_the_current_user_endpoint_without_a_token(): void
{
$this->getJson('/api/user')->assertUnauthorized();
$this->getJson('/api/me')->assertUnauthorized();
}
public function test_it_logs_out_the_current_token(): void

View File

@@ -17,17 +17,23 @@ class RegisterControllerTest extends TestCase
'email' => 'ada@example.com',
'password' => 'secret123',
'password_confirmation' => 'secret123',
'dni' => '12345678A',
'telefono' => '+541122334455',
]);
$response
->assertCreated()
->assertJsonPath('message', 'Usuario registrado correctamente.')
->assertJsonPath('data.nombre_apellido', 'Ada Lovelace')
->assertJsonPath('data.email', 'ada@example.com');
->assertJsonPath('data.email', 'ada@example.com')
->assertJsonPath('data.dni', '12345678A')
->assertJsonPath('data.telefono', '+541122334455');
$this->assertDatabaseHas('users', [
'nombre_apellido' => 'Ada Lovelace',
'email' => 'ada@example.com',
'dni' => '12345678A',
'telefono' => '+541122334455',
]);
$user = User::query()->where('email', 'ada@example.com')->firstOrFail();
@@ -35,6 +41,31 @@ class RegisterControllerTest extends TestCase
$this->assertNotSame('secret123', $user->password);
}
public function test_it_registers_a_user_without_optional_fields(): void
{
$response = $this->postJson('/api/register', [
'nombre_apellido' => 'Alan Turing',
'email' => 'alan@example.com',
'password' => 'secret123',
'password_confirmation' => 'secret123',
]);
$response
->assertCreated()
->assertJsonPath('message', 'Usuario registrado correctamente.')
->assertJsonPath('data.nombre_apellido', 'Alan Turing')
->assertJsonPath('data.email', 'alan@example.com')
->assertJsonPath('data.dni', null)
->assertJsonPath('data.telefono', null);
$this->assertDatabaseHas('users', [
'nombre_apellido' => 'Alan Turing',
'email' => 'alan@example.com',
'dni' => null,
'telefono' => null,
]);
}
public function test_it_validates_required_fields_and_unique_email(): void
{
User::factory()->create([

View File

@@ -0,0 +1,140 @@
<?php
namespace Tests\Feature\Purchase;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class StorePurchaseTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_purchase_with_customer_details_and_clears_the_cart(): void
{
// 1. Setup Tenant
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
// 2. Setup User
$user = User::factory()->create([
'email' => 'buyer@example.com'
]);
// 3. Setup Product & Variant
$category = \App\Domains\Catalog\Models\Category::query()->create([
'tenant_code' => 'sonder',
'nombre' => 'Test Category',
]);
$product = Product::query()->create([
'tenant_codigo' => 'sonder',
'categoria_id' => $category->id,
'slug' => 'test-product',
'nombre' => 'Test Product',
'descripcion' => 'Test',
'precio' => '50.00',
]);
$variant = ProductVariant::query()->create([
'producto_id' => $product->id,
'slug' => 'test-variant',
'nombre' => 'Test Variant',
'stock' => 10,
'precio' => '50.00',
]);
// 4. Add item to user's cart
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
])
->assertOk();
// Check cart exists in DB
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
]);
// 5. Submit Purchase with payment_method at root
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras', [
'dni' => '987654321',
'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com',
'payment_method' => 'transferencia',
'items' => [
[
'producto_variante_id' => $variant->id,
'cantidad' => 2,
]
]
]);
// 6. Assertions
$response->assertCreated();
$response->assertJsonPath('data.dni', '987654321');
$response->assertJsonPath('data.telefono', '+54 9 341 555-4321');
$response->assertJsonPath('data.nombre_apellido', 'Juan Perez');
$response->assertJsonPath('data.email', 'juan.perez@example.com');
$response->assertJsonPath('data.payment_method', 'transferencia');
$response->assertJsonPath('data.tenant_codigo', 'sonder');
// Assert Purchase saved in DB
$this->assertDatabaseHas('compras', [
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'dni' => '987654321',
'telefono' => '+54 9 341 555-4321',
'nombre_apellido' => 'Juan Perez',
'email' => 'juan.perez@example.com',
'payment_method' => 'transferencia',
]);
// Assert Cart was cleared (deleted)
$this->assertDatabaseMissing('carritos', [
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) \Illuminate\Support\Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\BankAccount\Models\BankAccount;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Auth\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BankAccountControllerTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private User $user;
protected function setUp(): void
{
parent::setUp();
$hdrKey = (string) \Illuminate\Support\Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$this->tenant = Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
'primary_color' => '#ff0000',
'secondary_color' => '#00ff00',
'danger_color' => '#0000ff',
'success_color' => '#00ff00',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
$this->user = User::factory()->create();
$this->actingAs($this->user, 'sanctum');
}
public function test_it_lists_bank_accounts_for_a_tenant(): void
{
BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$response = $this->getJson("/api/tenants/acme/bank-accounts");
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.titular', 'John Doe');
}
public function test_it_creates_a_bank_account_for_a_tenant(): void
{
$response = $this->postJson("/api/tenants/acme/bank-accounts", [
'titular' => 'Jane Doe',
'entidad' => 'Banco Nación',
'alias' => 'jane.doe.nacion',
'cvu' => '9876543210987654321098',
]);
$response->assertCreated()
->assertJsonPath('data.titular', 'Jane Doe');
$this->assertDatabaseHas('bank_accounts', [
'tenant_code' => 'acme',
'titular' => 'Jane Doe',
]);
}
public function test_it_shows_a_bank_account(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$response = $this->getJson("/api/tenants/acme/bank-accounts/{$account->id}");
$response->assertOk()
->assertJsonPath('data.titular', 'John Doe');
}
public function test_it_updates_a_bank_account(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$response = $this->putJson("/api/tenants/acme/bank-accounts/{$account->id}", [
'titular' => 'John Doe Updated',
]);
$response->assertOk()
->assertJsonPath('data.titular', 'John Doe Updated');
$this->assertDatabaseHas('bank_accounts', [
'id' => $account->id,
'titular' => 'John Doe Updated',
]);
}
public function test_it_deletes_a_bank_account(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$response = $this->deleteJson("/api/tenants/acme/bank-accounts/{$account->id}");
$response->assertNoContent();
$this->assertDatabaseMissing('bank_accounts', ['id' => $account->id]);
}
public function test_it_selects_a_bank_account_for_a_tenant(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$response = $this->postJson("/api/tenants/acme/bank-accounts/{$account->id}/select");
$response->assertOk()
->assertJsonPath('data.selected_bank_account_id', $account->id);
$this->tenant->refresh();
$this->assertEquals($account->id, $this->tenant->selected_bank_account_id);
}
public function test_it_deletes_related_bank_accounts_when_tenant_is_deleted(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$this->assertDatabaseHas('bank_accounts', ['id' => $account->id]);
$this->tenant->delete();
$this->assertDatabaseMissing('bank_accounts', ['id' => $account->id]);
}
public function test_it_sets_selected_bank_account_id_to_null_when_selected_bank_account_is_deleted(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
$this->tenant->selected_bank_account_id = $account->id;
$this->tenant->save();
$account->delete();
$this->tenant->refresh();
$this->assertNull($this->tenant->selected_bank_account_id);
}
public function test_it_gets_the_selected_bank_account_for_a_tenant(): void
{
$account = BankAccount::query()->create([
'tenant_code' => 'acme',
'titular' => 'John Doe',
'entidad' => 'Banco Galicia',
'alias' => 'john.doe.galicia',
'cvu' => '1234567890123456789012',
]);
// When no bank account is selected
$response = $this->getJson("/api/tenants/acme/bank-accounts/selected");
$response->assertNotFound();
// Select the bank account
$this->tenant->selected_bank_account_id = $account->id;
$this->tenant->save();
// Get selected bank account
$response = $this->getJson("/api/tenants/acme/bank-accounts/selected");
$response->assertOk()
->assertJsonPath('data.id', $account->id)
->assertJsonPath('data.titular', 'John Doe');
}
public function test_it_rejects_unauthenticated_requests(): void
{
$this->app['auth']->forgetGuards();
$response = $this->getJson("/api/tenants/acme/bank-accounts/selected");
$response->assertStatus(401);
$response2 = $this->getJson("/api/tenants/acme/bank-accounts");
$response2->assertStatus(401);
$response3 = $this->postJson("/api/tenants/acme/bank-accounts", []);
$response3->assertStatus(401);
}
}