Compare commits
10 Commits
4edda0bfd8
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
| b273b2866f | |||
| 05a92b7ec3 | |||
| f23029e785 | |||
| f18fbc8147 | |||
| c276ccd311 | |||
| 786b4eb6a9 | |||
| eca3b01083 | |||
| 7b2a741884 | |||
| 362e888e46 | |||
| 92458e217e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
.env.testing
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
|
|||||||
@@ -1367,7 +1367,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"status\": \"pending\",\n \"payment_status\": \"pending\",\n \"payment_method\": \"cash\",\n \"items\": [\n {\n \"producto_variante_id\": {{product_variant_id}},\n \"cantidad\": 1\n }\n ]\n}"
|
"raw": "{\n \"cart_id\": {{cart_id}},\n \"dni\": \"12345678\",\n \"telefono\": \"+54 9 341 555-4321\",\n \"nombre_apellido\": \"Juan Perez\",\n \"email\": \"juan.perez@example.com\"\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/compras",
|
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/compras",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class TenantIntegrationController extends Controller
|
|||||||
{
|
{
|
||||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
|
try {
|
||||||
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
|
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
|
||||||
$tenantCode,
|
$tenantCode,
|
||||||
$integration,
|
$integration,
|
||||||
@@ -43,5 +44,10 @@ class TenantIntegrationController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($tenantIntegration);
|
return response()->json($tenantIntegration);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,4 +152,16 @@ abstract class BaseIntegrationService
|
|||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
abstract public function getHeaders(): array;
|
abstract public function getHeaders(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook called after the integration is configured for the tenant.
|
||||||
|
* Can be used to validate credentials or perform initial setups.
|
||||||
|
* Throw an Exception on failure.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function onSetup(): void
|
||||||
|
{
|
||||||
|
// Override in child classes if needed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
* @return array
|
* @return array
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function getCashinDetails(int $cashinId): array
|
public function getCashinDetails(string $cashinId): array
|
||||||
{
|
{
|
||||||
$response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}");
|
$response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}");
|
||||||
|
|
||||||
@@ -213,4 +213,16 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||||
Cache::forget($cacheKey);
|
Cache::forget($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform initial setup validation for Telepagos.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function onSetup(): void
|
||||||
|
{
|
||||||
|
// Realiza un login de prueba para validar que las credenciales son correctas.
|
||||||
|
$this->login();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||||
use App\Domains\Purchase\Models\TelepagosQr;
|
use App\Domains\Purchase\Models\TelepagosQr;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -28,51 +29,82 @@ class TelepagosWebhookService
|
|||||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||||
|
|
||||||
$telepagosService = new TelepagosIntegrationService();
|
$telepagosService = new TelepagosIntegrationService();
|
||||||
$telepagosService->forTenant($tenant);
|
$telepagosService->forTenant($tenant->codigo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$details = $telepagosService->getCashinDetails($cashinId);
|
$details = $telepagosService->getCashinDetails($cashinId);
|
||||||
|
|
||||||
// Extract the qr_order_id from the response
|
|
||||||
// The exact path depends on Telepagos response structure, assuming it's inside 'data'
|
|
||||||
// Need to handle potential nested structures based on actual API
|
|
||||||
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
||||||
$amount = $details['data']['amount'] ?? $details['amount'] ?? 0;
|
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||||
|
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||||
|
|
||||||
if (!$qrOrderId) {
|
$transferenciaOperationIds = [1, 3, 11];
|
||||||
Log::warning("Telepagos webhook: qr_order_id not found for cashin {$cashinId}");
|
$qrOperationIds = [31, 37, 47];
|
||||||
|
|
||||||
|
$compra = null;
|
||||||
|
|
||||||
|
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
||||||
|
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
||||||
|
|
||||||
|
if (! $cuit) {
|
||||||
|
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dni = substr($cuit, 2, -1);
|
||||||
|
|
||||||
|
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||||
|
->whereRaw("REPLACE(dni, '.', '') = ?", [$dni])
|
||||||
|
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||||
|
->where('payment_method', 'transfer')
|
||||||
|
->where('total', $amount)
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $compra) {
|
||||||
|
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||||
|
if (! $qrOrderId) {
|
||||||
|
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
||||||
|
|
||||||
if (!$telepagosQr) {
|
if (! $telepagosQr) {
|
||||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$compra = $telepagosQr->compra;
|
$compra = $telepagosQr->compra;
|
||||||
|
|
||||||
if (!$compra) {
|
if (! $compra) {
|
||||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalAmount = $compra->getTotalAmount();
|
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||||
|
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||||
// Depending on precision, we might need a looser comparison, but == should work for floats here
|
return;
|
||||||
if ($amount != $totalAmount) {
|
}
|
||||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
|
||||||
// In a real scenario, you might want to record a partial payment or flag it,
|
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
||||||
// but the instructions say to finalize ONLY if the amount is correct.
|
|
||||||
|
if ($amount !== $totalAmount) {
|
||||||
|
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the TelepagosPayment and finalize the purchase in a transaction
|
|
||||||
$paymentData = [
|
$paymentData = [
|
||||||
'compra_id' => $compra->id,
|
'compra_id' => $compra->id,
|
||||||
'cuit_buyer' => $details['data']['cuit_buyer'] ?? $details['cuit_buyer'] ?? null,
|
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||||
'cvu_buyer' => $details['data']['cvu_buyer'] ?? $details['cvu_buyer'] ?? null,
|
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
||||||
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
||||||
@@ -85,14 +117,18 @@ class TelepagosWebhookService
|
|||||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||||
TelepagosPayment::create($paymentData);
|
TelepagosPayment::create($paymentData);
|
||||||
$this->checkoutService->confirmPurchase($compra);
|
$this->checkoutService->confirmPurchase($compra);
|
||||||
$compra->finalize();
|
$compra->markAsPaid();
|
||||||
});
|
});
|
||||||
|
|
||||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function normalizeAmount(mixed $amount): string
|
||||||
|
{
|
||||||
|
return number_format((float) $amount, 2, '.', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Integration\Services;
|
|||||||
|
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
use App\Domains\Integration\Models\TenantIntegration;
|
use App\Domains\Integration\Models\TenantIntegration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class TenantIntegrationService
|
class TenantIntegrationService
|
||||||
{
|
{
|
||||||
@@ -23,7 +24,8 @@ class TenantIntegrationService
|
|||||||
|
|
||||||
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
|
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
|
||||||
{
|
{
|
||||||
return TenantIntegration::updateOrCreate(
|
return DB::transaction(function () use ($tenantCode, $integration, $data) {
|
||||||
|
$tenantIntegration = TenantIntegration::updateOrCreate(
|
||||||
[
|
[
|
||||||
'tenant_code' => $tenantCode,
|
'tenant_code' => $tenantCode,
|
||||||
'integration_code' => $integration->integration_code,
|
'integration_code' => $integration->integration_code,
|
||||||
@@ -32,5 +34,24 @@ class TenantIntegrationService
|
|||||||
'integration_data' => $data,
|
'integration_data' => $data,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$service = $this->resolveService($integration->integration_code);
|
||||||
|
if ($service) {
|
||||||
|
$service->forTenant($tenantCode)->onSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenantIntegration;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveService(string $integrationCode): ?BaseIntegrationService
|
||||||
|
{
|
||||||
|
switch ($integrationCode) {
|
||||||
|
case 'telepagos':
|
||||||
|
case 'telepagos_homo':
|
||||||
|
return new TelepagosIntegrationService($integrationCode);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Controllers;
|
namespace App\Domains\Purchase\Controllers;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Requests\StorePurchaseRequest;
|
use App\Domains\Purchase\Requests\StorePurchaseRequest;
|
||||||
use App\Domains\Purchase\Resources\PurchaseResource;
|
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class PurchaseController extends Controller
|
class PurchaseController extends Controller
|
||||||
@@ -24,6 +22,9 @@ class PurchaseController extends Controller
|
|||||||
->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
|
->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
|
->when($request->query('status'), function ($query, $status) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
})
|
||||||
->latest()
|
->latest()
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
)->response();
|
)->response();
|
||||||
@@ -55,9 +56,11 @@ class PurchaseController extends Controller
|
|||||||
{
|
{
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
$method = $request->validated('method');
|
$method = $request->validated('method');
|
||||||
|
$totalAmount = $compra->calculateCurrentTotalAmount();
|
||||||
|
|
||||||
$compra->update([
|
$compra->update([
|
||||||
'payment_method' => $method,
|
'payment_method' => $method,
|
||||||
|
'total' => $totalAmount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
@@ -89,16 +92,16 @@ class PurchaseController extends Controller
|
|||||||
$telepagosService->forTenant($tenant->codigo);
|
$telepagosService->forTenant($tenant->codigo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Log::info("Generating QR for purchase ID: {$compra->id}, amount: {$compra->getTotalAmount()}");
|
Log::info("Generating QR for purchase ID: {$compra->id}, amount: {$totalAmount}");
|
||||||
$qrResponse = $telepagosService->generateQr(
|
$qrResponse = $telepagosService->generateQr(
|
||||||
$compra->getTotalAmount(),
|
$totalAmount,
|
||||||
'Compra',
|
'Compra',
|
||||||
"Compra #{$compra->id}"
|
"Compra #{$compra->id}"
|
||||||
);
|
);
|
||||||
|
|
||||||
$telepagosQr = $compra->telepagosQr()->create([
|
$telepagosQr = $compra->telepagosQr()->create([
|
||||||
'qr_order_id' => (string) ($qrResponse['id'] ?? $qrResponse['order_id'] ?? $qrResponse['cashin_id'] ?? ''),
|
'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''),
|
||||||
'qr_code' => $qrResponse['qr'] ?? $qrResponse['qr_code'] ?? $qrResponse['qr_data'] ?? '',
|
'qr_code' => $qrResponse['qr_code'] ?? '',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -120,6 +123,15 @@ class PurchaseController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||||
|
{
|
||||||
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$checkoutService->completePurchase($compra)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
'user_id',
|
'user_id',
|
||||||
'status',
|
'status',
|
||||||
'payment_status',
|
|
||||||
'payment_method',
|
'payment_method',
|
||||||
|
'total',
|
||||||
'dni',
|
'dni',
|
||||||
'telefono',
|
'telefono',
|
||||||
'nombre_apellido',
|
'nombre_apellido',
|
||||||
@@ -27,6 +27,12 @@ class Purchase extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
public const STATUS_CREATED = 'created';
|
||||||
|
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||||
|
public const STATUS_PAID = 'paid';
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -34,6 +40,7 @@ class Purchase extends Model
|
|||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'total' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +94,26 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
|
if ($this->total !== null) {
|
||||||
|
return (float) $this->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->calculateCurrentTotalAmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCurrentTotalAmount(): float
|
||||||
|
{
|
||||||
|
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
|
||||||
|
return (float) $this->getRelation('items')->sum('total');
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->items()->exists()) {
|
if ($this->items()->exists()) {
|
||||||
return (float) $this->items()->sum('total');
|
return (float) $this->items()->sum('total');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->relationLoaded('cart')
|
$cart = $this->relationLoaded('cart')
|
||||||
? $this->getRelation('cart')
|
? $this->getRelation('cart')
|
||||||
: $this->cart()->first();
|
: $this->cart()->with('items.variant.product')->first();
|
||||||
|
|
||||||
if (! $cart) {
|
if (! $cart) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -102,11 +122,17 @@ class Purchase extends Model
|
|||||||
return $cart->getTotalAmount();
|
return $cart->getTotalAmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function finalize(): void
|
public function markAsPendingPayment(): void
|
||||||
{
|
{
|
||||||
$this->update([
|
$this->update([
|
||||||
'status' => 'pagado', // Or the equivalent final status in your system
|
'status' => self::STATUS_PENDING_PAYMENT,
|
||||||
'payment_status' => 'paid',
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsPaid(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_PAID,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domains\Purchase\Requests;
|
namespace App\Domains\Purchase\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StorePurchaseRequest extends FormRequest
|
class StorePurchaseRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -18,7 +17,6 @@ class StorePurchaseRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
|
|
||||||
'cart_id' => ['required', 'integer', 'exists:carritos,id'],
|
'cart_id' => ['required', 'integer', 'exists:carritos,id'],
|
||||||
'dni' => ['required', 'string'],
|
'dni' => ['required', 'string'],
|
||||||
'telefono' => ['required', 'string'],
|
'telefono' => ['required', 'string'],
|
||||||
|
|||||||
@@ -19,15 +19,19 @@ class PurchaseResource extends JsonResource
|
|||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: collect();
|
||||||
|
|
||||||
$subtotal = $items->reduce(
|
$subtotal = $items->isNotEmpty()
|
||||||
|
? $items->reduce(
|
||||||
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad),
|
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad),
|
||||||
0.0,
|
0.0,
|
||||||
);
|
)
|
||||||
|
: (float) ($this->total ?? 0);
|
||||||
|
|
||||||
$total = $items->reduce(
|
$total = $items->isNotEmpty()
|
||||||
|
? $items->reduce(
|
||||||
fn (float $carry, $item): float => $carry + (float) $item->total,
|
fn (float $carry, $item): float => $carry + (float) $item->total,
|
||||||
0.0,
|
0.0,
|
||||||
);
|
)
|
||||||
|
: (float) ($this->total ?? 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
@@ -35,7 +39,6 @@ class PurchaseResource extends JsonResource
|
|||||||
'tenant_codigo' => $this->tenant_codigo,
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
'user_id' => $this->user_id,
|
'user_id' => $this->user_id,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'payment_status' => $this->payment_status,
|
|
||||||
'payment_method' => $this->payment_method,
|
'payment_method' => $this->payment_method,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
|||||||
@@ -29,20 +29,66 @@ class CheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cartItems->load('variant.product');
|
||||||
|
$cart->setRelation('items', $cartItems);
|
||||||
|
$totalAmount = $cart->getTotalAmount();
|
||||||
|
|
||||||
|
/** @var Purchase|null $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->where('cart_id', $cart->getKey())
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($purchase === null) {
|
||||||
/** @var Purchase $purchase */
|
/** @var Purchase $purchase */
|
||||||
$purchase = Purchase::query()->updateOrCreate(
|
$purchase = Purchase::query()->create([
|
||||||
[
|
...$purchaseData,
|
||||||
'cart_id' => $cart->getKey(),
|
'cart_id' => $cart->getKey(),
|
||||||
'status' => 'pending',
|
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
],
|
'status' => Purchase::STATUS_CREATED,
|
||||||
[
|
|
||||||
...$purchaseData,
|
|
||||||
'payment_status' => 'pending',
|
|
||||||
'payment_method' => null,
|
'payment_method' => null,
|
||||||
]
|
'total' => $totalAmount,
|
||||||
);
|
]);
|
||||||
|
} else {
|
||||||
|
$purchase->fill([
|
||||||
|
...$purchaseData,
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
'payment_method' => null,
|
||||||
|
'total' => $totalAmount,
|
||||||
|
]);
|
||||||
|
$purchase->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completePurchase(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($purchase): Purchase {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($purchase->payment_method === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'payment_method' => 'The purchase payment method must be selected before finalizing.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) {
|
||||||
|
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update([
|
||||||
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
|
]);
|
||||||
|
|
||||||
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
|
return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
use App\Domains\Purchase\Controllers\PurchaseController;
|
use App\Domains\Purchase\Controllers\PurchaseController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
|
Route::prefix('tenants/{tenant:codigo}')->middleware(['auth:sanctum', \App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class])->group(function (): void {
|
||||||
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
|
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
|
||||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||||
|
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table) {
|
||||||
|
$table->decimal('total', 10, 2)->default(0)->after('payment_method');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(100, function ($compras): void {
|
||||||
|
foreach ($compras as $compra) {
|
||||||
|
$itemsQuery = DB::table('compra_items')
|
||||||
|
->where('compra_id', $compra->id);
|
||||||
|
$hasItems = $itemsQuery->exists();
|
||||||
|
$itemsTotal = $itemsQuery->sum('total');
|
||||||
|
|
||||||
|
$total = (float) $itemsTotal;
|
||||||
|
|
||||||
|
if (! $hasItems && $compra->cart_id !== null) {
|
||||||
|
$cartTotal = DB::table('carrito_items as carrito_item')
|
||||||
|
->join('productos_variantes as variante', 'variante.id', '=', 'carrito_item.producto_variante_id')
|
||||||
|
->join('productos as producto', 'producto.id', '=', 'variante.producto_id')
|
||||||
|
->where('carrito_item.cart_id', $compra->cart_id)
|
||||||
|
->selectRaw('COALESCE(SUM(producto.precio * carrito_item.cantidad), 0) as total')
|
||||||
|
->value('total');
|
||||||
|
|
||||||
|
$total = (float) ($cartTotal ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->where('id', $compra->id)
|
||||||
|
->update([
|
||||||
|
'total' => number_format($total, 2, '.', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('total');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
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('status')->default('created')->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Schema::hasColumn('compras', 'payment_status')) {
|
||||||
|
DB::table('compras')
|
||||||
|
->where('payment_status', 'approved')
|
||||||
|
->update(['status' => 'paid']);
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->where('payment_status', 'rejected')
|
||||||
|
->where('status', '!=', 'cancelled')
|
||||||
|
->update(['status' => 'rejected']);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->whereNull('payment_method')
|
||||||
|
->update(['status' => 'created']);
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->whereNotNull('payment_method')
|
||||||
|
->update(['status' => 'pending_payment']);
|
||||||
|
|
||||||
|
if (Schema::hasColumn('compras', 'payment_status')) {
|
||||||
|
Schema::table('compras', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('payment_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('compras', 'payment_status')) {
|
||||||
|
Schema::table('compras', function (Blueprint $table) {
|
||||||
|
$table->string('payment_status')->default('pending')->after('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->where('status', 'paid')
|
||||||
|
->update([
|
||||||
|
'status' => 'paid',
|
||||||
|
'payment_status' => 'approved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->where('status', 'rejected')
|
||||||
|
->update([
|
||||||
|
'status' => 'pending',
|
||||||
|
'payment_status' => 'rejected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->whereIn('status', ['created', 'pending_payment'])
|
||||||
|
->update([
|
||||||
|
'status' => 'pending',
|
||||||
|
'payment_status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table) {
|
||||||
|
$table->string('status')->default('pending')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -20,12 +20,14 @@
|
|||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
|
||||||
|
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>
|
||||||
|
<env name="APP_PACKAGES_CACHE" value="bootstrap/cache/phpunit-packages.php"/>
|
||||||
|
<env name="APP_ROUTES_CACHE" value="bootstrap/cache/phpunit-routes.php"/>
|
||||||
|
<env name="APP_SERVICES_CACHE" value="bootstrap/cache/phpunit-services.php"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
|
||||||
<env name="DB_URL" value=""/>
|
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
|||||||
246
tests/Feature/Integration/TelepagosWebhookTest.php
Normal file
246
tests/Feature/Integration/TelepagosWebhookTest.php
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Integration;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Models\Product;
|
||||||
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\TenantIntegration;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TelepagosWebhookTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['services.integrations.secret' => 'base64:' . base64_encode(random_bytes(32))]);
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$this->configureTelepagosIntegration($tenant);
|
||||||
|
|
||||||
|
$matchingUser = User::factory()->create([
|
||||||
|
'email' => 'buyer@example.com',
|
||||||
|
]);
|
||||||
|
$newerUser = User::factory()->create([
|
||||||
|
'email' => 'buyer-2@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
|
||||||
|
$matchingPurchase = $this->createPendingTransferPurchase(
|
||||||
|
$tenant,
|
||||||
|
$matchingUser->id,
|
||||||
|
$variant->id,
|
||||||
|
1,
|
||||||
|
'12345678'
|
||||||
|
);
|
||||||
|
|
||||||
|
$newerPurchase = $this->createPendingTransferPurchase(
|
||||||
|
$tenant,
|
||||||
|
$newerUser->id,
|
||||||
|
$variant->id,
|
||||||
|
2,
|
||||||
|
'12345678'
|
||||||
|
);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||||
|
'status' => 'ok',
|
||||||
|
'token' => 'test-token',
|
||||||
|
'expires_at' => now()->addHour()->toIso8601String(),
|
||||||
|
]),
|
||||||
|
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
|
||||||
|
'status' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'amount' => 50,
|
||||||
|
'operation_id' => 1,
|
||||||
|
'transaction_id' => 'tx-123',
|
||||||
|
'buyer' => [
|
||||||
|
'cuit' => '20123456789',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/webhooks/telepagos/sonder', [
|
||||||
|
'id' => '6351',
|
||||||
|
])->assertOk()->assertJsonPath('status', 'success');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $matchingPurchase->id,
|
||||||
|
'status' => Purchase::STATUS_PAID,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
'total' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $newerPurchase->id,
|
||||||
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
'total' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('telepagos_payments', [
|
||||||
|
'compra_id' => $matchingPurchase->id,
|
||||||
|
'amount' => 50,
|
||||||
|
'operation_id' => 1,
|
||||||
|
'transaction_id' => 'tx-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('telepagos_payments', [
|
||||||
|
'compra_id' => $newerPurchase->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compra_items', [
|
||||||
|
'compra_id' => $matchingPurchase->id,
|
||||||
|
'producto_variante_id' => $variant->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
'total' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('compra_items', [
|
||||||
|
'compra_id' => $newerPurchase->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSoftDeleted('carritos', [
|
||||||
|
'id' => $matchingPurchase->cart_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPendingTransferPurchase(
|
||||||
|
Tenant $tenant,
|
||||||
|
int $userId,
|
||||||
|
int $variantId,
|
||||||
|
int $quantity,
|
||||||
|
string $dni,
|
||||||
|
): Purchase {
|
||||||
|
$cart = Cart::query()->create([
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart->addItem($variantId, $quantity);
|
||||||
|
|
||||||
|
/** @var CheckoutService $checkoutService */
|
||||||
|
$checkoutService = app(CheckoutService::class);
|
||||||
|
|
||||||
|
$purchase = $checkoutService->startCheckout($tenant, $userId, [
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
'dni' => $dni,
|
||||||
|
'telefono' => '+54 9 341 555-4321',
|
||||||
|
'nombre_apellido' => 'Juan Perez',
|
||||||
|
'email' => 'juan.perez@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchase->update([
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$checkoutService->completePurchase($purchase);
|
||||||
|
|
||||||
|
return $purchase->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configureTelepagosIntegration(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
Integration::create([
|
||||||
|
'integration_code' => 'telepagos_homo',
|
||||||
|
'name' => 'Telepagos',
|
||||||
|
'url' => 'https://api.telepagos.com.ar',
|
||||||
|
'integration_data_schema' => [
|
||||||
|
'username' => 'required|string',
|
||||||
|
'password' => 'required|string',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
TenantIntegration::create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'integration_code' => 'telepagos_homo',
|
||||||
|
'integration_data' => [
|
||||||
|
'username' => 'user123',
|
||||||
|
'password' => 'pass123',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createVariantForTenant(
|
||||||
|
string $tenantCode,
|
||||||
|
int $stock,
|
||||||
|
string $price,
|
||||||
|
string $slugPrefix = 'shirt',
|
||||||
|
): ProductVariant {
|
||||||
|
$category = \App\Domains\Catalog\Models\Category::query()->create([
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'nombre' => "{$slugPrefix} category {$tenantCode}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = Product::query()->create([
|
||||||
|
'tenant_codigo' => $tenantCode,
|
||||||
|
'categoria_id' => $category->id,
|
||||||
|
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
|
||||||
|
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
|
||||||
|
'descripcion' => 'Test product',
|
||||||
|
'precio' => $price,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ProductVariant::query()->create([
|
||||||
|
'producto_id' => $product->id,
|
||||||
|
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
|
||||||
|
'nombre' => ucfirst($slugPrefix).' Variant',
|
||||||
|
'stock' => $stock,
|
||||||
|
'descripcion' => 'Test variant',
|
||||||
|
'precio' => $price,
|
||||||
|
])->load('product');
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
|
|||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\Product;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -74,8 +75,10 @@ class StorePurchaseTest extends TestCase
|
|||||||
$response->assertJsonPath('data.nombre_apellido', 'Juan Perez');
|
$response->assertJsonPath('data.nombre_apellido', 'Juan Perez');
|
||||||
$response->assertJsonPath('data.email', 'juan.perez@example.com');
|
$response->assertJsonPath('data.email', 'juan.perez@example.com');
|
||||||
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
||||||
|
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
||||||
$response->assertJsonPath('data.items', []);
|
$response->assertJsonPath('data.items', []);
|
||||||
$response->assertJsonPath('data.total', '0.00');
|
$response->assertJsonPath('data.subtotal', '100.00');
|
||||||
|
$response->assertJsonPath('data.total', '100.00');
|
||||||
|
|
||||||
$purchaseId = $response->json('data.id');
|
$purchaseId = $response->json('data.id');
|
||||||
|
|
||||||
@@ -88,6 +91,8 @@ class StorePurchaseTest extends TestCase
|
|||||||
'telefono' => '+54 9 341 555-4321',
|
'telefono' => '+54 9 341 555-4321',
|
||||||
'nombre_apellido' => 'Juan Perez',
|
'nombre_apellido' => 'Juan Perez',
|
||||||
'email' => 'juan.perez@example.com',
|
'email' => 'juan.perez@example.com',
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
'total' => 100,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseMissing('compra_items', [
|
$this->assertDatabaseMissing('compra_items', [
|
||||||
'compra_id' => $purchaseId,
|
'compra_id' => $purchaseId,
|
||||||
@@ -97,10 +102,6 @@ class StorePurchaseTest extends TestCase
|
|||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseMissing('carritos', [
|
|
||||||
'id' => $cartId,
|
|
||||||
'deleted_at' => null,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('carrito_items', [
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
'cart_id' => $cartId,
|
'cart_id' => $cartId,
|
||||||
'producto_variante_id' => $variant->id,
|
'producto_variante_id' => $variant->id,
|
||||||
@@ -113,6 +114,51 @@ class StorePurchaseTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_moves_a_created_purchase_to_pending_payment_when_finalized(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'buyer@example.com',
|
||||||
|
]);
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
|
||||||
|
$cartId = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/sonder/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->json('data.id');
|
||||||
|
|
||||||
|
$purchaseId = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/sonder/compras', [
|
||||||
|
'cart_id' => $cartId,
|
||||||
|
'dni' => '987654321',
|
||||||
|
'telefono' => '+54 9 341 555-4321',
|
||||||
|
'nombre_apellido' => 'Juan Perez',
|
||||||
|
'email' => 'juan.perez@example.com',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->json('data.id');
|
||||||
|
|
||||||
|
Purchase::query()
|
||||||
|
->whereKey($purchaseId)
|
||||||
|
->update([
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->postJson("/api/tenants/sonder/compras/{$purchaseId}/complete")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $purchaseId,
|
||||||
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_a_cart_from_another_user(): void
|
public function test_it_rejects_a_cart_from_another_user(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
|||||||
Reference in New Issue
Block a user