Services
Service catalogue with categories and packages, and their availability.
Modules are installed from your Hub's marketplace. Sign up for a Hub to access this module.
Módulo services — catálogo de servicios, categorías y bonos
Catálogo de lo que se vende cuando lo vendido no es físico: precio, duración, capacidad y
opciones de reserva. Categorías jerárquicas y paquetes/bonos con descuento, con libro de
usos append-only (redeem). Es el catálogo contra el que reserva appointments.
Module id:
services. Depende de:taxes(instalar services auto-instala taxes, ADR-0066/0085). Módulo híbrido: SQL + handler WASM (bulk_create_services,create_package,grant_package,redeem_package,on_sale_completed).
Documentación de usuario — docs/
Viaja dentro del módulo y se versiona con él: el asistente del hub (ADR-0282) la indexa por versión instalada y cita la de TU versión, no la de la última publicada. En inglés (idioma fuente).
| Fichero | Para qué |
|---|---|
docs/overview.md |
Qué hace y qué NO hace; qué es un paquete en un párrafo |
docs/screens.md |
Servicios, categorías y paquetes: crear, empaquetar, consultar saldo y canjear |
docs/concepts.md |
Paquete ≠ concesión ≠ canje, la vigencia arranca en la COMPRA, % y fixed son campos DISTINTOS, cantidad de bono en punto fijo 10⁶ |
docs/limits.md |
Huecos conocidos (variantes/addons inalcanzables, borrado sin cascada ni guard de citas), errores y permisos |
Qué expone hoy
| Tipo | Nombre | Permiso |
|---|---|---|
| query | services.services.list / .get · services.settings.get · services.catalog.status |
view_service |
| query | services.categories.list |
view_category |
| query | services.packages.list / .get · services.package_items.list |
view_package |
| query | services.packages.balance · .redeem_check |
view_package_balance · redeem_package |
| command | services.services.create (→ services.category_unavailable) |
add_service |
| command | services.services.update (→ services.service_update_rejected) / .bulk_create (WASM) |
change_service |
| command | services.services.delete |
delete_service |
| command | services.categories.create / .update / .delete |
los *_category |
| command | services.packages.create (WASM) / .update / .delete |
los *_package |
| command | services.packages.grant (WASM + gate) — la COMPRA: quién, qué bono, cuándo, en qué venta y por cuánto |
grant_package |
| command | services.packages.redeem (WASM + gate, rechaza con código de dominio: package_no_grant / package_no_uses_left / package_expired / package_not_found) |
redeem_package |
| command | services.settings.update |
manage_settings (solo admin) |
| emite | services.service.*, services.package.* (incl. services.package.granted y .redeemed) |
— |
| escucha | — | — |
Navegación: erp-services-list («Services»); ajustes declarativos (ADR-0082).
Layout
module.json # manifest (contrato técnico)
migrations/postgres/ # esquema §2.5 + libro de usos + tabla guardia services__gate
queries/*.sql # lecturas declarativas (:hub_id inyectado)
commands/*.sql # escrituras declarativas (las `_` son intenciones del WASM)
schemas/*.json # JSON Schemas de input (draft 2020-12)
handler/ # WASM Tier 2 → dist/handler.wasm
ui/ # Web Components (Lit/Ionic/OutfitKit)
docs/ # documentación de usuario + corpus del asistente
Estado y trabajo abierto
El estado vive en las Issues de este repo, no aquí. Huecos conocidos y documentados en
docs/limits.md: variantes y addons existen en BD sin query ni command. Archivar un servicio
con citas próximas avisa con el conteo (query pública de appointments), no bloquea (services#2).
La concesión del bono se escribe aquí al comprarlo (services#73, ADR-0390): el listener de
sale.completed concede cada bono que el ticket vendió, y services.packages.grant es la puerta
manual. Sin concesión no se gasta una sesión.
Doc de arquitectura: architecture/modules/services.md (cargarlo antes de tocar el módulo).
This module requires the following modules to be installed:
Sign in to leave a review
Share your experience with this module
User Reviews
No reviews yet
Be the first to review this module
chore(release): v1.5.42
chore(release): v1.5.41
chore(release): v1.5.40
chore(release): v1.5.39
chore(release): v1.5.38
Un canje tomado sobrevive a recargar la pantalla de cobro, y el abandonado devuelve la sesión (services#77) (#87) * test(packages): reproduce la sesión perdida al recargar la pantalla de cobro (services#77) RED a propósito. La sección A pasa contra un Postgres real —el hold GASTA la sesión en el acto y el bono deja de ofrecerse— y la B revienta: con las cuatro props que el host reemite en cada montaje (customer_id, service_id, checkout_ref, line_ref) NINGUNA de las 14 queries declaradas responde «¿qué tenía yo ya tomado en este cobro?», así que el redemption_id se fue con la memoria del componente y release_hold, que lo exige, no tiene por dónde entrar. Deja escrito además lo que el arreglo tiene que garantizar: aislamiento con vecino VIVO (mismo checkout_ref y mismo line_ref al lado), que un hold liquidado NO vuelve, que un cobro sin holds devuelve lista vacía y no error, y que el hold abandonado se suelta solo sin depender de que el cron corra. * feat(packages): el canje tomado sobrevive a la recarga, y el abandonado devuelve la sesión (services#77) VERDE de la batería que el commit anterior dejó en rojo. La LECTURA que rehidrata: services.packages.holds_for_checkout(checkout_ref) enumera los canjes vivos de un cobro abierto —redemption_id, grant, bono, línea, servicio y el contador que la cajera estaba mirando— así que la pantalla vuelve a su estado «canjeado, con deshacer» con las cuatro props que el host reemite en cada montaje. sales no aprende nada: sigue hospedando un slot y sin saber qué es un bono (ADR-0386). Devuelve SOLO lo deshacible: una sesión liquidada no vuelve (devolverla es una devolución, con su puerta auditada) y una soltada o vencida tampoco (la sesión ya está en el bono). El HOLD HUÉRFANO: plazo de UN DÍA puesto por el servidor (migración 014), nunca por el payload. Lo decide el mercado (13 referencias en el PR): la retención de minutos existe para arbitrar entre EXTRAÑOS CONCURRENTES en una web; en un mostrador hay una recepcionista, una clienta y una caja, así que un temporizador corto no compra nada y mete el fallo que venía a evitar —el bono soltándose solo mientras la clienta paga—. Los productos de mostrador barren al CERRAR EL DÍA O EL TURNO (Toast a las 4:00, Dynamics «Void when closing shift», la limpieza diaria de WooCommerce); los que no barren nada (Odoo POS, Lightspeed, Clover) son los de los foros llenos de «no puedo cerrar caja». 🔴 Y la barrida NO es lo que libera la sesión, que es el diseño entero: 1. las LECTURAS dejan de contar una retención en cuanto vence (tender_options, balance, redeem_check, holds_for_checkout) — tender_options es la que decide si el bono se OFRECE, así que si contara una retención rancia la cajera no podría ni intentarlo; 2. commands/hold_expire.sql es la PRIMERA sentencia de services._hold y services._redeem, en su propia transacción: la sesión se recupera por el acto mismo de intentar usarla. Un guarda que depende de un worker no es un guarda — WooCommerce libera el stock desde un cron al mismo intervalo que la retención y lo deja bloqueado PARA SIEMPRE cuando no corre; appointments#69 ya escribió esa lección aquí. El settle GANA igualmente: no consulta el plazo (un settle ES alguien volviendo) y limpia expires_at. Sin ventana de doble gasto: la sesión recuperada solo puede TOMARSE por _hold o _redeem, y ambos borran en blando la fila rancia antes de contar. release_reason ('released' | 'expired') separa la decisión del vencimiento: ambos borran en blando, y sin el sello el histórico del salón enseñaría dos sucesos distintos como la misma fila. Ajustes a tests existentes, con su motivo escrito: redeem_reasons.contract fija la POSICIÓN de hold_expire.sql (después del INSERT llegaría una sentencia tarde) y package_grant aplica 014 antes de consultar balance (un hub nunca consulta contra un esquema a medio migrar). * test(packages): probar que lo que libera la sesión es el RELOJ, no el borrado en blando Un mutante que borraba el predicado de caducidad de la lectura SOBREVIVÍA: todas las aserciones se comprobaban DESPUÉS de la barrida, así que las sostenía is_deleted = 1 y no el reloj. Ahora la sección G afirma con la fila todavía VIVA y sin barrida ninguna: pasado el plazo la pantalla ya no ve el canje, el TPV vuelve a ofrecer el bono, y se comprueba que la fila sigue viva para que no quepa duda de cuál de los dos mecanismos actuó. El bono de la sección es de UNA sesión a propósito: con tres se ofrecería igualmente y la aserción pasaría sin demostrar nada. * test(ui): el componente de cobro no se rehidrata tras recargar (services#77) ROJO: 6 de 8 nuevos fallan, y los 17 anteriores siguen en verde. El componente no pregunta services.packages.holds_for_checkout al montarse, así que vuelve pintando el selector sobre una sesión YA gastada — que es exactamente lo que cobra el corte entero y pierde la sesión. Deja fijado también el caso peligroso: si la lectura de recuperación FALLA, no se pinta el selector. El riesgo aquí no es «no hay bonos», es «vuelve a ofrecerlo». * feat(ui): la pantalla de cobro vuelve como estaba tras recargarse (services#77) VERDE los 8 nuevos; los 17 anteriores del componente y los 162 de ui/ siguen en verde. Al montarse, el componente pregunta PRIMERO services.packages.holds_for_checkout con el checkout_ref que el host reemite, y si este cobro ya tiene una sesión tomada para SU línea vuelve al estado «canjeado, con deshacer» en vez de al selector. Pintar el selector sobre una sesión ya gastada es lo que cobraba el corte entero: el host no sabe que la línea está cubierta, así que la clienta pagaba el corte Y perdía la sesión. La lectura es por cobro y el filtro por line_ref se hace aquí: un cobro cubre varias líneas y cada una hospeda su propio slot, así que una consulta responde al tique entero y cada slot saca su fila, en vez de N viajes que dirían lo mismo. 🔴 Falla CERRADO: si la recuperación revienta NO se pinta el selector. El riesgo aquí no es «no hay bonos», es «vuelve a ofrecerlo» — confirmar chocaría con el índice único de la línea en el mejor caso y gastaría una segunda sesión en el peor. Se enseña el aviso y el reintento; ui.tender.loadFailed ya dice lo único que hay que leer («inténtalo otra vez antes de cobrar el precio completo»), así que no hace falta cadena nueva. Sin cadenas nuevas => no queda traducción pendiente (la regla en+es se cumple reusando las que ya llevan su par). dist/ reconstruido con `erplora build` y .erplora/contracts.json regenerado, que es lo que el gate exigía: la nueva query entra como contrato consumido. El stub del test de deshacer se hizo FIEL —tras release_hold el servidor deja de listar el canje— porque un mock que siguiera diciendo «sigue tomado» escondía justo el viaje que ese test demuestra: el id salió del servidor, volvió al servidor, y la pantalla releyó la respuesta en vez de fiarse de sí misma. * test(packages): cerrar dos huecos que los mutantes destaparon (services#77) Un mutante que hacia que remaining_after contara tambien las retenciones rancias SOBREVIVIA: ninguna seccion tenia una concesion con DOS retenciones a la vez, asi que el predicado del reloj no estaba cubierto en ese contador. La seccion L construye el caso — una sesion tomada el martes esta rancia el jueves mientras la del miercoles no, y NINGUNA se ha barrido porque nadie ha intentado gastar de ese bono en medio — y comprueba ademas que el TPV da el mismo numero, que es para lo que comparten predicado. Y un mutante sobre la rama is_unlimited del componente sobrevivia porque era INALCANZABLE: la query deriva remaining_after y is_unlimited del mismo max_uses IS NULL, asi que re-derivarlo en el componente era una segunda opinion sobre una pregunta ya respondida y una rama que ningun test podia distinguir. Se quita la rama (manda la SQL) y la garantia se prueba donde vive, en la seccion K. Regla que deja escrita: un mutante que sobrevive es o un hueco del test o codigo muerto. Aqui hubo uno de cada. * test(packages): la pantalla de saldo es el TERCER lector del mismo numero (services#77) Un mutante que quitaba el predicado del reloj SOLO de package_balance sobrevivia: la bateria comprobaba holds_for_checkout y tender_options, pero nunca preguntaba el saldo. Un salon que le dice a la clienta «te queda 1» mientras la caja cobra contra 2 tiene una reclamacion, asi que los tres lectores se afirman juntos. * test(packages): el CUARTO lector es el que decide el MENSAJE de rechazo (services#77) Un mutante que quitaba el predicado del reloj de redeem_check sobrevivia. Esa lectura la precargan los handlers de hold_for_line y redeem, asi que si siguiera contando la retencion rancia le diria a la cajera «no quedan sesiones» de un bono que la sentencia siguiente va a gastar sin problema: un motivo de rechazo que no es cierto es peor que no dar motivo. El bono de la seccion tiene UNA sesion, que es lo que hace que la respuesta dependa solo de ese predicado. --------- Co-authored-by: Claude <[email protected]>
chore(release): v1.5.36
chore(release): v1.5.35
chore(release): v1.5.34
chore(release): v1.5.33
chore(release): v1.5.32
chore(release): v1.5.31
chore(release): v1.5.30
chore(release): v1.5.29
chore(release): v1.5.28
chore(release): v1.5.27
chore(release): v1.5.26
chore(release): v1.5.25
chore(release): v1.5.24
chore(release): v1.5.23
Related Modules
Book client appointments against services and staff, and track them from booked to completed.
Internal staff tasks and projects, with assignment, status and comments.