Qué cambió — paso 08
Qué cambió · paso-07 → paso-08

Cambios de la lección 08

Todo lo que cambia en tienda/pasos/paso-08 respecto al paso anterior. Vuelve a la lección: 08. Token Exchange.

10 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

ArchivoEstadoLíneas
cmd/facturacion/main.gomodificado+79 −24
cmd/web/main.gomodificado+4 −2
infra/realm/tienda-realm.jsonmodificado+31 −3
internal/apiauth/apiauth.gomodificado+2 −0
internal/facturacion/server.gonuevo+115 −0
internal/facturasclient/client.gonuevo+88 −0
internal/tokenexchange/exchange.gonuevo+88 −0
internal/web/compras.gomodificado+19 −0
internal/web/templates/pedidos.htmlmodificado+13 −6
internal/web/web.gomodificado+21 −5
cmd/facturacion/main.go modificado · +79 −24
@@ -1,29 +1,41 @@
-// Command facturacion es el servicio de facturación: un proceso interno que,
-// sin ningún usuario delante, factura los pedidos entregados. Se autentica
-// ante Keycloak como client confidencial (Client Credentials).
+// Command facturacion es el servicio de facturación. Hace dos cosas:
 //
-// Uso (desde tienda/pasos/paso-07):
+//   - Sin usuario: factura cada 30 s los pedidos entregados, con su propio
+//     token de Client Credentials (lección 7).
+//   - En nombre de un usuario: atiende peticiones de tienda-web en el puerto
+//     8082 y, para consultar api-pedidos como ese usuario, intercambia su
+//     token (Token Exchange, lección 8).
+//
+// Uso (desde tienda/pasos/paso-08):
 //
 //	go run ./cmd/facturacion
 package main
 
 import (
 	"context"
+	"errors"
 	"log"
+	"net/http"
 	"os"
 	"os/signal"
+	"strings"
 	"time"
 
 	"github.com/coreos/go-oidc/v3/oidc"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/clientcredentials"
 
+	"tienda/internal/apiauth"
 	"tienda/internal/facturacion"
+	"tienda/internal/tokenexchange"
 )
 
 func main() {
 	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
+	clientID := env("OIDC_CLIENT_ID", "facturacion")
+	clientSecret := env("OIDC_CLIENT_SECRET", "facturacion-secret") // solo para desarrollo
 	apiURL := env("API_URL", "http://localhost:8081")
+	addr := env("ADDR", ":8082")
 	interval, err := time.ParseDuration(env("INTERVALO", "30s"))
 	if err != nil {
 		log.Fatalf("INTERVALO: %v", err)
@@ -36,32 +48,48 @@
 	if err != nil {
 		log.Fatal(err)
 	}
+	tokenURL := provider.Endpoint().TokenURL
+	store := facturacion.NewStore()
 
-	// Client Credentials: el servicio se identifica con su client ID y su
-	// secreto. No hay usuario, ni navegador, ni refresh token.
-	cfg := clientcredentials.Config{
-		ClientID:     env("OIDC_CLIENT_ID", "facturacion"),
-		ClientSecret: env("OIDC_CLIENT_SECRET", "facturacion-secret"), // solo para desarrollo
-		TokenURL:     provider.Endpoint().TokenURL,
+	// --- Lección 7: Client Credentials para el proceso automático ---
+	cc := clientcredentials.Config{ClientID: clientID, ClientSecret: clientSecret, TokenURL: tokenURL}
+	serviceClient := oauth2.NewClient(ctx, oauth2.ReuseTokenSource(nil, loggingSource{cc.TokenSource(ctx)}))
+	serviceClient.Timeout = 5 * time.Second
+	worker := &facturacion.Worker{API: apiURL, HTTP: serviceClient, Store: store, Interval: interval}
+	go worker.Run(ctx)
+
+	// --- Lección 8: HTTP para usuarios + Token Exchange ---
+	// Valida los tokens que trae tienda-web: su aud debe incluir «facturacion».
+	verifier, err := apiauth.NewVerifier(ctx, issuer, clientID)
+	if err != nil {
+		log.Fatal(err)
 	}
+	httpClient := &http.Client{Timeout: 5 * time.Second}
+	srv := &facturacion.Server{
+		Store: store,
+		Exchanger: &tokenexchange.Exchanger{
+			TokenURL: tokenURL, ClientID: clientID, ClientSecret: clientSecret, HTTP: httpClient,
+		},
+		API:  apiURL,
+		HTTP: httpClient,
+	}
+	mux := http.NewServeMux()
+	srv.Register(mux, verifier)
 
-	// ReuseTokenSource guarda el token y solo pide otro cuando caduca.
-	// loggingSource nos deja ver en el log cuándo ocurre eso.
-	ts := oauth2.ReuseTokenSource(nil, loggingSource{cfg.TokenSource(ctx)})
-	client := oauth2.NewClient(ctx, ts) // añade «Authorization: Bearer» a cada petición
-	client.Timeout = 5 * time.Second
-
-	w := &facturacion.Worker{
-		API:      apiURL,
-		HTTP:     client,
-		Store:    facturacion.NewStore(),
-		Interval: interval,
+	server := &http.Server{Addr: addr, Handler: logRequests(mux), ReadHeaderTimeout: 5 * time.Second}
+	go func() {
+		<-ctx.Done()
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		_ = server.Shutdown(shutdownCtx)
+	}()
+	log.Printf("facturacion escuchando en http://%s; facturando cada %s contra %s", listenHost(addr), interval, apiURL)
+	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+		log.Fatal(err)
 	}
-	log.Printf("facturacion: facturando cada %s contra %s", interval, apiURL)
-	w.Run(ctx)
 }
 
-// loggingSource registra cada vez que hace falta un token nuevo de Keycloak.
+// loggingSource registra cada vez que hace falta un token de servicio nuevo.
 type loggingSource struct{ src oauth2.TokenSource }
 
 func (l loggingSource) Token() (*oauth2.Token, error) {
@@ -73,9 +101,36 @@
 	return tok, nil
 }
 
+// statusRecorder recuerda el código de estado para el log.
+type statusRecorder struct {
+	http.ResponseWriter
+	status int
+}
+
+func (s *statusRecorder) WriteHeader(code int) {
+	s.status = code
+	s.ResponseWriter.WriteHeader(code)
+}
+
+func logRequests(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+		next.ServeHTTP(rec, r)
+		log.Printf("%s %s → %d", r.Method, r.URL.Path, rec.status)
+	})
+}
+
 func env(key, def string) string {
 	if v := os.Getenv(key); v != "" {
 		return v
 	}
 	return def
 }
