@pcreative/commerce-contract 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md ADDED
@@ -0,0 +1,118 @@
1
+ # @pcreative/commerce-contract
2
+
3
+ [English](README.md) · **Español**
4
+
5
+ Contrato de datos de commerce (v1.0): las entidades que consume un tema y los
6
+ adaptadores que las resuelven. **Sin dependencias y sin el SDK del backend.**
7
+
8
+ Un tema no importa ningún SDK de nadie: consume este contrato.
9
+ De ahí salen las dos propiedades que sostienen el producto — el mismo tema sirve
10
+ para otro backend mañana, y el backend no queda casado con temas de un solo
11
+ framework.
12
+
13
+ ## Uso
14
+
15
+ ```js
16
+ import { createCommerce } from "@pcreative/commerce-contract/api"
17
+
18
+ const commerce = createCommerce({
19
+ baseUrl: process.env.NEXT_PUBLIC_COMMERCE_URL,
20
+ publishableKey: process.env.NEXT_PUBLIC_COMMERCE_KEY,
21
+ countryCode: "es",
22
+ // El framework aporta su caché sin que el adaptador sepa de frameworks:
23
+ requestInit: { next: { revalidate: 300 } },
24
+ })
25
+
26
+ const { items, count } = await commerce.listProducts({ category: "iluminacion", limit: 24 })
27
+ const carrito = await commerce.cart.addItem(cartId, variantId, 1)
28
+ ```
29
+
30
+ ## Reglas del contrato
31
+
32
+ **Los importes son decimales en unidades mayores** (`19.99`, no `1999`) y llevan
33
+ su moneda al lado. Ningún tema tiene que adivinar la escala, que es de donde sale
34
+ la mitad de los errores de precio en una tienda.
35
+
36
+ ```js
37
+ import { formatMoney, sumMoney } from "@pcreative/commerce-contract"
38
+ formatMoney({ amount: 19.99, currency: "eur" }) // "19,99 €"
39
+ sumMoney(a, b) // lanza si mezclas monedas
40
+ ```
41
+
42
+ **Los errores llevan el código HTTP dentro** (`err.status`), porque un 404 de
43
+ carrito caducado y un 401 por clave publicable mal puesta piden cosas distintas
44
+ al tema. Un carrito que ya no existe devuelve `null`, no una excepción.
45
+
46
+ **`checkout.complete()` puede devolver `{ order }` o `{ cart, error }`** — las
47
+ dos con HTTP 200. Si el cobro falla, el carrito vuelve intacto y con el motivo.
48
+
49
+ **El autor de una pieza y su soporte van juntos.** `getAuthor(handle)` trae la
50
+ ficha pública de quien vende en el mercado, con sus piezas y con lo que calcula
51
+ la plataforma: cuántas consultas resuelve y cuánto tarda en contestar. Lo que
52
+ todavía no tiene dato viene en `null` —nunca en cero— para que el tema pueda
53
+ enseñar «autor nuevo» en vez de un 0 % que espanta.
54
+
55
+ ```js
56
+ const autor = await commerce.getAuthor("estudio-demo")
57
+ autor.support?.resolvedPct // 80, o null si aún no hay tres valoraciones
58
+ autor.sales // 12, o null si todavía no ha vendido
59
+ ```
60
+
61
+ **Para escribir al autor hace falta la clave de licencia**: solo da soporte a
62
+ quien ha comprado. El hilo es privado y el correo del comprador viaja siempre en
63
+ el cuerpo, nunca en la dirección.
64
+
65
+ ```js
66
+ const { id, covered } = await commerce.support.open({
67
+ licenseKey, subject: "No instala", message: "Me da un error", email,
68
+ })
69
+ // covered === false: su soporte incluido ya caducó. Se avisa, no se bloquea.
70
+ await commerce.support.get(id, email)
71
+ await commerce.support.rate(id, email, true) // una sola pregunta al cerrar
72
+ ```
73
+
74
+ **El id de un pedido no es una contraseña.** El pedido de quien compró sin
75
+ cuenta solo vuelve entero con la prueba de que es suyo. Sin ella, `getOrder`
76
+ devuelve el pedido marcado con `partial: true` y sin correo ni dirección, y las
77
+ licencias, los códigos y las descargas contestan 403. La prueba le llega al tema
78
+ en el enlace que llevan los correos (`?acceso=`), en la respuesta de
79
+ `checkout.complete`, o pidiéndola con el correo de la compra: y viaja en una
80
+ cabecera, nunca en la dirección.
81
+
82
+ ```js
83
+ const { order, access } = await commerce.checkout.complete(cartId) // guarda `access`
84
+ const pedido = await commerce.getOrder(id, access) // entero
85
+ const licencias = await commerce.getLicenses(id, access) // 403 sin ella
86
+
87
+ // ¿Sin prueba? Se pide. Devuelve null cuando el correo no es el del pedido:
88
+ // exactamente lo mismo que devuelve un pedido que no existe, para que la
89
+ // respuesta no pueda decir cuál de las dos cosas ha pasado.
90
+ const dado = await commerce.requestOrderAccess(id, email) // { access, days } | null
91
+ ```
92
+
93
+ ## Escribir otro adaptador
94
+
95
+ Implementa la interfaz `CommerceClient` de [`src/index.d.ts`](src/index.d.ts) y
96
+ compruébalo:
97
+
98
+ ```js
99
+ import { assertCommerceClient } from "@pcreative/commerce-contract"
100
+ const faltan = assertCommerceClient(miCliente) // [] si cumple
101
+ ```
102
+
103
+ `account`, `content`, `getAuthor` y `support` son opcionales: un backend puede
104
+ no tener cuentas de cliente, ni blog, ni mercado de autores.
105
+
106
+ ## Idioma de los mensajes
107
+
108
+ Los errores que lanza el paquete salen en inglés. Salen en español cuando
109
+ `LC_ALL`, `LC_MESSAGES` o `LANG` empiezan por `es` (por ejemplo
110
+ `LANG=es_ES.UTF-8`). Donde no hay entorno (un navegador), en inglés.
111
+
112
+ ## Tests
113
+
114
+ ```bash
115
+ node --test "test/*.test.js"
116
+ ```
117
+
118
+ Los tests inyectan un `fetch` de mentira, así que corren sin backend.
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # @pcreative/commerce-contract
2
2
 
