Apuntes DAM
Volver al inicio

Factura de un carrito con IVA y promociones

Ejercicio de PHPDifícilUnos 70 minutos

Convierte el carrito de una tienda online en una factura: valida cada línea, aplica un 3x2 y un descuento por importe, calcula el IVA de cada tipo y alinea la tabla en columnas. Arrays asociativos, validación, cálculos en céntimos y formato de importes en PHP.

  • Arrays asociativos
  • explode, trim y array_map
  • Validación de datos
  • Céntimos en enteros
  • sprintf y number_format
  • mb_str_pad

Enunciado

Una tienda online pequeña guarda el carrito de cada cliente como texto, con una línea por producto: nombre, categoría, precio sin IVA y unidades. Antes de cobrar, necesita un programa que convierta ese carrito en una factura clara, con las promociones aplicadas y el IVA desglosado.

Para simplificar, la tienda agrupa sus productos en tres categorías y cada una tiene un único tipo de IVA (en la realidad hay alimentos al 4 % y otros al 10 %, pero aquí no hace falta afinar tanto). Además tiene dos promociones: un 3x2 en alimentación y un 5 % de descuento en los productos de la categoría general cuando la compra es grande.

El carrito lo rellena un formulario antiguo y a veces llegan líneas incorrectas. El programa no debe fallar por ellas: avisa de cada una y factura el resto.

Como en cualquier programa que maneje dinero, los importes se calculan en céntimos enteros. Sumar euros con decimales en coma flotante acumula pequeños errores (0.1 + 0.2 no es exactamente 0.3), y en una factura un céntimo de diferencia es un error.

Qué tiene que hacer el programa

  1. Cada línea del carrito es nombre;categoria;precio;unidades. El precio no lleva IVA y usa punto decimal. Se ignoran las líneas vacías y los espacios al principio y al final de cada campo.
  2. Una línea no es válida si no tiene exactamente 4 campos, si la categoría no es libros, alimentacion ni general, si el precio no es un número mayor o igual que 0 o si las unidades no son un número entero mayor que 0. Por cada una escribe Aviso: línea N ignorada, con N el número de línea de la entrada (las vacías también cuentan). Los avisos salen antes que la factura.
  3. Si no queda ningún producto válido, escribe Carrito vacío y termina.
  4. La tabla empieza con la cabecera y tiene una línea por producto, en el orden del carrito: el nombre en 22 caracteres alineado a la izquierda (contando letras, no bytes: «Lámpara» ocupa 7), y alineados a la derecha las unidades en 5 caracteres, el precio unitario en 10 y el importe (unidades × precio) en 11. La cabecera usa las mismas anchuras: Producto, Uds, Precio e Importe.
  5. 3x2 en alimentación: por cada 3 unidades de un mismo producto de alimentación, una es gratis (unidades gratis = unidades ÷ 3, división entera). Por cada producto con unidades gratis, escribe 3x2 en nombre: -X €.
  6. Descuento por importe: si la base de todo el carrito, ya con el 3x2 aplicado, supera los 100 € (estrictamente), los productos de la categoría general tienen un 5 % de descuento sobre su base, redondeado al céntimo. Si ese descuento es mayor que 0, escribe Descuento del 5 % en general: -X €.
  7. Si hay alguna promoción, se escriben bajo la línea Promociones: con dos espacios delante: primero los 3x2 en el orden del carrito y después el descuento.
  8. El resumen tiene la Base imponible: (la suma de las bases de las tres categorías tras las promociones), una línea IVA T %: por cada categoría con base mayor que 0, en el orden 4 %, 10 % y 21 %, y el TOTAL: (base más cuotas). La cuota de cada tipo es su base × T ÷ 100, redondeada al céntimo. Cada línea lleva la etiqueta en 16 caracteres a la izquierda, el importe en 12 a la derecha, un espacio y €.
  9. Todos los importes se escriben con dos decimales, coma decimal y punto de millar, por ejemplo 1.200,00. Los redondeos son al céntimo más cercano y las mitades suben (como hace round en PHP).

Entrada

Una línea por producto: nombre;categoria;precio;unidades. Puede haber líneas vacías o mal escritas.

Datos de referencia

Categorías de la tienda
CategoríaIVAPromoción
libros4 %—
alimentacion10 %3x2 en cada producto
general21 %5 % de descuento si la base del carrito supera 100 €

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Un carrito con promociones

Entrada

Leche entera;alimentacion;0.95;6
Pan de molde;alimentacion;1.80;2
Auriculares;general;59.90;1
Cargador USB-C;general;19.95;2
El Quijote;libros;12.50;1