+
+// listenHost convierte ":3000" en "localhost:3000" para mostrar la URL.
+func listenHost(addr string) string {
+	if strings.HasPrefix(addr, ":") {
+		return "localhost" + addr
+	}
+	return addr
+}
cmd/web/main.go modificado · +4 −2
@@ -15,6 +15,7 @@
 	"time"
 
 	"tienda/internal/auth"
+	"tienda/internal/facturasclient"
 	"tienda/internal/pedidosclient"
 	"tienda/internal/session"
 	"tienda/internal/web"
@@ -30,7 +31,8 @@
 		PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
 	}
 	addr := env("ADDR", ":3000")
-	apiURL := env("API_URL", "http://localhost:8081") // api-pedidos
+	apiURL := env("API_URL", "http://localhost:8081")              // api-pedidos
+	facturasURL := env("FACTURACION_URL", "http://localhost:8082") // facturacion
 
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
@@ -43,7 +45,7 @@
 
 	mux := http.NewServeMux()
 	a.Register(mux)
-	web.Register(mux, a, pedidosclient.New(apiURL))
+	web.Register(mux, a, pedidosclient.New(apiURL), facturasclient.New(facturasURL))
 
 	srv := &http.Server{
 		Addr: addr,
infra/realm/tienda-realm.json modificado · +31 −3 · generado, plegado
@@ -163,7 +163,8 @@
         "roles",
         "basic",
         "email",
-        "api-pedidos"
+        "api-pedidos",
+        "facturacion"
       ],
       "optionalClientScopes": [
         "address",
@@ -203,7 +204,8 @@
         "organization",
         "offline_access",
         "microprofile-jwt",
-        "pedidos:escribir"
+        "pedidos:escribir",
+        "facturacion"
       ]
     },
     {
@@ -238,7 +240,7 @@
     {
       "clientId": "facturacion",
       "name": "Servicio de facturación",
-      "description": "Servicio interno: Client Credentials (lección 7)",
+      "description": "Servicio interno: Client Credentials (lección 7) y Token Exchange (lección 8)",
       "enabled": true,
       "protocol": "openid-connect",
       "publicClient": false,
@@ -248,6 +250,9 @@
       "implicitFlowEnabled": false,
       "directAccessGrantsEnabled": false,
       "serviceAccountsEnabled": true,
+      "attributes": {
+        "standard.token.exchange.enabled": "true"
+      },
       "defaultClientScopes": [
         "web-origins",
         "acr",
@@ -299,6 +304,29 @@
         "display.on.consent.screen": "true",
         "consent.screen.text": "Crear y modificar pedidos"
       }
+    },
+    {
+      "name": "facturacion",
+      "description": "Añade facturacion a la audiencia (aud) del access token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "audiencia facturacion",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-audience-mapper",
+          "consentRequired": false,
+          "config": {
+            "included.client.audience": "facturacion",
+            "id.token.claim": "false",
+            "access.token.claim": "true",
+            "introspection.token.claim": "true"
+          }
+        }
+      ]
     },
     {
       "name": "email",
internal/apiauth/apiauth.go modificado · +2 −0
@@ -24,6 +24,7 @@
 	Scopes   []string // scope, separado por espacios
 	Roles    []string // realm_access.roles
 	APIRoles []string // resource_access.<audiencia>.roles: roles de client de esta API
+	Token    string   // el access token en bruto, por si hay que intercambiarlo (lección 8)
 }
 
 // Verifier valida access tokens emitidos por un realm para una audiencia.