3
- Contrato de datos de commerce (v1.0): las entidades que consume un tema y los
4
- adaptadores que las resuelven. **Sin dependencias y sin el SDK del backend.**
3
+ **English** · [Español](README.es.md)
5
4
 
6
- Un tema no importa ningún SDK de nadie: consume este contrato.
7
- De ahí salen las dos propiedades que sostienen el producto — el mismo tema sirve
8
- para otro backend mañana, y el backend no queda casado con temas de un solo
5
+ Commerce data contract (v1.0): the entities a theme consumes and the adapters
6
+ that resolve them. **No dependencies and no backend SDK.**
7
+
8
+ A theme does not import anybody's SDK: it consumes this contract. That gives
9
+ the two properties the product stands on — the same theme can serve another
10
+ backend tomorrow, and the backend is not married to themes from a single
9
11
  framework.
10
12
 
11
- ## Uso
13
+ ## Usage
12
14
 
13
15
  ```js
14
16
  import { createCommerce } from "@pcreative/commerce-contract/api"
@@ -17,7 +19,7 @@ const commerce = createCommerce({
17
19
  baseUrl: process.env.NEXT_PUBLIC_COMMERCE_URL,
18
20
  publishableKey: process.env.NEXT_PUBLIC_COMMERCE_KEY,
19
21
  countryCode: "es",
20
- // El framework aporta su caché sin que el adaptador sepa de frameworks:
22
+ // The framework brings its own cache without the adapter knowing about frameworks:
21
23
  requestInit: { next: { revalidate: 300 } },
22
24
  })
23
25
 
@@ -25,62 +27,87 @@ const { items, count } = await commerce.listProducts({ category: "iluminacion",
25
27
  const carrito = await commerce.cart.addItem(cartId, variantId, 1)
26
28
  ```
27
29
 
28
- ## Reglas del contrato
30
+ ## Contract rules
29
31
 
30
- **Los importes son decimales en unidades mayores** (`19.99`, no `1999`) y llevan
31
- su moneda al lado. Ningún tema tiene que adivinar la escala, que es de donde sale
32
- la mitad de los errores de precio en una tienda.
32
+ **Amounts are decimals in major units** (`19.99`, not `1999`) and carry their
33
+ currency next to them. No theme has to guess the scale, which is where half of
34
+ the price bugs in a store come from.
33
35
 
34
36
  ```js
35
37
  import { formatMoney, sumMoney } from "@pcreative/commerce-contract"
36
38
  formatMoney({ amount: 19.99, currency: "eur" }) // "19,99 €"
37
- sumMoney(a, b) // lanza si mezclas monedas
39
+ sumMoney(a, b) // throws if you mix currencies
38
40
  ```
