Integra Battlepay con SumUp (órdenes battlepay_orders)
- pagos_sumup.py: crear_checkout_battlepay(reference,...) y obtener_checkout() - battlepay_view: lista las órdenes PENDING de la cuenta de juego - battlepay_pay_view: crea checkout SumUp para una orden concreta (checkout_reference=reference) - sumup_webhook: marca la orden PAID (valida estado contra la API de SumUp si falta) - plantillas account/battlepay.html y partials/pago_sumup.html - sql/battlepay_sumup.sql (tablas battlepay_orders/battlepay_price) y docs actualizados Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-7
@@ -81,14 +81,42 @@ guardan `bnet_id` y `bnet_email` para cambios de contraseña/email.
|
||||
3. Registrar una cuenta desde la web y comprobar el email de activación.
|
||||
4. Activar y **probar login en el cliente 3.4.3** (ver nota de verificación).
|
||||
|
||||
## Battlepay / SumUp (tienda del cliente)
|
||||
|
||||
Integración de la tienda nativa del cliente (Battlepay) con la pasarela SumUp,
|
||||
usando las tablas `battlepay_orders` / `battlepay_price` del source
|
||||
(`sql/battlepay_sumup.sql`, aplicar sobre la BD `auth`).
|
||||
|
||||
**Flujo:**
|
||||
1. En el juego, el jugador pulsa *Comprar*; si el producto tiene precio en
|
||||
`battlepay_price`, el worldserver crea una orden `PENDING` en
|
||||
`battlepay_orders` (con `account_id` = id de cuenta de juego y una
|
||||
`reference` única).
|
||||
2. En la web, `/es/battlepay/` lista las órdenes `PENDING` de la cuenta de juego
|
||||
seleccionada y permite pagarlas.
|
||||
3. `/es/battlepay/pay/` crea un checkout de SumUp usando la `reference` de la
|
||||
orden como `checkout_reference`.
|
||||
4. El webhook `/es/sumup-webhook/` marca la orden como `PAID`
|
||||
(`paid_at = UNIX_TIMESTAMP()`), validando el estado contra la API de SumUp si
|
||||
hace falta.
|
||||
5. El worldserver entrega el producto y marca la orden `DELIVERED`.
|
||||
|
||||
**Código:** `home/pagos_sumup.py` (`crear_checkout_battlepay`, `obtener_checkout`),
|
||||
vistas `battlepay_view` / `battlepay_pay_view` / `sumup_webhook` en `home/views.py`,
|
||||
plantillas `account/battlepay.html` y `partials/pago_sumup.html`.
|
||||
|
||||
> Nota: `battlepay_orders.account_id` es el **id de cuenta de juego**
|
||||
> (`account.id`), mientras que `battlePayCredits` (créditos del sistema clásico
|
||||
> de puntos) vive en la **cuenta bnet** (`battlenet_accounts.id`); se unen por
|
||||
> `account.battlenet_account`. Esta pasarela usa las órdenes en €, no los créditos.
|
||||
|
||||
**Configuración necesaria:**
|
||||
- Aplicar `sql/battlepay_sumup.sql` en la BD `auth` y rellenar `battlepay_price`
|
||||
con tus `product_id` reales.
|
||||
- Configurar el webhook de SumUp apuntando a `https://<tu-dominio>/es/sumup-webhook/`.
|
||||
- Enlazar `/es/battlepay/` desde el menú de «Mi cuenta» (opcional).
|
||||
|
||||
## Pendiente / opcional
|
||||
|
||||
- **Battlepay/SumUp**: el source trae `battlePayCredits` (en `battlenet_accounts`)
|
||||
y las tablas `battlepay_orders` / `battlepay_price` (`sql/custom/battlepay_sumup.sql`).
|
||||
Ojo: `battlepay_orders.account_id` es el **id de cuenta de juego**
|
||||
(`account.id`), mientras que `battlePayCredits` está en la **cuenta bnet**
|
||||
(`battlenet_accounts.id`); se unen por `account.battlenet_account`. La
|
||||
integración de la pasarela con estas tablas se puede abordar como paso
|
||||
siguiente.
|
||||
- Mostrar el email (en lugar de `<bnetId>#1`) como nombre visible en
|
||||
«Mi cuenta» es un retoque cosmético opcional.
|
||||
|
||||
+44
-17
@@ -6,40 +6,67 @@ SUMUP_CLIENT_ID = settings.SUMUP_CLIENT_ID
|
||||
SUMUP_CLIENT_SECRET = settings.SUMUP_CLIENT_SECRET
|
||||
SUMUP_EMAIL = settings.SUMUP_MERCHANT_EMAIL
|
||||
|
||||
API_BASE = "https://api.sumup.com"
|
||||
|
||||
|
||||
def obtener_token():
|
||||
"""Obtiene un access token de SumUp"""
|
||||
url = "https://api.sumup.com/token"
|
||||
"""Obtiene un access token de SumUp (client_credentials)."""
|
||||
url = f"{API_BASE}/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": SUMUP_CLIENT_ID,
|
||||
"client_secret": SUMUP_CLIENT_SECRET
|
||||
"client_secret": SUMUP_CLIENT_SECRET,
|
||||
}
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data)
|
||||
response = requests.post(url, headers=headers, data=data, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("access_token")
|
||||
else:
|
||||
raise Exception(f"Error al obtener token: {response.json()}")
|
||||
raise Exception(f"Error al obtener token: {response.text}")
|
||||
|
||||
def crear_checkout(monto, moneda="EUR"):
|
||||
"""Crea un pago en SumUp y devuelve el checkout_id"""
|
||||
|
||||
def crear_checkout_battlepay(reference, monto, descripcion, moneda="EUR", return_url=None):
|
||||
"""
|
||||
Crea un checkout de SumUp para una orden Battlepay concreta.
|
||||
|
||||
``reference`` es la referencia única de la orden (battlepay_orders.reference);
|
||||
se usa como ``checkout_reference`` para poder mapear el webhook a la orden.
|
||||
Devuelve el JSON del checkout (incluye ``id``).
|
||||
"""
|
||||
access_token = obtener_token()
|
||||
url = "https://api.sumup.com/v0.1/checkouts"
|
||||
url = f"{API_BASE}/v0.1/checkouts"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
data = {
|
||||
"checkout_reference": "pedido-12345",
|
||||
"amount": monto,
|
||||
"checkout_reference": reference,
|
||||
"amount": float(monto),
|
||||
"currency": moneda,
|
||||
"pay_to_email": SUMUP_EMAIL,
|
||||
"description": "Pago de prueba"
|
||||
"description": descripcion,
|
||||
}
|
||||
if return_url:
|
||||
data["return_url"] = return_url
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
response = requests.post(url, headers=headers, json=data, timeout=15)
|
||||
if response.status_code == 201:
|
||||
return response.json().get("id") # checkout_id
|
||||
else:
|
||||
raise Exception(f"Error al crear checkout: {response.json()}")
|
||||
return response.json()
|
||||
raise Exception(f"Error al crear checkout: {response.text}")
|
||||
|
||||
|
||||
def obtener_checkout(checkout_id):
|
||||
"""Consulta un checkout por id. Devuelve su JSON (status, checkout_reference...)."""
|
||||
access_token = obtener_token()
|
||||
url = f"{API_BASE}/v0.1/checkouts/{checkout_id}"
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = requests.get(url, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
raise Exception(f"Error al consultar checkout: {response.text}")
|
||||
|
||||
|
||||
def crear_checkout(monto, moneda="EUR"):
|
||||
"""Compatibilidad: checkout genérico de prueba. Devuelve el checkout_id."""
|
||||
checkout = crear_checkout_battlepay("pedido-prueba", monto, "Pago de prueba", moneda)
|
||||
return checkout.get("id")
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
{% include 'partials/head.html' %}
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'partials/video.html' %}
|
||||
|
||||
<div class="main-page">
|
||||
<div class="middle-content">
|
||||
<div class="body-content">
|
||||
<div class="title-content">
|
||||
<h1>Tienda del cliente (Battlepay)</h1>
|
||||
</div>
|
||||
<div class="box-content">
|
||||
<div class="body-box-content centered">
|
||||
<p>Compras pendientes de pago de la cuenta <strong>{{ username }}</strong>.</p>
|
||||
<br>
|
||||
|
||||
{% if orders %}
|
||||
<table class="middle-center-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th>Precio</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.product_name }}</td>
|
||||
<td>{{ order.price_eur }} €</td>
|
||||
<td>
|
||||
<form action="{% url 'battlepay_pay' %}" method="POST" accept-charset="utf-8">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="reference" value="{{ order.reference }}">
|
||||
<button type="submit" class="login-button">Pagar con SumUp</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No tienes compras pendientes.</p>
|
||||
<br>
|
||||
<p>Para comprar, pulsa <strong>Comprar</strong> en la tienda del cliente dentro del juego;
|
||||
la compra aparecerá aquí para completar el pago.</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<p><a href="my-account">Volver a mi cuenta</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/social.html' %}
|
||||
{% include 'partials/footer.html' %}
|
||||
{% include 'partials/final.html' %}
|
||||
@@ -10,7 +10,12 @@
|
||||
|
||||
{% if error %}
|
||||
<p style="color: red;">Error: {{ error }}</p>
|
||||
<p><a href="battlepay">Volver a mis compras</a></p>
|
||||
{% else %}
|
||||
{% if product_name %}
|
||||
<p>Producto: <strong>{{ product_name }}</strong></p>
|
||||
<p>Importe: <strong>{{ price_eur }} €</strong></p>
|
||||
{% endif %}
|
||||
<div id="sumup-card"></div>
|
||||
|
||||
<script>
|
||||
@@ -18,8 +23,8 @@
|
||||
checkoutId: "{{ checkout_id }}",
|
||||
onResponse: function (type, body) {
|
||||
if (type === 'success') {
|
||||
alert("Pago exitoso. ID de transacción: " + body.transaction_code);
|
||||
console.log("Pago exitoso:", body);
|
||||
alert("Pago exitoso. El producto se entregará en el juego en breve.");
|
||||
window.location = "battlepay";
|
||||
} else if (type === 'error') {
|
||||
alert("Error en el pago: " + body.message);
|
||||
console.error("Error en el pago:", body);
|
||||
@@ -27,6 +32,7 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<p><a href="battlepay">Cancelar y volver</a></p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -77,6 +77,8 @@ urlpatterns = [
|
||||
path('guild-success/', views.guild_rename_success_view, name='guild-success'),
|
||||
path('guild-cancel/', views.guild_rename_cancel_view, name='guild-cancel'),
|
||||
path("pagar-sumup/", views.pagar_sumup, name="pagar_sumup"),
|
||||
path("battlepay/", views.battlepay_view, name="battlepay"),
|
||||
path("battlepay/pay/", views.battlepay_pay_view, name="battlepay_pay"),
|
||||
path("sumup-webhook/", views.sumup_webhook, name="sumup_webhook"),
|
||||
|
||||
]
|
||||
|
||||
+120
-32
@@ -5,7 +5,7 @@ from django.http import HttpResponse, JsonResponse
|
||||
from django.db import connections
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from .pagos_sumup import crear_checkout
|
||||
from .pagos_sumup import crear_checkout, crear_checkout_battlepay, obtener_checkout
|
||||
from django import forms
|
||||
import stripe
|
||||
from .pagos_stripe import create_checkout_session
|
||||
@@ -2979,44 +2979,132 @@ def novawow_players_view(request):
|
||||
})
|
||||
|
||||
|
||||
def pagar_sumup(request):
|
||||
"""Genera un pago de prueba y lo muestra en el widget"""
|
||||
try:
|
||||
checkout_id = crear_checkout(10.00) # Pago de 10 EUR
|
||||
pedido = Pedido.objects.create(email_cliente="cliente@example.com", monto=10.00)
|
||||
return render(request, "account/pago_sumup.html", {"checkout_id": checkout_id, "pedido": pedido})
|
||||
except Exception as e:
|
||||
return render(request, "account/pago_sumup.html", {"error": str(e)})
|
||||
# ---------------------------------------------------------------------------
|
||||
# Battlepay (tienda nativa del cliente) pagado vía SumUp
|
||||
#
|
||||
# Flujo: el worldserver crea una orden PENDING en `battlepay_orders` cuando el
|
||||
# jugador pulsa "Comprar" en la tienda del cliente y el producto tiene precio en
|
||||
# `battlepay_price`. El portal muestra esas órdenes, cobra por SumUp y las marca
|
||||
# PAID; el worldserver las entrega y las marca DELIVERED.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_pending_battlepay_orders(account_id):
|
||||
"""Órdenes Battlepay PENDING de una cuenta de juego."""
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, reference, product_id, product_name, price_eur, status, created_at "
|
||||
"FROM battlepay_orders WHERE account_id = %s AND status = 'PENDING' "
|
||||
"ORDER BY created_at DESC",
|
||||
[account_id],
|
||||
)
|
||||
cols = [c[0] for c in cursor.description]
|
||||
return [dict(zip(cols, row)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def _mark_order_paid_by_reference(reference):
|
||||
"""Marca una orden como PAID solo si estaba PENDING. Devuelve filas afectadas."""
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE battlepay_orders SET status = 'PAID', paid_at = UNIX_TIMESTAMP() "
|
||||
"WHERE reference = %s AND status = 'PENDING'",
|
||||
[reference],
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def battlepay_view(request):
|
||||
"""Lista las órdenes Battlepay pendientes de la cuenta de juego seleccionada."""
|
||||
account_id = request.session.get('account_id')
|
||||
if not account_id:
|
||||
if request.session.get('bnet_id'):
|
||||
return redirect('select-account')
|
||||
return redirect('login')
|
||||
|
||||
orders = get_pending_battlepay_orders(account_id)
|
||||
return render(request, 'account/battlepay.html', {
|
||||
'orders': orders,
|
||||
'username': request.session.get('username'),
|
||||
})
|
||||
|
||||
|
||||
def battlepay_pay_view(request):
|
||||
"""Crea un checkout de SumUp para pagar una orden Battlepay pendiente."""
|
||||
account_id = request.session.get('account_id')
|
||||
if not account_id:
|
||||
return redirect('login')
|
||||
if request.method != 'POST':
|
||||
return redirect('battlepay')
|
||||
|
||||
reference = request.POST.get('reference', '').strip()
|
||||
|
||||
# Validar que la orden pertenece a la cuenta y sigue pendiente
|
||||
with connections['acore_auth'].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT product_name, price_eur FROM battlepay_orders "
|
||||
"WHERE reference = %s AND account_id = %s AND status = 'PENDING'",
|
||||
[reference, account_id],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return render(request, 'account/pago_sumup.html', {'error': 'Orden no encontrada o ya procesada.'})
|
||||
|
||||
product_name, price_eur = row
|
||||
try:
|
||||
checkout = crear_checkout_battlepay(reference, price_eur, product_name)
|
||||
except Exception as e:
|
||||
return render(request, 'account/pago_sumup.html', {'error': str(e)})
|
||||
|
||||
return render(request, 'account/pago_sumup.html', {
|
||||
'checkout_id': checkout.get('id'),
|
||||
'reference': reference,
|
||||
'product_name': product_name,
|
||||
'price_eur': price_eur,
|
||||
})
|
||||
|
||||
|
||||
def pagar_sumup(request):
|
||||
"""Alias de compatibilidad: envía al listado de órdenes Battlepay."""
|
||||
return redirect('battlepay')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def sumup_webhook(request):
|
||||
"""Recibe el webhook de SumUp y actualiza la base de datos"""
|
||||
if request.method == "POST":
|
||||
"""Webhook de SumUp: marca la orden Battlepay como PAID al completarse el pago."""
|
||||
if request.method != "POST":
|
||||
return JsonResponse({"error": "Método no permitido"}, status=405)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
# Verificar si el evento es un pago exitoso
|
||||
if data.get("event_type") == "transaction.successful":
|
||||
transaction_id = data.get("data", {}).get("transaction_id")
|
||||
amount = data.get("data", {}).get("amount")
|
||||
email_cliente = data.get("data", {}).get("customer_email")
|
||||
|
||||
# Buscar el pedido y actualizarlo como pagado
|
||||
pedido = Pedido.objects.filter(email_cliente=email_cliente, monto=amount).first()
|
||||
if pedido:
|
||||
pedido.transaction_id = transaction_id
|
||||
pedido.estado = "Pagado"
|
||||
pedido.save()
|
||||
return JsonResponse({"message": "Pago actualizado"}, status=200)
|
||||
else:
|
||||
return JsonResponse({"error": "Pedido no encontrado"}, status=404)
|
||||
|
||||
return JsonResponse({"error": "Evento no reconocido"}, status=400)
|
||||
|
||||
data = json.loads(request.body or b'{}')
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"error": "JSON inválido"}, status=400)
|
||||
|
||||
return JsonResponse({"error": "Método no permitido"}, status=405)
|
||||
payload = data.get('payload') or data.get('data') or {}
|
||||
reference = data.get('checkout_reference') or payload.get('checkout_reference')
|
||||
status = (data.get('status') or payload.get('status') or '').upper()
|
||||
checkout_id = data.get('id') or payload.get('checkout_id') or payload.get('id')
|
||||
event_type = (data.get('event_type') or '').lower()
|
||||
|
||||
# Si falta la referencia o el estado, consultarlo a la API de SumUp
|
||||
if (not reference or status not in ('PAID', 'SUCCESSFUL')) and checkout_id:
|
||||
try:
|
||||
checkout = obtener_checkout(checkout_id)
|
||||
reference = reference or checkout.get('checkout_reference')
|
||||
status = (checkout.get('status') or status).upper()
|
||||
except Exception as e:
|
||||
logger.error("SumUp webhook: no se pudo consultar el checkout %s: %s", checkout_id, e)
|
||||
|
||||
pagado = status in ('PAID', 'SUCCESSFUL') or event_type == 'transaction.successful'
|
||||
|
||||
if reference and pagado:
|
||||
try:
|
||||
afectadas = _mark_order_paid_by_reference(reference)
|
||||
except Exception as e:
|
||||
logger.error("SumUp webhook: error actualizando la orden %s: %s", reference, e)
|
||||
return JsonResponse({"error": "Error interno"}, status=500)
|
||||
if afectadas:
|
||||
return JsonResponse({"message": "Orden marcada como pagada"}, status=200)
|
||||
return JsonResponse({"message": "Orden no pendiente o inexistente"}, status=200)
|
||||
|
||||
return JsonResponse({"message": "Evento ignorado"}, status=200)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- Battlepay nativo → pasarela SumUp (NovaWeb)
|
||||
-- Se aplica sobre la base de datos `auth` (LoginDatabase).
|
||||
--
|
||||
-- Flujo:
|
||||
-- 1) El jugador pulsa "Comprar" en la tienda nativa del cliente.
|
||||
-- 2) El worldserver (SendMakePurchase) busca el producto en `battlepay_price`.
|
||||
-- Si tiene precio en €, crea una orden PENDING aquí y NO exige puntos.
|
||||
-- 3) El cliente abre /login/sso → /es/checkout (NovaWeb) y paga con SumUp.
|
||||
-- 4) NovaWeb marca la orden PAID (creditPaymentIfPaid).
|
||||
-- 5) El worldserver (BattlepayManager::Update) ve la orden PAID del jugador
|
||||
-- conectado y la entrega con ProcessDelivery → la marca DELIVERED.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Órdenes de compra (las escribe el worldserver, las lee/actualiza NovaWeb).
|
||||
CREATE TABLE IF NOT EXISTS `battlepay_orders` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`reference` VARCHAR(64) NOT NULL,
|
||||
`account_id` INT NOT NULL, -- auth.account.id (cuenta de juego)
|
||||
`product_id` INT NOT NULL, -- Battlepay ProductID nativo
|
||||
`product_name` VARCHAR(120) NOT NULL DEFAULT '',
|
||||
`price_eur` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING | PAID | DELIVERED
|
||||
`created_at` INT NOT NULL,
|
||||
`paid_at` INT NULL,
|
||||
`delivered_at` INT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_reference` (`reference`),
|
||||
KEY `idx_account_status` (`account_id`, `status`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Mapa producto nativo → precio real en €. Si un ProductID NO está aquí (o
|
||||
-- enabled=0), el worldserver usa el flujo clásico de puntos para ese producto.
|
||||
CREATE TABLE IF NOT EXISTS `battlepay_price` (
|
||||
`product_id` INT NOT NULL,
|
||||
`product_name` VARCHAR(120) NOT NULL DEFAULT '',
|
||||
`price_eur` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Ejemplo (ajusta product_id al de tu tienda nativa):
|
||||
INSERT INTO `battlepay_price` (`product_id`, `product_name`, `price_eur`, `enabled`)
|
||||
VALUES (54811, 'Corcel Celestial', 5.00, 1)
|
||||
ON DUPLICATE KEY UPDATE `product_name`=VALUES(`product_name`), `price_eur`=VALUES(`price_eur`);
|
||||
Reference in New Issue
Block a user