@@ -78,6 +79,7 @@
 		Scopes:   strings.Fields(c.Scope),
 		Roles:    c.RealmAccess.Roles,
 		APIRoles: c.ResourceAccess[v.audience].Roles,
+		Token:    raw,
 	}, nil
 }
 
internal/facturacion/server.go nuevo · +115 −0
@@ -0,0 +1,115 @@
+package facturacion
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"strconv"
+
+	"golang.org/x/oauth2"
+
+	"tienda/internal/apiauth"
+	"tienda/internal/jsonhttp"
+	"tienda/internal/tokenexchange"
+)
+
+// Server expone la facturación a los usuarios (a través de tienda-web).
+type Server struct {
+	Store     *Store
+	Exchanger *tokenexchange.Exchanger
+	API       string       // URL base de api-pedidos
+	HTTP      *http.Client // cliente sin token: el token lo ponemos a mano
+}
+
+// Register añade las rutas. Exigen un access token de usuario cuyo aud
+// incluya «facturacion» (lo valida v) y el rol cliente o admin.
+func (s *Server) Register(mux *http.ServeMux, v *apiauth.Verifier) {
+	usuario := apiauth.RequireRole("cliente", "admin")
+	mux.Handle("GET /facturas", v.Middleware(usuario(http.HandlerFunc(s.list))))
+	mux.Handle("POST /facturas", v.Middleware(usuario(http.HandlerFunc(s.request))))
+}
+
+// list devuelve las facturas de quien llama. No necesita a api-pedidos: las
+// facturas son datos de este servicio y el «sub» del token dice de quién son.
+func (s *Server) list(w http.ResponseWriter, r *http.Request) {
+	p := apiauth.FromContext(r.Context())
+	jsonhttp.Write(w, http.StatusOK, map[string]any{"facturas": s.Store.ByCustomer(p.Subject)})
+}
+
+// request emite una factura anticipada de un pedido del usuario. Para saber
+// si el pedido es suyo y en qué estado está, pregunta a api-pedidos EN NOMBRE
+// DEL USUARIO: cambia su token por uno para api-pedidos (token exchange), así
+// es la API quien aplica sus reglas (un cliente solo ve sus pedidos).
+func (s *Server) request(w http.ResponseWriter, r *http.Request) {
+	p := apiauth.FromContext(r.Context())
+	var body struct {
+		Pedido int `json:"pedido"`
+	}
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil || body.Pedido <= 0 {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", `se espera {"pedido": <número>}`)
+		return
+	}
+
+	// 1. Token exchange: el token de ana para facturacion → uno de ana para api-pedidos.
+	tok, err := s.Exchanger.Exchange(r.Context(), p.Token, "api-pedidos")
+	if err != nil {
+		log.Printf("intercambio fallido: %v", err)
+		var oe *tokenexchange.Error
+		if errors.As(err, &oe) && oe.Description == "Invalid token" {
+			jsonhttp.Error(w, http.StatusUnauthorized, "invalid_token", "la sesión del usuario ya no es válida")
+			return
+		}
+		jsonhttp.Error(w, http.StatusBadGateway, "exchange_failed", "no se pudo actuar en nombre del usuario")
+		return
+	}
+
+	// 2. Con ese token, api-pedidos nos dice si el pedido es de ana.
+	order, status, err := s.getOrder(r.Context(), tok, body.Pedido)
+	switch {
+	case err != nil:
+		log.Printf("api-pedidos: %v", err)
+		jsonhttp.Error(w, http.StatusBadGateway, "api_error", "api-pedidos no respondió")
+		return
+	case status == http.StatusNotFound:
+		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
+		return
+	case status != http.StatusOK:
+		jsonhttp.Error(w, http.StatusBadGateway, "api_error", fmt.Sprintf("api-pedidos respondió %d", status))
+		return
+	}
+	if order.Status != "Enviado" && order.Status != "Entregado" {
+		jsonhttp.Error(w, http.StatusConflict, "invalid_state", "solo se facturan pedidos enviados o entregados")
+		return
+	}
+
+	// 3. Emitimos la factura (o devolvemos la que ya tuviera).
+	inv, created := s.Store.Issue(order.ID, p.Subject, order.Total, "solicitada")
+	code := http.StatusOK
+	if created {
+		code = http.StatusCreated
+		log.Printf("factura %s solicitada por %s para el pedido #%d", inv.Number, p.Username, order.ID)
+	}
+	jsonhttp.Write(w, code, inv)
+}
+
+// getOrder pide un pedido a api-pedidos con el token intercambiado.
+func (s *Server) getOrder(ctx context.Context, tok *oauth2.Token, id int) (Order, int, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.API+"/pedidos/"+strconv.Itoa(id), nil)
+	if err != nil {
+		return Order{}, 0, err
+	}
+	tok.SetAuthHeader(req)
+	resp, err := s.HTTP.Do(req)
+	if err != nil {
+		return Order{}, 0, err
+	}
+	defer resp.Body.Close()
+	var o Order
+	if resp.StatusCode == http.StatusOK {
+		err = json.NewDecoder(resp.Body).Decode(&o)
+	}
+	return o, resp.StatusCode, err
+}
internal/facturasclient/client.go nuevo · +88 −0
@@ -0,0 +1,88 @@
+// Package facturasclient es el cliente HTTP con el que tienda-web llama al
+// servicio de facturación en nombre del usuario.
+package facturasclient
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"golang.org/x/oauth2"
+)
+
+// Invoice es una factura tal como la devuelve facturacion.
+type Invoice struct {
+	Number  string  `json:"numero"`
+	OrderID int     `json:"pedido"`
+	Total   float64 `json:"total"`
+	Origin  string  `json:"origen"`
+}
+
+// Client llama a facturacion.
+type Client struct {
+	baseURL string
+	http    *http.Client
+}
+
+// New crea un cliente para facturacion en baseURL (p. ej. http://localhost:8082).
+func New(baseURL string) *Client {
+	return &Client{baseURL: baseURL, http: &http.Client{Timeout: 10 * time.Second}}
+}
+
+// MyInvoices devuelve las facturas del usuario, indexadas por número de pedido.
+func (c *Client) MyInvoices(ctx context.Context, tok *oauth2.Token) (map[int]Invoice, error) {
+	var out struct {
+		Facturas []Invoice `json:"facturas"`
+	}
+	if err := c.do(ctx, tok, http.MethodGet, "/facturas", nil, &out); err != nil {
+		return nil, err
+	}
+	m := make(map[int]Invoice, len(out.Facturas))
+	for _, inv := range out.Facturas {
+		m[inv.OrderID] = inv
+	}
+	return m, nil
+}
+
+// Request pide la factura anticipada de un pedido.
+func (c *Client) Request(ctx context.Context, tok *oauth2.Token, orderID int) (Invoice, error) {
+	var inv Invoice
+	err := c.do(ctx, tok, http.MethodPost, "/facturas", map[string]int{"pedido": orderID}, &inv)
+	return inv, err
+}
+
+func (c *Client) do(ctx context.Context, tok *oauth2.Token, method, path string, body, out any) error {
+	var rd io.Reader
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return err
+		}
+		rd = bytes.NewReader(b)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd)
+	if err != nil {
+		return err
+	}
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	tok.SetAuthHeader(req)
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		var e struct {
+			Description string `json:"error_description"`
+		}
+		_ = json.NewDecoder(resp.Body).Decode(&e)
+		return fmt.Errorf("facturacion respondió %s: %s", resp.Status, e.Description)
+	}
+	return json.NewDecoder(resp.Body).Decode(out)
+}
internal/tokenexchange/exchange.go nuevo · +88 −0
@@ -0,0 +1,88 @@
+// Package tokenexchange implementa el Token Exchange de OAuth 2.0 (RFC 8693)
+// tal como lo soporta Keycloak («standard token exchange»): un client
+// confidencial cambia el access token de un usuario por otro, emitido a su
+// nombre y dirigido a otra audiencia.
+package tokenexchange
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"golang.org/x/oauth2"
+)
+
+const (
+	grantType       = "urn:ietf:params:oauth:grant-type:token-exchange"
+	accessTokenType = "urn:ietf:params:oauth:token-type:access_token"
+)
+
+// Exchanger hace token exchange en nombre de un client confidencial.
+type Exchanger struct {
+	TokenURL     string // endpoint de token del realm
+	ClientID     string // el client que intercambia (debe estar en el aud del token original)
+	ClientSecret string
+	HTTP         *http.Client
+}
+
+// Error es un error OAuth devuelto por Keycloak («error», «error_description»).
+type Error struct {
+	Code        string `json:"error"`
+	Description string `json:"error_description"`
+}
+
+func (e *Error) Error() string { return fmt.Sprintf("token exchange: %s: %s", e.Code, e.Description) }
+
+// Exchange cambia subjectToken (el access token del usuario) por un access
+// token para audience. El token nuevo conserva el usuario (sub) y su sesión,
+// pero su azp es este client y su aud queda reducida a audience.
+func (e *Exchanger) Exchange(ctx context.Context, subjectToken, audience string) (*oauth2.Token, error) {
+	form := url.Values{
+		"grant_type":           {grantType},
+		"subject_token":        {subjectToken},
+		"subject_token_type":   {accessTokenType},
+		"requested_token_type": {accessTokenType},
+		"audience":             {audience},
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.TokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.SetBasicAuth(url.QueryEscape(e.ClientID), url.QueryEscape(e.ClientSecret)) // RFC 6749 §2.3.1
+
+	client := e.HTTP
+	if client == nil {
+		client = http.DefaultClient
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		oe := &Error{}
+		if json.NewDecoder(resp.Body).Decode(oe) != nil || oe.Code == "" {
+			return nil, fmt.Errorf("token exchange: Keycloak respondió %s", resp.Status)
+		}
+		return nil, oe
+	}
+	var body struct {
+		AccessToken string `json:"access_token"`
+		TokenType   string `json:"token_type"`
+		ExpiresIn   int    `json:"expires_in"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+		return nil, err
+	}
+	return &oauth2.Token{
+		AccessToken: body.AccessToken,
+		TokenType:   body.TokenType,
+		Expiry:      time.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
+	}, nil
+}
internal/web/compras.go modificado · +19 −0
@@ -87,6 +87,25 @@
 	http.Redirect(w, r, "/admin?ok="+strconv.Itoa(id), http.StatusSeeOther)
 }
 
+// pedirFactura pide a facturacion la factura anticipada de un pedido.
+func (h *handlers) pedirFactura(w http.ResponseWriter, r *http.Request) {
+	_, tok, ok := h.apiToken(w, r, "%2Fpedidos")
+	if !ok {
+		return
+	}
+	id, err := strconv.Atoi(r.PostFormValue("pedido"))
+	if err != nil {
+		http.Error(w, "pedido inválido", http.StatusBadRequest)
+		return
+	}
+	if _, err := h.facturas.Request(r.Context(), tok, id); err != nil {
+		log.Printf("pedir factura: %v", err)
+		http.Redirect(w, r, "/pedidos?error=factura", http.StatusSeeOther)
+		return
+	}
+	http.Redirect(w, r, "/pedidos?factura="+strconv.Itoa(id), http.StatusSeeOther)
+}
+
 // errorCode resume un error de la API en un código corto para la URL.
 func errorCode(err error) string {
 	switch {
internal/web/templates/pedidos.html modificado · +13 −6
@@ -2,18 +2,25 @@
 <h1>Mis pedidos</h1>
 <p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}}), servidos por <code>api-pedidos</code></p>
 {{if .Flash}}<div class="note">✔ {{.Flash}}</div>{{end}}
-{{if .Error}}
-<div class="note">⚠ {{.Error}}</div>
-{{else if .Orders}}
+{{if .Error}}<div class="note">⚠ {{.Error}}</div>{{end}}
+{{if .Orders}}
 <table>
-  <thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th></tr></thead>
+  <thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th><th>Factura</th></tr></thead>
   <tbody>
   {{range .Orders}}
-    <tr><td>#{{.ID}}</td><td>{{.Items}}</td><td>$ {{printf "%.2f" .Total}}</td><td><span class="badge">{{.Status}}</span></td></tr>
+    <tr><td>#{{.ID}}</td><td>{{.Items}}</td><td>$ {{printf "%.2f" .Total}}</td><td><span class="badge">{{.Status}}</span></td>
+      <td>
+        {{$inv := index $.Invoices .ID}}
+        {{if $inv.Number}}<strong>{{$inv.Number}}</strong> <span class="who">({{$inv.Origin}})</span>
+        {{else if or (eq .Status "Enviado") (eq .Status "Entregado")}}
+        <form class="inline" method="post" action="/facturas"><input type="hidden" name="pedido" value="{{.ID}}"><button class="btn ghost" type="submit">Pedir factura</button></form>
+        {{else}}—{{end}}
+      </td>
+    </tr>
   {{end}}
   </tbody>
 </table>
-{{else}}
+{{else if not .Error}}
 <div class="note">Todavía no tienes pedidos.</div>
 {{end}}
 {{end}}
internal/web/web.go modificado · +21 −5
@@ -13,6 +13,7 @@
 	"time"
 
 	"tienda/internal/auth"
+	"tienda/internal/facturasclient"
 	"tienda/internal/pedidos"
 	"tienda/internal/pedidosclient"
 	"tienda/internal/session"
@@ -40,6 +41,7 @@
 	Session  *session.Session
 	Products []pedidos.Product
 	Orders   []pedidosclient.Order
+	Invoices map[int]facturasclient.Invoice // facturas por número de pedido
 	Statuses []string
 	Claims   []claim
 	UserInfo []claim
@@ -49,14 +51,15 @@
 }
 
 type handlers struct {
-	auth  *auth.Auth
-	api   *pedidosclient.Client
-	pages map[string]*template.Template
+	auth     *auth.Auth
+	api      *pedidosclient.Client
+	facturas *facturasclient.Client
+	pages    map[string]*template.Template
 }
 
 // Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
-func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client) {
-	h := &handlers{auth: a, api: api, pages: make(map[string]*template.Template)}
+func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client, facturas *facturasclient.Client) {
+	h := &handlers{auth: a, api: api, facturas: facturas, pages: make(map[string]*template.Template)}
 	for _, name := range []string{"home.html", "pedidos.html", "perfil.html", "admin.html"} {
 		h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
 	}
@@ -68,6 +71,7 @@
 	mux.Handle("POST /comprar", a.RequireLogin(http.HandlerFunc(h.comprar)))
 	mux.Handle("GET /admin", a.RequireLogin(http.HandlerFunc(h.admin)))
 	mux.Handle("POST /admin/pedidos/{id}", a.RequireLogin(http.HandlerFunc(h.cambiarEstado)))
+	mux.Handle("POST /facturas", a.RequireLogin(http.HandlerFunc(h.pedirFactura)))
 }
 
 func (h *handlers) home(w http.ResponseWriter, r *http.Request) {
@@ -90,6 +94,12 @@
 	if id := r.URL.Query().Get("nuevo"); id != "" {
 		data.Flash = "Pedido #" + id + " creado."
 	}
+	if f := r.URL.Query().Get("factura"); f != "" {
+		data.Flash = "Factura emitida para el pedido #" + f + "."
+	}
+	if r.URL.Query().Get("error") == "factura" {
+		data.Error = "No se pudo emitir la factura. Mira el log de tienda-web."
+	}
 	if err != nil {
 		data.Error = err.Error()
 	} else if orders, err := h.api.MyOrders(r.Context(), tok); err != nil {
@@ -97,6 +107,12 @@
 		data.Error = "No se pudieron cargar los pedidos: " + err.Error()
 	} else {
 		data.Orders = orders
+		// Las facturas vienen de otro servicio; si no responde, la página sigue funcionando.
+		if inv, err := h.facturas.MyInvoices(r.Context(), tok); err != nil {
+			log.Printf("facturacion: %v", err)
+		} else {
+			data.Invoices = inv
+		}
 	}
 	h.render(w, "pedidos.html", data)
 }

← Volver a la lección 08