Salida por consola

Producto                Uds    Precio    Importe
Leche entera              6      0,95       5,70
Pan de molde              2      1,80       3,60
Auriculares               1     59,90      59,90
Cargador USB-C            2     19,95      39,90
El Quijote                1     12,50      12,50
Promociones:
  3x2 en Leche entera: -1,90 €
  Descuento del 5 % en general: -4,99 €
Base imponible:       114,71 €
IVA 4 %:                0,50 €
IVA 10 %:               0,74 €
IVA 21 %:              19,91 €
TOTAL:                135,86 €

Con líneas incorrectas

Entrada

Yogur natural;alimentacion;0.45;7
Cuaderno;general;2.40;3
Revista;prensa;3.00;1
Bolígrafos;general;abc;2
Agua;alimentacion;0.30;0

Diccionario;libros;24.00;1

Salida por consola

Aviso: línea 3 ignorada
Aviso: línea 4 ignorada
Aviso: línea 5 ignorada
Producto                Uds    Precio    Importe
Yogur natural             7      0,45       3,15
Cuaderno                  3      2,40       7,20
Diccionario               1     24,00      24,00
Promociones:
  3x2 en Yogur natural: -0,90 €
Base imponible:        33,45 €
IVA 4 %:                0,96 €
IVA 10 %:               0,23 €
IVA 21 %:               1,51 €
TOTAL:                 36,15 €

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Leer el carrito línea a línea

stream_get_contents(STDIN) devuelve toda la entrada como una cadena y explode("\n", …) la parte en líneas. Recórrelas con foreach ($lineas as $i => $linea): el número de línea que piden los avisos es $i + 1.

2. Validar cada campo

Separa los campos con explode(";", $linea) y quítales los espacios con array_map("trim", …). Después comprueba el número de campos y cada valor: isset(IVA[$categoria]) dice si la categoría existe, is_numeric si el precio es un número y ctype_digit si las unidades son solo cifras (así descartas -3, 2.5 o dos).

php
$campos = array_map("trim", explode(";", $linea));
if (count($campos) !== 4) {
    echo "Aviso: línea " . ($i + 1) . " ignorada\n";
    continue;
}
[$nombre, $categoria, $precio, $unidades] = $campos;
3. Pasar a céntimos al leer

Guarda cada producto en un array asociativo con el precio ya convertido a céntimos. round antes de convertir a entero evita que 19.95 × 100 = 1994.9999… se quede en 1994.

php
$productos[] = [
    "nombre" => $nombre,
    "categoria" => $categoria,
    "unidades" => (int) $unidades,
    "precio" => (int) round((float) $precio * 100),
];
4. Formatear la tabla

number_format($centimos / 100, 2, ",", ".") da el formato español. Para las columnas numéricas, sprintf("%5d%10s%11s", …) alinea a la derecha. Para el nombre no sirve str_pad: cuenta bytes, y en UTF-8 la «á» ocupa dos, así que «Lámpara» quedaría un hueco más corta. mb_str_pad (PHP 8.3) cuenta caracteres.

5. Bases por categoría, promociones e IVA

Lleva un array $base con la base de cada categoría. Al recorrer los productos suma su importe menos el valor de las unidades gratis (intdiv($unidades, 3) en alimentación). Al terminar, comprueba si array_sum($base) supera 10000 céntimos para aplicar el descuento a $base["general"]. Para el IVA, recorre la constante IVA: ya está en el orden 4, 10, 21.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 3 casos ocultos que buscan los errores típicos.

🐘PHPFactura de un carrito con IVA y promocionesDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
Leche entera;alimentacion;0.95;6
Pan de molde;alimentacion;1.80;2
Auriculares;general;59.90;1
Cargador USB-C;general;19.95;2
El Quijote;libros;12.50;1
Salida esperada
Producto                Uds    Precio    Importe
Leche entera              6      0,95       5,70
Pan de molde              2      1,80       3,60
Auriculares               1     59,90      59,90
Cargador USB-C            2     19,95      39,90
El Quijote                1     12,50      12,50
Promociones:
  3x2 en Leche entera: -1,90 €
  Descuento del 5 % en general: -4,99 €
