@salifbiaye/create-boutique 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +143 -10
  3. package/cli/dist/create.js +54 -7
  4. package/cli/dist/generate.js +65 -4
  5. package/cli/dist/guide.js +10 -2
  6. package/cli/dist/index.js +20 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/docs/fiche-client.en.html +2 -2
  9. package/docs/fiche-client.html +2 -2
  10. package/docs/guide-complet.fr.html +681 -256
  11. package/docs/guide-images/categories.jpg +0 -0
  12. package/docs/guide-images/clients.jpg +0 -0
  13. package/docs/guide-images/collections.jpg +0 -0
  14. package/docs/guide-images/commande-detail.jpg +0 -0
  15. package/docs/guide-images/commande-expedier.jpg +0 -0
  16. package/docs/guide-images/commande-menu.jpg +0 -0
  17. package/docs/guide-images/commande-paiement.jpg +0 -0
  18. package/docs/guide-images/commandes-liste.jpg +0 -0
  19. package/docs/guide-images/connexion.jpg +0 -0
  20. package/docs/guide-images/groupes-clients.jpg +0 -0
  21. package/docs/guide-images/inventaire.jpg +0 -0
  22. package/docs/guide-images/listes-prix.jpg +0 -0
  23. package/docs/guide-images/menu.jpg +0 -0
  24. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques.jpg +0 -0
  27. package/docs/guide-images/parametres-boutique.jpg +0 -0
  28. package/docs/guide-images/parametres-canaux.jpg +0 -0
  29. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  30. package/docs/guide-images/parametres-profil.jpg +0 -0
  31. package/docs/guide-images/parametres-regions.jpg +0 -0
  32. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  33. package/docs/guide-images/produit-creer-details.jpg +0 -0
  34. package/docs/guide-images/produit-detail.jpg +0 -0
  35. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  36. package/docs/guide-images/produit-variantes.jpg +0 -0
  37. package/docs/guide-images/produits-liste.jpg +0 -0
  38. package/docs/guide-images/promotion-details.jpg +0 -0
  39. package/docs/guide-images/promotion-type.jpg +0 -0
  40. package/docs/guide-images/promotions-liste.jpg +0 -0
  41. package/docs/guide-images/site-boutique.jpg +0 -0
  42. package/docs/guide-images/site-fermee.png +0 -0
  43. package/docs/guide-images/vue-ensemble.jpg +0 -0
  44. package/package.json +3 -2
  45. package/templates/backend/medusa-config.ts +31 -25
  46. package/templates/backend/package-lock.json +21 -0
  47. package/templates/backend/package.json +2 -0
  48. package/templates/backend/src/admin/i18n/json/en.json +51 -3
  49. package/templates/backend/src/admin/i18n/json/fr.json +51 -3
  50. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +2 -0
  51. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +36 -1
  52. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  53. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +3 -1
  54. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  55. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  56. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  57. package/templates/backend/src/api/middlewares.ts +7 -0
  58. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  59. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  60. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  61. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  62. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  63. package/templates/backend/src/emails/en.json +3 -1
  64. package/templates/backend/src/emails/fr.json +3 -1
  65. package/templates/backend/src/lib/email-config.ts +14 -0
  66. package/templates/backend/src/lib/emails.ts +9 -0
  67. package/templates/backend/src/lib/notify.ts +14 -8
  68. package/templates/backend/src/lib/payment-config.ts +66 -0
  69. package/templates/backend/src/lib/project.ts +12 -0
  70. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  71. package/templates/backend/src/lib/review-token.ts +40 -0
  72. package/templates/backend/src/lib/reviews.ts +25 -0
  73. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  74. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  75. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  76. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  77. package/templates/backend/src/modules/paytech/index.ts +6 -0
  78. package/templates/backend/src/modules/paytech/service.ts +114 -0
  79. package/templates/backend/src/modules/product-review/index.ts +8 -0
  80. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  81. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  82. package/templates/backend/src/modules/product-review/service.ts +6 -0
  83. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  84. package/templates/backend/src/modules/smtp/index.ts +6 -0
  85. package/templates/backend/src/modules/smtp/service.ts +73 -0
  86. package/templates/backend/src/scripts/seed.ts +52 -36
  87. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  88. package/templates/backend/src/workflows/reviews.ts +21 -0
  89. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  90. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  91. package/templates/storefront/PERSONNALISER.md +134 -0
  92. package/templates/storefront/locales/en.json +198 -11
  93. package/templates/storefront/locales/fr.json +198 -11
  94. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  95. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  96. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  97. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  98. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  99. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  100. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  101. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  102. package/templates/storefront/src/app/layout.tsx +13 -1
  103. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  104. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  105. package/templates/storefront/src/lib/boutique/schema.ts +3 -1
  106. package/templates/storefront/src/lib/constants.tsx +17 -0
  107. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  108. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  109. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  110. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  111. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  112. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  113. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  114. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  115. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  116. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  117. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  118. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  119. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  120. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  121. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  122. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  123. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  124. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  125. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  126. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  127. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  128. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  129. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  130. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  131. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  132. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  133. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  134. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  135. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  136. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  137. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  138. package/templates/storefront/src/proxy.ts +2 -0
  139. package/templates/storefront/src/styles/custom.css +26 -0
  140. package/templates/storefront/src/styles/ub/album.css +1 -1
  141. package/templates/storefront/src/styles/ub/arena.css +120 -0
  142. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  143. package/templates/storefront/src/styles/ub/gala.css +1 -1
  144. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  145. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  146. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  147. package/templates/storefront/src/styles/ub/locker.css +115 -0
  148. package/templates/storefront/src/styles/ub/media.css +10 -0
  149. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  150. package/templates/storefront/src/styles/ub/motion.css +0 -3
  151. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  152. package/templates/storefront/src/styles/ub/pages.css +176 -0
  153. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  154. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  155. package/templates/storefront/src/styles/ub/souk.css +144 -0
  156. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  157. package/templates/storefront/src/styles/ub/tech.css +2 -5
  158. package/templates/storefront/src/styles/ub/training.css +120 -0
  159. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  160. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  161. package/templates/storefront/themes/jeux/theme.json +11 -0
  162. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  163. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  164. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  165. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  166. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  167. package/templates/storefront/themes/mubarak/theme.json +11 -0
  168. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  169. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  170. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  171. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  172. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  173. package/templates/storefront/themes/sport/theme.json +11 -0
  174. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  175. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  176. package/templates/storefront/themes/sport-2/theme.json +11 -0
  177. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  178. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  179. package/templates/storefront/themes/summer/theme.json +11 -0
  180. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  181. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  182. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -2,6 +2,8 @@ import { MedusaContainer } from "@medusajs/framework/types"