39
41
 
40
- **Los errores llevan el código HTTP dentro** (`err.status`), porque un 404 de
41
- carrito caducado y un 401 por clave publicable mal puesta piden cosas distintas
42
- al tema. Un carrito que ya no existe devuelve `null`, no una excepción.
42
+ **Errors carry the HTTP status inside** (`err.status`), because a 404 for an
43
+ expired cart and a 401 for a misconfigured publishable key ask different things
44
+ of the theme. A cart that no longer exists returns `null`, not an exception.
43
45
 
44
- **`checkout.complete()` puede devolver `{ order }` o `{ cart, error }`** — las
45
- dos con HTTP 200. Si el cobro falla, el carrito vuelve intacto y con el motivo.
46
+ **`checkout.complete()` can return `{ order }` or `{ cart, error }`** — both
47
+ with HTTP 200. If the charge fails, the cart comes back intact with the reason.
46
48
 
47
- **El autor de una pieza y su soporte van juntos.** `getAuthor(handle)` trae la
48
- ficha pública de quien vende en el mercado, con sus piezas y con lo que calcula
49
- la plataforma: cuántas consultas resuelve y cuánto tarda en contestar. Lo que
50
- todavía no tiene dato viene en `null` —nunca en cero— para que el tema pueda
51
- enseñar «autor nuevo» en vez de un 0 % que espanta.
49
+ **An item's author and its support go together.** `getAuthor(handle)` returns
50
+ the public profile of whoever sells on the marketplace, with their items and
51
+ what the platform computes: how many requests they resolve and how long they
52
+ take to answer. Anything without data yet comes as `null` — never zero — so the
53
+ theme can show "new author" instead of a 0 % that scares people away.
52
54
 
53
55
  ```js
54
56
  const autor = await commerce.getAuthor("estudio-demo")
55
- autor.support?.resolvedPct // 80, o null si aún no hay tres valoraciones
56
- autor.sales // 12, o null si todavía no ha vendido
57
+ autor.support?.resolvedPct // 80, or null until there are three ratings
58
+ autor.sales // 12, or null if they have not sold yet
57
59
  ```
58
60
 
59
- **Para escribir al autor hace falta la clave de licencia**: solo da soporte a
60
- quien ha comprado. El hilo es privado y el correo del comprador viaja siempre en
61
- el cuerpo, nunca en la dirección.
61
+ **Writing to the author requires the license key**: support is only for those
62
+ who bought. The thread is private and the buyer's email always travels in the
63
+ body, never in the URL.
62
64
 
63
65
  ```js
64
66
  const { id, covered } = await commerce.support.open({
65
- licenseKey, subject: "No instala", message: "Me da un error", email,
67
+ licenseKey, subject: "Will not install", message: "I get an error", email,
66
68
  })
67
- // covered === false: su soporte incluido ya caducó. Se avisa, no se bloquea.
69
+ // covered === false: the included support has expired. It warns, it does not block.
68
70
  await commerce.support.get(id, email)
69
- await commerce.support.rate(id, email, true) // una sola pregunta al cerrar
71
+ await commerce.support.rate(id, email, true) // a single question when closing
70
72
  ```
71
73
 
72
- ## Escribir otro adaptador
74
+ **An order id is not a password.** A guest order only comes back whole with
75
+ proof that it belongs to whoever is asking. Without it, `getOrder` returns the
76
+ order marked `partial: true` and with no email or address, and licences, codes
77
+ and downloads answer 403. The proof reaches the theme in the link the emails
78
+ carry (`?acceso=`), in the answer to `checkout.complete`, or by asking for it
79
+ with the email of the purchase — and it travels in a header, never in the URL.
80
+
81
+ ```js
82
+ const { order, access } = await commerce.checkout.complete(cartId) // keep `access`
83
+ const pedido = await commerce.getOrder(id, access) // whole
84
+ const licencias = await commerce.getLicenses(id, access) // 403 without it
85
+
86
+ // No proof? Ask for it. null when the email is not the one on the order —
87
+ // exactly what an order that does not exist returns, so the answer cannot say
88
+ // which of the two happened.
89
+ const dado = await commerce.requestOrderAccess(id, email) // { access, days } | null
90
+ ```
73
91
 