Base imponible:       114,71 €
IVA 4 %:                0,50 €
IVA 10 %:               0,74 €
IVA 21 %:              19,91 €
TOTAL:                135,86 €
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
php
1<?php
2// Tipos de IVA de cada categoría
3const IVA = ["libros" => 4, "alimentacion" => 10, "general" => 21];
4
5/** Céntimos → «1.234,50» (punto de millar y coma decimal). */
6function dinero(int $centimos): string
7{
8    return number_format($centimos / 100, 2, ",", ".");
9}
10
11$lineas = explode("\n", stream_get_contents(STDIN));
12$productos = [];
13foreach ($lineas as $i => $linea) {
14    if (trim($linea) === "") {
15        continue;
16    }
17    $campos = array_map("trim", explode(";", $linea));
18    if (count($campos) !== 4) {
19        echo "Aviso: línea " . ($i + 1) . " ignorada\n";
20        continue;
21    }
22    [$nombre, $categoria, $precio, $unidades] = $campos;
23    if (!isset(IVA[$categoria]) || !is_numeric($precio) || $precio < 0 || !ctype_digit($unidades) || (int) $unidades === 0) {
24        echo "Aviso: línea " . ($i + 1) . " ignorada\n";
25        continue;
26    }
27    // Todo se calcula en céntimos enteros: así los totales cuadran siempre
28    $productos[] = [
29        "nombre" => $nombre,
30        "categoria" => $categoria,
31        "unidades" => (int) $unidades,
32        "precio" => (int) round((float) $precio * 100),
33    ];
34}
35
36if (count($productos) === 0) {
37    echo "Carrito vacío\n";
38    exit;
39}
40
41echo "Producto" . str_repeat(" ", 14) . sprintf("%5s%10s%11s", "Uds", "Precio", "Importe") . "\n";
42$base = ["libros" => 0, "alimentacion" => 0, "general" => 0];
43$promociones = [];
44foreach ($productos as $p) {
45    $importe = $p["unidades"] * $p["precio"];
46    // mb_str_pad cuenta caracteres, no bytes: así los nombres con tildes también quedan alineados
47    echo mb_str_pad($p["nombre"], 22) . sprintf("%5d%10s%11s", $p["unidades"], dinero($p["precio"]), dinero($importe)) . "\n";
48    $gratis = $p["categoria"] === "alimentacion" ? intdiv($p["unidades"], 3) : 0;
49    if ($gratis > 0) {
50        $promociones[] = "3x2 en {$p['nombre']}: -" . dinero($gratis * $p["precio"]) . " €";
51    }
52    $base[$p["categoria"]] += $importe - $gratis * $p["precio"];
53}
54
55if (array_sum($base) > 10000) {
56    $descuento = (int) round($base["general"] * 5 / 100);
57    if ($descuento > 0) {
58        $base["general"] -= $descuento;
59        $promociones[] = "Descuento del 5 % en general: -" . dinero($descuento) . " €";
60    }
61}
62
63if ($promociones) {
64    echo "Promociones:\n";
65    foreach ($promociones as $texto) {
66        echo "  $texto\n";
67    }
68}
69
70$total = array_sum($base);
71printf("%-16s%12s €\n", "Base imponible:", dinero($total));
72foreach (IVA as $categoria => $tipo) {
73    if ($base[$categoria] > 0) {
74        $cuota = (int) round($base[$categoria] * $tipo / 100);
75        $total += $cuota;
76        printf("%-16s%12s €\n", "IVA $tipo %:", dinero($cuota));
77    }
78}
79printf("%-16s%12s €\n", "TOTAL:", dinero($total));

La lectura y la validación van primero y por separado: cada línea se comprueba campo a campo y, si algo falla, se avisa y se pasa a la siguiente con continue. Como los avisos se escriben mientras se lee, aparecen antes que la factura sin necesidad de guardarlos.

Todos los importes son céntimos enteros. Solo hay redondeo donde aparece un porcentaje (el descuento y cada cuota de IVA), y se redondea una vez por categoría, igual que en una factura real, donde el IVA se calcula sobre la base de cada tipo y no producto a producto.

El array asociativo $base es el corazón del programa: acumula la base de cada categoría ya con el 3x2, el descuento modifica solo la de general y el bucle del IVA lo recorre en el orden de la constante IVA, que define a la vez los tipos y el orden de las líneas del resumen.

La presentación se apoya en sprintf para las columnas y en una única función dinero para el formato de los importes. mb_str_pad cuenta caracteres en lugar de bytes, así que los nombres con tildes también quedan alineados.

Para ir más allá

  • Lee los tipos de IVA y las promociones de un fichero JSON con json_decode, para cambiarlos sin tocar el código.
  • Genera la factura como una página HTML con una tabla, escapando los nombres con htmlspecialchars.
  • Añade cupones: si la primera línea es CUPON codigo, aplica el descuento que corresponda a ese código.

Dónde se explica