2
2
  import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
3
3
  import { type EmailType, renderEmail, emailLabel } from "./emails"
4
4
  import { readProject } from "./project"
5
+ import { emailMode } from "./email-config"
6
+ import { reviewToken } from "./review-token"
5
7
 
6
8
  type Options = {
7
9
  /** Texte en plus sous l'introduction (ex. numéro de suivi) */
@@ -12,6 +14,8 @@ type Options = {
12
14
  vars?: Record<string, string>
13
15
  /** Envoyer le récapitulatif des articles */
14
16
  withItems?: boolean
17
+ /** Bouton « Laisser un avis » vers la boutique de la commande (lien signé, e-mail « commande livrée ») */
18
+ reviewLink?: boolean
15
19
  }
16
20
 
17
21
  let warned = false
@@ -19,13 +23,13 @@ let warned = false
19
23
  /**
20
24
  * E-mail au client d'une commande (et au marchand pour une nouvelle commande).
21
25
  * Langue, nom et couleurs : ceux de la boutique d'où vient la commande (sales channel → project.json).
22
- * Sans RESEND_API_KEY : rien n'est envoyé (la boutique fonctionne quand même, `check` le signale).
26
+ * Sans envoi configuré (ni Resend ni SMTP, voir email-config.ts) : rien n'est envoyé (la boutique fonctionne quand même).
23
27
  * Une erreur d'envoi est journalisée mais ne bloque jamais la commande.
24
28
  */
25
29
  export async function sendOrderEmail(container: MedusaContainer, orderId: string, type: EmailType, opts: Options = {}) {
26
30
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
27
- if (!process.env.RESEND_API_KEY) {
28
- if (!warned) logger.warn("[emails] RESEND_API_KEY absente : aucun e-mail n'est envoyé")
31
+ if (!emailMode()) {
32
+ if (!warned) logger.warn("[emails] ni RESEND_API_KEY ni SMTP configuré : aucun e-mail n'est envoyé")
29
33
  warned = true
30
34
  return
31
35
  }
@@ -79,8 +83,10 @@ export async function sendOrderEmail(container: MedusaContainer, orderId: string
79
83
  items: opts.withItems ? items : undefined,
80
84
  total: opts.withItems ? money(order.total) : undefined,
81
85
  address: opts.withItems ? address : undefined,
82
- extra: opts.extra,
83
- button: opts.button,
86
+ extra: opts.reviewLink ? emailLabel(project.language, type, "reviewText") : opts.extra,
87
+ button: opts.reviewLink
88
+ ? { label: emailLabel(project.language, type, "review"), url: `${boutique.url}/review?token=${encodeURIComponent(reviewToken(order.id))}` }
89
+ : opts.button,
84
90
  })
85
91
 
86
92
  const notifications = container.resolve(Modules.NOTIFICATION)
@@ -115,12 +121,12 @@ export const adminUrl = () => (process.env.ADMIN_URL || process.env.ADMIN_CORS?.
115
121
 
116
122
  /**
117
123
  * E-mails de compte admin (mot de passe oublié, invitation d'équipe) : un bouton vers l'admin.
118
- * Sans RESEND_API_KEY : rien n'est envoyé (le lien reste visible dans les logs du backend en dev).
124
+ * Sans envoi configuré (ni Resend ni SMTP) : rien n'est envoyé (le lien reste visible dans les logs du backend en dev).
119
125
  */
120
126
  export async function sendAccountEmail(container: MedusaContainer, to: string, type: "password_reset" | "team_invite", url: string) {
121
127
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
122
- if (!process.env.RESEND_API_KEY) {
123
- logger.warn(`[emails] RESEND_API_KEY absente : e-mail « ${type} » non envoyé à ${to}`)
128
+ if (!emailMode()) {
129
+ logger.warn(`[emails] aucun envoi configuré (Resend ou SMTP) : e-mail « ${type} » non envoyé à ${to}`)
124
130
  return
125
131
  }
126
132
  try {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Moyens de paiement activés d'après l'environnement (.env). Un moyen sans ses clés n'est pas chargé.
3
+ * Utilisé par medusa-config.ts (providers à charger) et par le seed (providers de chaque région, selon sa devise).
4
+ *
5
+ * - Manuel (pp_system_default) : toujours.
6
+ * - Stripe (carte, Apple Pay, Google Pay) : STRIPE_API_KEY.
7
+ * - PayTech (Wave, Orange Money, Free Money, carte) : PAYTECH_API_KEY + PAYTECH_API_SECRET ; PAYTECH_ENV=test|prod.
8
+ * - PayDunya (Wave, Orange Money, carte) : PAYDUNYA_MASTER_KEY + PAYDUNYA_PRIVATE_KEY + PAYDUNYA_TOKEN ; PAYDUNYA_ENV=test|prod.
9
+ * - NabooPay (Wave, Orange Money) : NABOOPAY_API_KEY (+ NABOOPAY_WEBHOOK_SECRET) ; NABOOPAY_METHODS=wave,orange_money.
10
+ */
11
+ type Provider = { resolve: string; id: string; options: Record<string, unknown>; providerId: string; currencies?: string[] }
12
+
13
+ export function paymentProviders(env: NodeJS.ProcessEnv = process.env): Provider[] {
14
+ const list: Provider[] = []
15
+ if (env.STRIPE_API_KEY) {
16
+ list.push({
17
+ resolve: "@medusajs/medusa/payment-stripe",
18
+ id: "stripe",
19
+ providerId: "pp_stripe_stripe",
20
+ options: { apiKey: env.STRIPE_API_KEY, webhookSecret: env.STRIPE_WEBHOOK_SECRET || undefined, capture: true, automaticPaymentMethods: true },
21
+ })
22
+ }
23
+ if (env.PAYTECH_API_KEY && env.PAYTECH_API_SECRET) {
24
+ list.push({
25
+ resolve: "./src/modules/paytech",
26
+ id: "paytech",
27
+ providerId: "pp_paytech_paytech",
28
+ currencies: ["xof", "eur", "usd", "cad", "gbp", "mad"],
29
+ options: { api_key: env.PAYTECH_API_KEY, api_secret: env.PAYTECH_API_SECRET, env: env.PAYTECH_ENV === "prod" ? "prod" : "test" },
30
+ })
31
+ }
32
+ if (env.PAYDUNYA_MASTER_KEY && env.PAYDUNYA_PRIVATE_KEY && env.PAYDUNYA_TOKEN) {
33
+ list.push({
34
+ resolve: "./src/modules/paydunya",
35
+ id: "paydunya",
36
+ providerId: "pp_paydunya_paydunya",
37
+ currencies: ["xof"],
38
+ options: {
39
+ master_key: env.PAYDUNYA_MASTER_KEY,
40
+ private_key: env.PAYDUNYA_PRIVATE_KEY,
41
+ token: env.PAYDUNYA_TOKEN,
42
+ env: env.PAYDUNYA_ENV === "prod" ? "prod" : "test",
43
+ },
44
+ })
45
+ }
46
+ if (env.NABOOPAY_API_KEY) {
47
+ list.push({
48
+ resolve: "./src/modules/naboopay",
49
+ id: "naboopay",
50
+ providerId: "pp_naboopay_naboopay",
51
+ currencies: ["xof"],
52
+ options: {
53
+ api_key: env.NABOOPAY_API_KEY,
54
+ webhook_secret: env.NABOOPAY_WEBHOOK_SECRET || undefined,
55
+ methods: (env.NABOOPAY_METHODS || "wave,orange_money").split(",").map((m) => m.trim()).filter(Boolean),
56
+ },
57
+ })
58
+ }
59
+ return list
60
+ }
61
+
62
+ /** Providers d'une région (devise) : manuel + ceux qui acceptent cette devise */
63
+ export function regionPaymentProviders(currency: string, env: NodeJS.ProcessEnv = process.env): string[] {
64
+ const c = currency.toLowerCase()
65
+ return ["pp_system_default", ...paymentProviders(env).filter((p) => !p.currencies || p.currencies.includes(c)).map((p) => p.providerId)]
66
+ }
@@ -24,13 +24,17 @@ const projectSchema = z.object({
24
24
  language: z.string().default("en"),
25
25
  shipsTo: shipsTo.default("all"),
26
26
  merchantEmail: z.email().optional(),
27
+ /** Avis : « shared » = un produit affiche les avis de toutes les boutiques (défaut) ; « boutique » = seulement les siens */
28
+ reviews: z.enum(["shared", "boutique"]).default("shared"),
27
29
  }),
30
+ site: z.looseObject({ port: z.number().optional(), url: z.string().optional() }).optional(),
28
31
  boutiques: z
29
32
  .array(
30
33
  z.looseObject({
31
34
  slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
32
35
  theme: z.string(),
33
36
  port: z.number().optional(),
37
+ url: z.string().optional(),
34
38
  storeName: z.string().optional(),
35
39
  currency: code3.optional(),
36
40
  country: code2.optional(),
@@ -47,6 +51,8 @@ export type Boutique = {
47
51
  currency: string
48
52
  country: string
49
53
  shipsTo: "all" | string[]
54
+ /** Adresse publique du site de la boutique (mode single : celle du site unique) */
55
+ url: string
50
56
  }
51
57
 
52
58
  export type Project = {
@@ -56,6 +62,7 @@ export type Project = {
56
62
  defaultCurrency: string
57
63
  language: string
58
64
  merchantEmail?: string
65
+ reviews: "shared" | "boutique"
59
66
  boutiques: Boutique[]
60
67
  }
61
68
 
@@ -73,6 +80,7 @@ export function readProject(): Project {
73
80
  defaultCurrency: defaults.currency,
74
81
  language: defaults.language,
75
82
  merchantEmail: defaults.merchantEmail,
83
+ reviews: defaults.reviews,
76
84
  boutiques: boutiques.map((b) => ({
77
85
  slug: b.slug,
78
86
  theme: b.theme,
@@ -80,6 +88,10 @@ export function readProject(): Project {
80
88
  currency: b.currency ?? defaults.currency,
81
89
  country: b.country ?? defaults.country,
82
90
  shipsTo: b.shipsTo ?? defaults.shipsTo,
91
+ url: (res.data.mode === "single"
92
+ ? (res.data.site?.url ?? `http://localhost:${res.data.site?.port ?? 8000}`)
93
+ : (b.url ?? `http://localhost:${b.port ?? 8000}`)
94
+ ).replace(/\/+$/, ""),
83
95
  })),
84
96
  }
85
97
  }
@@ -0,0 +1,264 @@
1
+ import crypto from "node:crypto"
2
+ import type {
3
+ AuthorizePaymentInput,
4
+ AuthorizePaymentOutput,
5
+ CancelPaymentInput,
6
+ CancelPaymentOutput,
7
+ CapturePaymentInput,
8
+ CapturePaymentOutput,
9
+ DeletePaymentInput,
10
+ DeletePaymentOutput,
11
+ GetPaymentStatusInput,
12
+ GetPaymentStatusOutput,
13
+ InitiatePaymentInput,
14
+ InitiatePaymentOutput,
15
+ Logger,
16
+ PaymentSessionStatus,
17
+ ProviderWebhookPayload,
18
+ RefundPaymentInput,
19
+ RefundPaymentOutput,
20
+ RetrievePaymentInput,
21
+ RetrievePaymentOutput,
22
+ UpdatePaymentInput,
23
+ UpdatePaymentOutput,
24
+ WebhookActionResult,
25
+ } from "@medusajs/framework/types"
26
+ import { AbstractPaymentProvider, BigNumber, MedusaError } from "@medusajs/framework/utils"
27
+
28
+ /**
29
+ * Base commune des agrégateurs à redirection d'Afrique de l'Ouest (PayTech, PayDunya, NabooPay : Wave, Orange Money,
30
+ * Free Money, carte…). Le déroulé est le même pour les trois :
31
+ *
32
+ * 1. initiatePayment : on crée le paiement chez l'agrégateur (montant calculé par Medusa, jamais envoyé par le site)
33
+ * et on garde son adresse de paiement (redirect_url) dans les données de la session ;
34
+ * 2. le site envoie le client sur cette adresse ; il paie (Wave, Orange Money, carte…) puis revient sur le site ;
35
+ * 3. au retour, le site termine la commande : Medusa appelle authorizePayment, qui DEMANDE le statut à l'agrégateur
36
+ * (on ne croit jamais l'URL de retour) → « captured » si payé ;
37
+ * 4. en parallèle, l'agrégateur notifie le backend (/hooks/payment/<id>_<id>) : notification vérifiée (signature),
38
+ * puis statut reconfirmé auprès de l'agrégateur ; Medusa termine alors la commande même si le client n'est pas revenu.
39
+ *
40
+ * Le numéro de session Medusa (data.session_id, fourni par Medusa) voyage chez l'agrégateur (champ libre ou URL de
41
+ * retour) pour relier la notification à la bonne session.
42
+ */
43
+
44
+ export type PayStatus = "paid" | "pending" | "failed"
45
+
46
+ export type Checkout = {
47
+ amount: number
48
+ currency: string
49
+ sessionId: string
50
+ /** Adresse de retour sur la boutique (vérifiée : une des origines de STORE_CORS) */
51
+ returnUrl: string
52
+ cancelUrl: string
53
+ /** Adresse publique de notification (IPN) ; absente si le backend n'est pas en HTTPS (les agrégateurs l'exigent) */
54
+ notifyUrl?: string
55
+ label: string
56
+ customer: { first_name?: string; last_name?: string; phone?: string; email?: string }
57
+ }
58
+
59
+ export type WebhookInfo = { ref: string; sessionId: string; amount: number }
60
+
61
+ type Deps = { logger: Logger }
62
+
63
+ export abstract class RedirectPaymentProvider<TOptions extends Record<string, unknown>> extends AbstractPaymentProvider<TOptions> {
64
+ protected logger: Logger
65
+ protected options: TOptions
66
+
67
+ /** Devises acceptées par l'agrégateur (codes Medusa, minuscules) */
68
+ protected abstract currencies: string[]
69
+ /** Chemin du webhook après /hooks/payment/ (ex. « paytech_paytech ») */
70
+ protected abstract hookId: string
71
+
72
+ protected abstract createCheckout(c: Checkout): Promise<{ ref: string; redirect_url: string; extra?: Record<string, unknown> }>
73
+ protected abstract fetchStatus(ref: string): Promise<PayStatus>
74
+ /** Notification : vérifie l'authenticité (sinon null) et en extrait la référence et la session */
75
+ protected abstract parseWebhook(body: Record<string, unknown>, raw: string, headers: Record<string, unknown>): WebhookInfo | null
76
+ /**
77
+ * Remboursement chez l'agrégateur. Par défaut (agrégateur sans API de remboursement, ex. PayDunya) : le marchand
78
+ * rembourse dans le tableau de bord de l'agrégateur, puis le bouton Remboursement de l'admin l'ENREGISTRE (commande,
79
+ * e-mail au client) sans mouvement d'argent ici. Voir le guide client, section 4.3.
80
+ */
81
+ protected async refund(_data: Record<string, unknown>, amount: number): Promise<void> {
82
+ this.logger.warn(`[${this.hookId}] remboursement de ${amount} enregistré : à effectuer dans le tableau de bord de l'agrégateur`)
83
+ }
84
+
85
+ constructor({ logger }: Deps, options: TOptions) {
86
+ super({ logger }, options)
87
+ this.logger = logger
88
+ this.options = options
89
+ }
90
+
91
+ // ---------- Adresses ----------
92
+
93
+ private get storeOrigins() {
94
+ return (process.env.STORE_CORS ?? "").split(",").map((o) => o.trim().replace(/\/+$/, "")).filter(Boolean)
95
+ }
96
+
97
+ private get backendUrl() {
98
+ return (process.env.ADMIN_URL || process.env.ADMIN_CORS?.split(",")[0] || "http://localhost:9000").replace(/\/+$/, "")
99
+ }
100
+
101
+ /** Adresse de retour fournie par le site : acceptée seulement si elle pointe vers une des boutiques du projet */
102
+ private safeUrl(url: unknown, fallbackPath: string) {
103
+ const origins = this.storeOrigins
104
+ if (typeof url === "string" && origins.some((o) => url === o || url.startsWith(`${o}/`))) return url
105
+ return `${origins[0] ?? "http://localhost:8000"}${fallbackPath}`
106
+ }
107
+
108
+ // ---------- Cycle de vie de la session ----------
109
+
110
+ async initiatePayment({ amount, currency_code, data }: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
111
+ const currency = currency_code.toLowerCase()
112
+ if (!this.currencies.includes(currency)) {
113
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, `${this.constructor.name} : devise ${currency.toUpperCase()} non acceptée (${this.currencies.join(", ").toUpperCase()})`)
114
+ }
115
+ const sessionId = String(data?.session_id ?? "")
116
+ const str = (k: string) => (typeof data?.[k] === "string" ? (data[k] as string).slice(0, 120) : undefined)
117
+ const checkout = await this.createCheckout({
118
+ amount: Math.round(Number(amount)),
119
+ currency,
120
+ sessionId,
121
+ returnUrl: this.safeUrl(data?.return_url, "/checkout?step=review&pay_return=1"),
122
+ cancelUrl: this.safeUrl(data?.cancel_url, "/checkout?step=review&pay_return=cancel"),
123
+ notifyUrl: this.backendUrl.startsWith("https://") ? `${this.backendUrl}/hooks/payment/${this.hookId}` : undefined,
124
+ label: str("label") ?? "Commande",
125
+ customer: { first_name: str("first_name"), last_name: str("last_name"), phone: str("phone"), email: str("email") },
126
+ })
127
+ return {
128
+ id: checkout.ref,
129
+ status: "pending",
130
+ data: { ref: checkout.ref, redirect_url: checkout.redirect_url, session_id: sessionId, ...(checkout.extra ?? {}) },
131
+ }
132
+ }
133
+
134
+ /** Montant ou panier modifié : nouveau paiement chez l'agrégateur (l'ancien n'est jamais payé) */
135
+ async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
136
+ const out = await this.initiatePayment({ ...input, data: { ...input.data } } as InitiatePaymentInput)
137
+ return { data: out.data, status: out.status }
138
+ }
139
+
140
+ async authorizePayment({ data }: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
141
+ const status = await this.statusOf(data)
142
+ return { data, status }
143
+ }
144
+
145
+ async getPaymentStatus({ data }: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
146
+ return { status: await this.statusOf(data), data }
147
+ }
148
+
149
+ async retrievePayment({ data }: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
150
+ return { data: { ...(data ?? {}), status: await this.statusOf(data) } }
151
+ }
152
+
153
+ /** Le paiement est encaissé dès qu'il est confirmé (pas de pré-autorisation chez ces agrégateurs) */
154
+ async capturePayment({ data }: CapturePaymentInput): Promise<CapturePaymentOutput> {
155
+ return { data }
156
+ }
157
+
158
+ async cancelPayment({ data }: CancelPaymentInput): Promise<CancelPaymentOutput> {
159
+ return { data }
160
+ }
161
+
162
+ async deletePayment({ data }: DeletePaymentInput): Promise<DeletePaymentOutput> {
163
+ return { data }
164
+ }
165
+
166
+ async refundPayment({ data, amount }: RefundPaymentInput): Promise<RefundPaymentOutput> {
167
+ await this.refund(data ?? {}, Math.round(Number(amount)))
168
+ return { data }
169
+ }
170
+
171
+ private async statusOf(data: Record<string, unknown> | undefined): Promise<PaymentSessionStatus> {
172
+ const ref = typeof data?.ref === "string" ? data.ref : ""
173
+ if (!ref) return "pending"
174
+ const s = await this.fetchStatus(ref)
175
+ return s === "paid" ? "captured" : s === "failed" ? "error" : "pending"
176
+ }
177
+
178
+ // ---------- Notification (webhook) ----------
179
+
180
+ async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
181
+ const none = { action: "not_supported" as const, data: { session_id: "", amount: new BigNumber(0) } }
182
+ const raw = rawText(payload.rawData)
183
+ const body = bodyOf(payload.data, raw)
184
+ const info = this.parseWebhook(body, raw, (payload.headers ?? {}) as Record<string, unknown>)
185
+ if (!info?.sessionId || !info.ref) {
186
+ this.logger.warn(`[${this.hookId}] notification ignorée (signature invalide ou session inconnue)`)
187
+ return none
188
+ }
189
+ // Défense en profondeur : même signée, la notification est reconfirmée auprès de l'agrégateur
190
+ const status = await this.fetchStatus(info.ref).catch(() => "pending" as PayStatus)
191
+ const data = { session_id: info.sessionId, amount: new BigNumber(info.amount) }
192
+ if (status === "paid") return { action: "captured", data }
193
+ if (status === "failed") return { action: "failed", data }
194
+ return none
195
+ }
196
+
197
+ // ---------- Outils pour les agrégateurs ----------
198
+
199
+ protected async http<T>(url: string, init: RequestInit & { json?: unknown } = {}): Promise<T> {
200
+ const { json, ...rest } = init
201
+ const res = await fetch(url, {
202
+ ...rest,
203
+ headers: { Accept: "application/json", ...(json !== undefined ? { "Content-Type": "application/json" } : {}), ...(rest.headers ?? {}) },
204
+ body: json !== undefined ? JSON.stringify(json) : rest.body,
205
+ signal: AbortSignal.timeout(20_000),
206
+ })
207
+ const text = await res.text()
208
+ let parsed: unknown
209
+ try {
210
+ parsed = text ? JSON.parse(text) : {}
211
+ } catch {
212
+ parsed = { raw: text }
213
+ }
214
+ if (!res.ok) {
215
+ this.logger.error(`[${this.hookId}] ${rest.method ?? "GET"} ${url} → ${res.status} ${text.slice(0, 300)}`)
216
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `${this.constructor.name} : erreur ${res.status} de l'agrégateur`)
217
+ }
218
+ return parsed as T
219
+ }
220
+ }
221
+
222
+ /** Corps brut du webhook (Buffer, ou Buffer sérialisé par le bus d'événements Redis) en texte */
223
+ function rawText(raw: unknown): string {
224
+ if (!raw) return ""
225
+ if (typeof raw === "string") return raw
226
+ if (Buffer.isBuffer(raw)) return raw.toString("utf8")
227
+ const r = raw as { type?: string; data?: number[] }
228
+ if (r.type === "Buffer" && Array.isArray(r.data)) return Buffer.from(r.data).toString("utf8")
229
+ return ""
230
+ }
231
+
232
+ /** Corps du webhook en objet : JSON, ou formulaire (a[b][c]=v, comme PayDunya et PayTech) */
233
+ function bodyOf(parsed: unknown, raw: string): Record<string, unknown> {
234
+ if (parsed && typeof parsed === "object" && Object.keys(parsed as object).length) return parsed as Record<string, unknown>
235
+ const t = raw.trim()
236
+ if (!t) return {}
237
+ if (t.startsWith("{")) {
238
+ try {
239
+ return JSON.parse(t)
240
+ } catch {
241
+ return {}
242
+ }
243
+ }
244
+ const out: Record<string, unknown> = {}
245
+ for (const [key, value] of new URLSearchParams(t)) {
246
+ const parts = key.replace(/\]/g, "").split("[")
247
+ let node = out
248
+ parts.forEach((p, i) => {
249
+ if (i === parts.length - 1) node[p] = value
250
+ else node = (node[p] = (node[p] as Record<string, unknown>) ?? {}) as Record<string, unknown>
251
+ })
252
+ }
253
+ return out
254
+ }
255
+
256
+ /** Comparaison en temps constant de deux chaînes hexadécimales */
257
+ export function sameHex(a: string, b: string) {
258
+ const x = Buffer.from(a.toLowerCase())
259
+ const y = Buffer.from(b.toLowerCase())
260
+ return x.length === y.length && crypto.timingSafeEqual(x, y)
261
+ }
262
+
263
+ export const sha = (alg: "sha256" | "sha512", s: string) => crypto.createHash(alg).update(s).digest("hex")
264
+ export const hmac256 = (key: string, s: string) => crypto.createHmac("sha256", key).update(s).digest("hex")
@@ -0,0 +1,40 @@
1
+ import crypto from "node:crypto"
2
+
3
+ /**
4
+ * Lien « Laisser un avis » de l'e-mail « commande livrée » : <boutique>/review?t=<jeton>.
5
+ * Jeton = commande + date d'expiration, signés (HMAC-SHA256) avec COOKIE_SECRET, le secret du projet :
6
+ * impossible d'en fabriquer un pour une autre commande. Sans état en base. Valable 180 jours.
7
+ */
8
+ const DAYS = 180
9
+
10
+ const secret = () => {
11
+ const s = process.env.COOKIE_SECRET
12
+ if (!s) throw new Error("COOKIE_SECRET manquant")
13
+ return s
14
+ }
15
+
16
+ const sign = (payload: string) => crypto.createHmac("sha256", secret()).update(`review:${payload}`).digest("base64url")
17
+
18
+ export function reviewToken(orderId: string, now = Date.now()): string {
19
+ const exp = Math.floor(now / 1000) + DAYS * 86400
20
+ const payload = `${orderId}.${exp}`
21
+ return `${Buffer.from(payload).toString("base64url")}.${sign(payload)}`
22
+ }
23
+
24
+ /** Numéro de commande si le jeton est authentique et non expiré, sinon null */
25
+ export function readReviewToken(token: string, now = Date.now()): string | null {
26
+ const [p, sig] = token.split(".")
27
+ if (!p || !sig) return null
28
+ let payload: string
29
+ try {
30
+ payload = Buffer.from(p, "base64url").toString("utf8")
31
+ } catch {
32
+ return null
33
+ }
34
+ const expected = Buffer.from(sign(payload))
35
+ const given = Buffer.from(sig)
36
+ if (expected.length !== given.length || !crypto.timingSafeEqual(expected, given)) return null
37
+ const [orderId, exp] = payload.split(".")
38
+ if (!orderId || !/^\d+$/.test(exp ?? "") || Number(exp) * 1000 < now) return null
39
+ return orderId
40
+ }
@@ -0,0 +1,25 @@
1
+ import type { MedusaStoreRequest } from "@medusajs/framework/http"
2
+ import { readProject } from "./project"
3
+
4
+ /**
5
+ * Portée des avis affichés sur un site : réglage project.json → defaults.reviews.
6
+ * « shared » (défaut) : tous les avis validés du produit, quelle que soit la boutique ;
7
+ * « boutique » : seulement ceux laissés dans la boutique qui interroge (sales channel de la publishable key).
8
+ */
9
+ export function reviewChannels(req: MedusaStoreRequest): string[] | undefined {
10
+ if (readProject().reviews !== "boutique") return undefined
11
+ return req.publishable_key_context?.sales_channel_ids ?? []
12
+ }
13
+
14
+ /** Champs d'un avis visibles du public : jamais l'e-mail ni la commande */
15
+ export const PUBLIC_FIELDS = ["id", "product_id", "author_name", "rating", "title", "comment", "created_at"] as const
16
+
17
+ export type Summary = { average: number; count: number; distribution: [number, number, number, number, number] }
18
+
19
+ export function summarize(ratings: number[]): Summary {
20
+ const distribution: Summary["distribution"] = [0, 0, 0, 0, 0]
21
+ for (const r of ratings) if (r >= 1 && r <= 5) distribution[r - 1]++
22
+ const count = ratings.length
23
+ const average = count ? Math.round((ratings.reduce((a, b) => a + b, 0) / count) * 10) / 10 : 0
24
+ return { average, count, distribution }
25
+ }
@@ -0,0 +1,6 @@
1
+ import { ModuleProvider, Modules } from "@medusajs/framework/utils"
2
+ import NaboopayProviderService from "./service"
3
+
4
+ export default ModuleProvider(Modules.PAYMENT, {
5
+ services: [NaboopayProviderService],
6
+ })
@@ -0,0 +1,92 @@
1
+ import { MedusaError } from "@medusajs/framework/utils"
2
+ import { type Checkout, type PayStatus, RedirectPaymentProvider, hmac256, sameHex } from "../../lib/redirect-payment"
3
+
4
+ type Options = {
5
+ api_key: string
6
+ /** Secret du webhook (tableau de bord NabooPay → Paramètres → Intégration), 16 caractères minimum */
7
+ webhook_secret?: string
8
+ /** Moyens proposés sur la page NabooPay */
9
+ methods: string[]
10
+ }
11
+
12
+ const API = "https://api.naboopay.com/api/v2"
13
+
14
+ /**
15
+ * NabooPay (naboopay.com) : Wave, Orange Money… sur une page de paiement NabooPay.
16
+ * Doc : https://docs.naboopay.com/api-reference — création POST /transactions (checkout_url, order_id),
17
+ * statut GET /transactions/{order_id} (pending | paid | paid_and_blocked | refunded | cancelled),
18
+ * remboursement GET /refund/{order_id} (total, Wave et Orange Money), webhook signé X-Signature =
19
+ * HMAC-SHA256 du JSON compact avec le secret du webhook. Le webhook ne renvoie pas de champ libre :
20
+ * la session Medusa voyage dans l'URL de retour (paramètre ps), que le webhook renvoie.
21
+ * Client obligatoire (prénom, nom, téléphone) : pris de l'adresse de livraison du panier.
22
+ */
23
+ class NaboopayProviderService extends RedirectPaymentProvider<Options> {
24
+ static identifier = "naboopay"
25
+ protected currencies = ["xof"]
26
+ protected hookId = "naboopay_naboopay"
27
+
28
+ static validateOptions(options: Record<string, unknown>) {
29
+ if (!options.api_key) throw new MedusaError(MedusaError.Types.INVALID_DATA, "NabooPay : NABOOPAY_API_KEY requise")
30
+ }
31
+
32
+ private get headers() {
33
+ return { Authorization: `Bearer ${this.options.api_key}` }
34
+ }
35
+
36
+ protected async createCheckout(c: Checkout) {
37
+ const withSession = (url: string) => `${url}${url.includes("?") ? "&" : "?"}ps=${encodeURIComponent(c.sessionId)}`
38
+ const res = await this.http<{ order_id?: string; checkout_url?: string; message?: string }>(`${API}/transactions`, {
39
+ method: "POST",
40
+ headers: this.headers,
41
+ json: {
42
+ method_of_payment: this.options.methods?.length ? this.options.methods : ["wave", "orange_money"],
43
+ products: [{ name: c.label, price: c.amount, quantity: 1, description: c.label }],
44
+ customer: {
45
+ first_name: c.customer.first_name || "Client",
46
+ last_name: c.customer.last_name || "-",
47
+ phone: c.customer.phone || "",
48
+ },
49
+ success_url: withSession(c.returnUrl),
50
+ error_url: withSession(c.cancelUrl),
51
+ fees_customer_side: false,
52
+ is_escrow: false,
53
+ is_merchant: false,
54
+ },
55
+ })
56
+ if (!res.order_id || !res.checkout_url) {
57
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `NabooPay : transaction non créée (${res.message ?? "réponse inattendue"})`)
58
+ }
59
+ return { ref: res.order_id, redirect_url: res.checkout_url }
60
+ }
61
+
62
+ protected async fetchStatus(orderId: string): Promise<PayStatus> {
63
+ const res = await this.http<{ transaction_status?: string }>(`${API}/transactions/${encodeURIComponent(orderId)}`, { headers: this.headers })
64
+ const s = String(res.transaction_status ?? "").toLowerCase()
65
+ if (s === "paid" || s === "done" || s === "paid_and_blocked") return "paid"
66
+ if (s === "cancelled" || s === "refunded") return "failed"
67
+ return "pending"
68
+ }
69
+
70
+ protected parseWebhook(body: Record<string, unknown>, raw: string, headers: Record<string, unknown>) {
71
+ const secret = this.options.webhook_secret
72
+ const signature = String(headers["x-signature"] ?? "")
73
+ if (!secret || !signature) return null
74
+ // Signature du JSON compact : on essaie le corps brut tel quel, puis re-sérialisé sans espaces
75
+ const candidates = [raw, JSON.stringify(body)].filter(Boolean)
76
+ if (!candidates.some((payload) => sameHex(hmac256(secret, payload), signature))) return null
77
+ let sessionId = ""
78
+ try {
79
+ sessionId = new URL(String(body.success_url ?? "")).searchParams.get("ps") ?? ""
80
+ } catch {
81
+ sessionId = ""
82
+ }
83
+ return { ref: String(body.order_id ?? ""), sessionId, amount: Number(body.amount) || 0 }
84
+ }
85
+
86
+ /** Remboursement total seulement (Wave et Orange Money) */
87
+ protected async refund(data: Record<string, unknown>) {
88
+ await this.http(`${API}/refund/${encodeURIComponent(String(data.ref ?? ""))}`, { headers: this.headers })
89
+ }
90
+ }
91
+
92
+ export default NaboopayProviderService
@@ -0,0 +1,6 @@
1
+ import { ModuleProvider, Modules } from "@medusajs/framework/utils"
2
+ import PaydunyaProviderService from "./service"
3
+
4
+ export default ModuleProvider(Modules.PAYMENT, {
5
+ services: [PaydunyaProviderService],
6
+ })