74
- Implementa la interfaz `CommerceClient` de [`src/index.d.ts`](src/index.d.ts) y
75
- compruébalo:
92
+ ## Writing another adapter
93
+
94
+ Implement the `CommerceClient` interface from [`src/index.d.ts`](src/index.d.ts)
95
+ and check it:
76
96
 
77
97
  ```js
78
98
  import { assertCommerceClient } from "@pcreative/commerce-contract"
79
- const faltan = assertCommerceClient(miCliente) // [] si cumple
99
+ const faltan = assertCommerceClient(miCliente) // [] if it complies
80
100
  ```
81
101
 
82
- `account`, `content`, `getAuthor` y `support` son opcionales: un backend puede
83
- no tener cuentas de cliente, ni blog, ni mercado de autores.
102
+ `account`, `content`, `getAuthor` and `support` are optional: a backend may have
103
+ no customer accounts, no blog and no author marketplace.
104
+
105
+ ## Language of the messages
106
+
107
+ Errors thrown by the package are in English. They come out in Spanish when
108
+ `LC_ALL`, `LC_MESSAGES` or `LANG` starts with `es` (for example
109
+ `LANG=es_ES.UTF-8`). Where there is no environment (a browser), English is
110
+ used.
84
111
 
85
112
  ## Tests
86
113
 
@@ -88,4 +115,4 @@ no tener cuentas de cliente, ni blog, ni mercado de autores.
88
115
  node --test "test/*.test.js"
89
116
  ```
90
117
 
91
- Los tests inyectan un `fetch` de mentira, así que corren sin backend.
118
+ The tests inject a fake `fetch`, so they run without a backend.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pcreative/commerce-contract",
3
- "version": "1.3.4",
4
- "description": "Contrato de datos de commerce: los tipos que consume un tema y los adaptadores que los resuelven. Sin dependencias ni SDK del backend.",
3
+ "version": "1.3.6",
4
+ "description": "Commerce data contract: the types a theme consumes and the adapters that resolve them. No dependencies and no backend SDK.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "README.md",
22
+ "README.es.md",
22
23
  "LICENSE"
23
24
  ],
24
25
  "engines": {
@@ -1,4 +1,8 @@
1
1
  import { money } from "../index.js"
2
+ import { msg } from "../mensajes.js"
3
+
4
+ /** La cabecera con la que se demuestra que un pedido de invitado es tuyo. */
5
+ const CABECERA_DE_ACCESO = "x-pcc-pedido"
2
6
 
3
7
  const ORDEN_DEL_CONTRATO = {
4
8
  newest: "novedades",
@@ -34,16 +38,20 @@ export function createCommerce({
34
38
  fetch: fetchImpl,
35
39
  requestInit = {},
36
40
  } = {}) {
37
- if (!baseUrl) throw new Error("createCommerce: falta baseUrl")
41
+ if (!baseUrl) throw new Error(msg("faltaBaseUrl"))
38
42
  const f = fetchImpl ?? globalThis.fetch
39
43
  const raiz = String(baseUrl).replace(/\/$/, "")
40
44
 
41
- async function api(ruta, { method = "GET", body, token, ...extra } = {}) {
45
+ async function api(ruta, { method = "GET", body, token, acceso, ...extra } = {}) {
42
46
  const cabeceras = { accept: "application/json", ...(extra.headers ?? {}) }
43
47
  if (publishableKey) cabeceras["x-pcc-clave"] = publishableKey
44
48
  if (locale) cabeceras["x-pcc-locale"] = locale
45
49
  if (body !== undefined) cabeceras["content-type"] = "application/json"
46
50
  if (token) cabeceras.authorization = `Bearer ${token}`
51
+ // La prueba de que el pedido es de quien pregunta va SIEMPRE en cabecera y
52
+ // nunca en la direccion: una direccion acaba en el historial, en los
53
+ // registros del servidor y en el `Referer` de cualquier enlace que se pulse.
54
+ if (acceso) cabeceras[CABECERA_DE_ACCESO] = acceso
47
55
 
48
56
  const res = await f(`${raiz}${ruta}`, {
49
57
  method,
@@ -259,16 +267,39 @@ export function createCommerce({
259
267
  return product_categories[0] ? mapCategory(product_categories[0]) : null
260
268
  }
261
269
 
262
- async function getOrder(id) {
270
+ async function getOrder(id, acceso) {
263
271
  try {
264
- const { order } = await api(`/tienda/pedidos/${encodeURIComponent(id)}`)
265
- return order ?? null
272
+ const { order, acceso_requerido } = await api(`/tienda/pedidos/${encodeURIComponent(id)}`, { acceso })
273
+ if (!order) return null
274
+ // Sin prueba, el comercio contesta el pedido recortado: sin correo ni
275
+ // direccion. `partial` es lo que le dice al tema que pida el correo en vez
276
+ // de pintar huecos vacios como si el pedido no tuviera datos.
277
+ return acceso_requerido ? { ...order, partial: true } : order
266
278
  } catch (e) {
267
279
  if (e.status === 404 || e.status === 401) return null
268
280
  throw e
269
281
  }
270
282
  }
271
283
 
284
+ /**
285
+ * La segunda puerta: quien llega a `/pedido/<id>` sin la prueba del correo la
286
+ * pide con el correo de la compra. Devuelve null cuando no coincide, que es
287
+ * la MISMA respuesta que da un pedido que no existe: si no, la respuesta
288
+ * diria si ese pedido existe.
289
+ */
290
+ async function requestOrderAccess(orderId, email) {
291
+ try {
292
+ const r = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/acceso`, {
293
+ method: "POST",
294
+ body: { email },
295
+ })
296
+ return r?.acceso ? { access: r.acceso, days: Number(r.dias ?? 0) } : null
297
+ } catch (e) {
298
+ if (e.status === 404 || e.status === 400) return null
299
+ throw e
300
+ }
301
+ }
302
+
272
303
  async function search(q, limit = 24) {
273
304
  const { items } = await listProducts({ q, limit })
274
305
  return items
@@ -295,7 +326,7 @@ export function createCommerce({
295
326
  body: cuerpo,
296
327
  })
297
328
  const datos = await r.json().catch(() => ({}))
298
- if (!r.ok) throw new Error(datos?.message || "No se pudo subir el fichero.")
329
+ if (!r.ok) throw new Error(datos?.message || msg("ficheroNoSubido"))
299
330
  return datos.fichero
300
331
  }
301
332
 
@@ -343,7 +374,7 @@ export function createCommerce({
343
374
  async applyPromo(cartId, code) {
344
375
  const r = await api(rutaCarrito(cartId, "/cupon"), { method: "POST", body: { codigo: code } })
345
376
  if (!r.aplicado) {
346
- const err = new Error(r.motivo ?? "Ese código no se puede usar.")
377
+ const err = new Error(r.motivo ?? msg("codigoNoValido"))
347
378
  err.status = 400
348
379
  err.detalle = r.motivo ?? ""
349
380
  throw err
@@ -443,7 +474,11 @@ export function createCommerce({
443
474
  async complete(cartId) {
444
475
  try {
445
476
  const r = await api(rutaCarrito(cartId, "/completar"), { method: "POST", body: {} })
446
- const pedido = (await getOrder(r.order.id)) ?? {
477
+ // La prueba de acceso vuelve con el pedido recién cerrado: la página de
478
+ // gracias enseña claves y descargas sin pedirle el correo a quien acaba
479
+ // de escribirlo dos pantallas antes.
480
+ const acceso = r.acceso ?? undefined
481
+ const pedido = (await getOrder(r.order.id, acceso)) ?? {
447
482
  id: r.order.id,
448
483
  status: "pending",
449
484
  items: [],
@@ -453,7 +488,7 @@ export function createCommerce({
453
488
  tax: money(0, r.order.currency_code ?? "eur", true),
454
489
  total: money(r.order.total, r.order.currency_code ?? "eur", true),
455
490
  }
456
- return { order: pedido }
491
+ return acceso ? { order: pedido, access: acceso } : { order: pedido }
457
492
  } catch (e) {
458
493
  if (e.status !== 409 && e.status !== 400) throw e
459
494
  return { cart: await getCart(cartId), error: e.detalle || "No se pudo completar el pedido" }
@@ -497,6 +532,18 @@ export function createCommerce({
497
532
  const { orders = [] } = await api("/tienda/cuenta/pedidos", { token })
498
533
  return orders
499
534
  },
535
+ async licenses(token) {
536
+ const { licencias = [] } = await api("/tienda/cuenta/licencias", { token })
537
+ return licencias
538
+ },
539
+ async releaseSite(token, licenseKey, instance) {
540
+ const r = await api("/tienda/cuenta/licencias/soltar", {
541
+ method: "POST",
542
+ token,
543
+ body: { clave: licenseKey, instancia: instance },
544
+ })
545
+ return { released: Boolean(r.soltado), site: r.sitio ?? "" }
546
+ },
500
547
  async confirmEmail(sessionToken, token) {
501
548
  try {
502
549
  const r = await api("/tienda/cuenta/confirmar", { method: "POST", token: sessionToken, body: { token } })
@@ -572,31 +619,36 @@ export function createCommerce({
572
619
  getCategory,
573
620
  search,
574
621
  getOrder,
622
+ requestOrderAccess,
575
623
  subirFichero: subirFicheroCliente,
576
- async getWithdrawal(orderId) {
624
+ async getWithdrawal(orderId, acceso) {
577
625
  try {
578
- const { desistimiento } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`)
626
+ const { desistimiento } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, { acceso })
579
627
  return desistimiento ?? null
580
628
  } catch (e) {
581
629
  if (e.status === 404) return null
582
630
  throw e
583
631
  }
584
632
  },
585
- async requestWithdrawal(orderId, datos = {}) {
586
- return await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, { method: "POST", body: datos })
633
+ async requestWithdrawal(orderId, datos = {}, acceso) {
634
+ return await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, {
635
+ method: "POST",
636
+ body: datos,
637
+ acceso,
638
+ })
587
639
  },
588
- async getDownloads(orderId) {
640
+ async getDownloads(orderId, acceso) {
589
641
  try {
590
- const { descargas } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/descargas`)
642
+ const { descargas } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/descargas`, { acceso })
591
643
  return (descargas ?? []).map((d) => ({ ...d, url: d.url ? `${raiz}${d.url}` : null }))
592
644
  } catch (e) {
593
645
  if (e.status === 404) return []
594
646
  throw e
595
647
  }
596
648
  },
597
- async getLicenses(orderId) {
649
+ async getLicenses(orderId, acceso) {
598
650
  try {
599
- const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias`)
651
+ const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias`, { acceso })
600
652
  return (licencias ?? []).map((l) => ({
601
653
  key: l.key,
602
654
  status: l.status,
@@ -610,19 +662,19 @@ export function createCommerce({
610
662
  throw e
611
663
  }
612
664
  },
613
- async getCodes(orderId) {
665
+ async getCodes(orderId, acceso) {
614
666
  try {
615
- const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos`)
667
+ const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos`, { acceso })
616
668
  return codigos ?? []
617
669
  } catch (e) {
618
670
  if (e.status === 404) return []
619
671
  throw e
620
672
  }
621
673
  },
622
- async revealCode(orderId, codeId) {
674
+ async revealCode(orderId, codeId, acceso) {
623
675
  const { codigo } = await api(
624
676
  `/tienda/pedidos/${encodeURIComponent(orderId)}/codigos/${encodeURIComponent(codeId)}/revelar`,
625
- { method: "POST", body: {} },
677
+ { method: "POST", body: {}, acceso },
626
678
  )
627
679
  return codigo ?? null
628
680
  },
package/src/index.d.ts CHANGED
@@ -121,6 +121,33 @@ export interface License {
121
121
  supportedUntil: string | null
122
122
  }
123
123
 
124
+ /** Un sitio donde una licencia esta puesta ahora mismo. */
125
+ export interface LicenseSite {
126
+ /** Lo que identifica esa instalacion: es lo que se manda para desactivarla. */
127
+ instance: string
128
+ /** El dominio, ya normalizado (sin esquema, sin www, sin puerto). */
129
+ site: string
130
+ /** Nombre que le puso la instalacion, si lo mando. */
131
+ label: string | null
132
+ activatedAt: string | null
133
+ /** Ultima vez que esa instalacion comprobo la licencia. */
134
+ lastSeenAt: string | null
135
+ }
136
+
137
+ /**
138
+ * Una licencia vista desde la cuenta de quien la compro: lo mismo que
139
+ * `License` mas los sitios, porque desde aqui se desactivan.
140
+ */
141
+ export interface OwnedLicense {
142
+ key: string
143
+ status: "activa" | "caducada" | "anulada"
144
+ title: string | null
145
+ orderId: string
146
+ limit: number
147
+ supportedUntil: string | null
148
+ sites: LicenseSite[]
149
+ }
150
+
124
151
  export interface ProductCode {
125
152
  id: string
126
153
  lineId: string
@@ -458,6 +485,13 @@ export interface Order {
458
485
  total: Money
459
486
  shippingAddress?: Address | null
460
487
  createdAt?: string
488
+ /**
489
+ * El pedido llega RECORTADO: sin correo ni dirección, porque quien lo pide no
490
+ * ha demostrado todavía que es suyo. Un tema que lo ignore pinta huecos
491
+ * vacíos como si el pedido no tuviera esos datos; lo que toca es pedir el
492
+ * correo con `requestOrderAccess`.
493
+ */
494
+ partial?: boolean
461
495
  }
462
496
 
463
497
  export interface Customer {
@@ -621,22 +655,47 @@ export interface CommerceClient {
621
655
  listCategories(): Promise<Category[]>
622
656
  getCategory(handle: string): Promise<Category | null>
623
657
  search(q: string, limit?: number): Promise<Product[]>
624
- /** Pedido por id, para la página de confirmación tras el checkout. */
625
- getOrder(id: string): Promise<Order | null>
658
+ /**
659
+ * Pedido por id, para la página de confirmación tras el checkout.
660
+ *
661
+ * Un id NO es una credencial: el pedido de quien compró sin cuenta solo sale
662
+ * entero con la prueba de `access`, que llega en el enlace del correo
663
+ * (`?acceso=`), la devuelve `checkout.complete` o la da `requestOrderAccess`.
664
+ * Sin ella vuelve con `partial: true` y sin datos personales.
665
+ */
666
+ getOrder(id: string, access?: string): Promise<Order | null>
667
+ /**
668
+ * Opcional: pedir la prueba de acceso a un pedido con el correo de la compra.
669
+ * Devuelve null si no coincide, que es exactamente lo mismo que devuelve un
670
+ * pedido que no existe: la respuesta no puede decir cuál de las dos cosas
671
+ * pasó. Está limitada por número de intentos, así que el tema tiene que
672
+ * contar con un fallo por exceso.
673
+ */
674
+ requestOrderAccess?(orderId: string, email: string): Promise<{ access: string; days: number } | null>
626
675
  /** Sube un fichero que el cliente adjunta a una personalización. */
627
676
  subirFichero?(archivo: File): Promise<{ id: string; nombre: string; tipo: string; bytes: number; firma: string }>
628
- /** Opcional: las descargas de un pedido con contenido digital. */
629
- getDownloads?(orderId: string): Promise<Download[]>
677
+ /**
678
+ * Opcional: las descargas de un pedido con contenido digital.
679
+ *
680
+ * Estas cuatro NO tienen versión recortada: sin `access` el comercio contesta
681
+ * 403, porque un fichero, una clave de licencia y un código son la compra
682
+ * misma. El tema las llama con la prueba o no las llama.
683
+ */
684
+ getDownloads?(orderId: string, access?: string): Promise<Download[]>
630
685
  /** Opcional: las licencias de las piezas del mercado compradas en un pedido. */
631
- getLicenses?(orderId: string): Promise<License[]>
686
+ getLicenses?(orderId: string, access?: string): Promise<License[]>
632
687
  /** Los codigos de un pedido, tapados. */
633
- getCodes?(orderId: string): Promise<ProductCode[]>
688
+ getCodes?(orderId: string, access?: string): Promise<ProductCode[]>
634
689
  /** Destapa uno y devuelve el codigo entero. Queda apuntado quien y cuando. */
635
- revealCode?(orderId: string, codeId: string): Promise<string>
690
+ revealCode?(orderId: string, codeId: string, access?: string): Promise<string>
636
691
  /** Opcional: estado del derecho de desistimiento de un pedido. */
637
- getWithdrawal?(orderId: string): Promise<WithdrawalState | null>
692
+ getWithdrawal?(orderId: string, access?: string): Promise<WithdrawalState | null>
638
693
  /** Opcional: solicita el desistimiento de un pedido (botón obligatorio en la UE). */
639
- requestWithdrawal?(orderId: string, datos?: { lineas?: string[]; motivo?: string }): Promise<{ solicitud: string; aceptadas: number }>
694
+ requestWithdrawal?(
695
+ orderId: string,
696
+ datos?: { lineas?: string[]; motivo?: string },
697
+ access?: string,
698
+ ): Promise<{ solicitud: string; aceptadas: number }>
640
699
  /**
641
700
  * Opcional: pedir vender en el mercado. Devuelve el id de la solicitud; la
642
701
  * cuenta se crea cuando alguien la acepta. Si el correo ya pidió, falla con
@@ -740,7 +799,12 @@ export interface CommerceClient {
740
799
  * backend. Se usa para la ruta de vuelta, que depende del idioma.
741
800
  */
742
801
  selectPaymentMethod(cartId: string, provider: string, datos?: Record<string, unknown>): Promise<Cart>
743
- complete(cartId: string): Promise<{ order: Order } | { cart: Cart; error: string }>
802
+ /**
803
+ * `access` es la prueba de que ese pedido es de quien acaba de pagarlo: la
804
+ * página de gracias enseña claves y descargas sin volver a pedirle el
805
+ * correo a quien lo escribió dos pantallas antes. Guárdala.
806
+ */
807
+ complete(cartId: string): Promise<{ order: Order; access?: string } | { cart: Cart; error: string }>
744
808
  }
745
809
 
746
810
  /** Opcional: un adaptador puede no soportar cuentas de cliente. */
@@ -750,6 +814,17 @@ export interface CommerceClient {
750
814
  me(token: string): Promise<Customer | null>
751
815
  orders(token: string): Promise<Order[]>
752
816
  confirmEmail?(sessionToken: string, token: string): Promise<{ adopted: number } | null>
817
+ /**
818
+ * Opcional: las licencias que ha comprado, con los sitios donde están
819
+ * puestas ahora mismo. Vacio si el adaptador no vende licencias.
820
+ */
821
+ licenses?(token: string): Promise<OwnedLicense[]>
822
+ /**
823
+ * Opcional: desactiva uno de SUS sitios, que es lo que hace quien se muda
824
+ * de dominio. Rechaza con `status` 400 si ese sitio ya no esta activo o la
825
+ * licencia no es suya, y 429 si se ha pasado del tope.
826
+ */
827
+ releaseSite?(token: string, licenseKey: string, instance: string): Promise<{ released: boolean; site: string }>
753
828
  }
754
829
 
755
830
  /** Opcional: nombre y datos legales de la tienda, tal y como los tiene el panel. */
package/src/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
 
2
+ import { msg } from "./mensajes.js"
3
+
2
4
  export const CONTRACT_VERSION = "1.0"
3
5
 
4
6
  export function money(amount, currency, taxIncluded) {
@@ -25,7 +27,7 @@ export function sumMoney(...importes) {
25
27
  const currency = validos[0].currency
26
28
  for (const m of validos) {
27
29
  if (m.currency !== currency) {
28
- throw new Error(`no se pueden sumar importes en ${currency} y en ${m.currency}`)
30
+ throw new Error(msg("monedasDistintas", currency, m.currency))
29
31
  }
30
32
  }
31
33
  return money(
@@ -43,7 +45,7 @@ const REQUERIDO = {
43
45
 
44
46
  export function assertCommerceClient(cliente) {
45
47
  const faltan = []
46
- if (!cliente || typeof cliente !== "object") return ["el cliente no es un objeto"]
48
+ if (!cliente || typeof cliente !== "object") return [msg("clienteNoObjeto")]
47
49
  for (const m of REQUERIDO.raiz) {
48
50
  if (typeof cliente[m] !== "function") faltan.push(m)
49
51
  }
@@ -0,0 +1,31 @@
1
+ export function idioma(entorno) {
2
+ const env = entorno ?? (typeof process !== "undefined" && process?.env ? process.env : {})
3
+ for (const v of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
4
+ if (v) return /^es([_.@-]|$)/i.test(String(v)) ? "es" : "en"
5
+ }
6
+ return "en"
7
+ }
8
+
9
+ const en = {
10
+ monedasDistintas: (a, b) => `cannot add amounts in ${a} and in ${b}`,
11
+ clienteNoObjeto: () => "the client is not an object",
12
+ faltaBaseUrl: () => "createCommerce: baseUrl is missing",
13
+ ficheroNoSubido: () => "The file could not be uploaded.",
14
+ codigoNoValido: () => "That code cannot be used.",
15
+ }
16
+
17
+ const es = {
18
+ monedasDistintas: (a, b) => `no se pueden sumar importes en ${a} y en ${b}`,
19
+ clienteNoObjeto: () => "el cliente no es un objeto",
20
+ faltaBaseUrl: () => "createCommerce: falta baseUrl",
21
+ ficheroNoSubido: () => "No se pudo subir el fichero.",
22
+ codigoNoValido: () => "Ese código no se puede usar.",
23
+ }
24
+
25
+ export const MENSAJES = { en, es }
26
+
27
+ export function msg(clave, ...args) {
28
+ const f = MENSAJES[idioma()][clave] ?? en[clave]
29
+ if (typeof f !== "function") throw new Error(`message without text: ${clave}`)
30
+ return f(...args)
31
+ }