@salifbiaye/create-boutique 0.5.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 (193) hide show
  1. package/cli/dist/code.js +110 -0
  2. package/cli/dist/commands.js +241 -17
  3. package/cli/dist/create.js +108 -15
  4. package/cli/dist/generate.js +108 -39
  5. package/cli/dist/guide.js +12 -4
  6. package/cli/dist/index.js +38 -7
  7. package/cli/dist/payments.js +150 -0
  8. package/cli/dist/project.js +42 -3
  9. package/docs/fiche-client.en.html +2 -2
  10. package/docs/fiche-client.html +2 -2
  11. package/docs/guide-complet.fr.html +681 -256
  12. package/docs/guide-images/categories.jpg +0 -0
  13. package/docs/guide-images/clients.jpg +0 -0
  14. package/docs/guide-images/collections.jpg +0 -0
  15. package/docs/guide-images/commande-detail.jpg +0 -0
  16. package/docs/guide-images/commande-expedier.jpg +0 -0
  17. package/docs/guide-images/commande-menu.jpg +0 -0
  18. package/docs/guide-images/commande-paiement.jpg +0 -0
  19. package/docs/guide-images/commandes-liste.jpg +0 -0
  20. package/docs/guide-images/connexion.jpg +0 -0
  21. package/docs/guide-images/groupes-clients.jpg +0 -0
  22. package/docs/guide-images/inventaire.jpg +0 -0
  23. package/docs/guide-images/listes-prix.jpg +0 -0
  24. package/docs/guide-images/menu.jpg +0 -0
  25. package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
  26. package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
  27. package/docs/guide-images/mes-boutiques.jpg +0 -0
  28. package/docs/guide-images/parametres-boutique.jpg +0 -0
  29. package/docs/guide-images/parametres-canaux.jpg +0 -0
  30. package/docs/guide-images/parametres-emplacements.jpg +0 -0
  31. package/docs/guide-images/parametres-profil.jpg +0 -0
  32. package/docs/guide-images/parametres-regions.jpg +0 -0
  33. package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
  34. package/docs/guide-images/produit-creer-details.jpg +0 -0
  35. package/docs/guide-images/produit-detail.jpg +0 -0
  36. package/docs/guide-images/produit-variantes-menu.jpg +0 -0
  37. package/docs/guide-images/produit-variantes.jpg +0 -0
  38. package/docs/guide-images/produits-liste.jpg +0 -0
  39. package/docs/guide-images/promotion-details.jpg +0 -0
  40. package/docs/guide-images/promotion-type.jpg +0 -0
  41. package/docs/guide-images/promotions-liste.jpg +0 -0
  42. package/docs/guide-images/site-boutique.jpg +0 -0
  43. package/docs/guide-images/site-fermee.png +0 -0
  44. package/docs/guide-images/vue-ensemble.jpg +0 -0
  45. package/package.json +3 -2
  46. package/templates/backend/medusa-config.ts +31 -25
  47. package/templates/backend/package-lock.json +21 -0
  48. package/templates/backend/package.json +2 -0
  49. package/templates/backend/src/admin/i18n/json/en.json +63 -5
  50. package/templates/backend/src/admin/i18n/json/fr.json +63 -5
  51. package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +29 -17
  52. package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +84 -6
  53. package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
  54. package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
  55. package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
  56. package/templates/backend/src/api/admin/boutique-campaigns/route.ts +9 -1
  57. package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
  58. package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
  59. package/templates/backend/src/api/admin/reviews/route.ts +47 -0
  60. package/templates/backend/src/api/middlewares.ts +9 -0
  61. package/templates/backend/src/api/store/campaign/route.ts +5 -1
  62. package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
  63. package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
  64. package/templates/backend/src/api/store/reviews/route.ts +43 -0
  65. package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
  66. package/templates/backend/src/emails/en.json +3 -1
  67. package/templates/backend/src/emails/fr.json +3 -1
  68. package/templates/backend/src/lib/active.ts +41 -0
  69. package/templates/backend/src/lib/email-config.ts +14 -0
  70. package/templates/backend/src/lib/emails.ts +9 -0
  71. package/templates/backend/src/lib/notify.ts +14 -8
  72. package/templates/backend/src/lib/payment-config.ts +66 -0
  73. package/templates/backend/src/lib/project.ts +38 -1
  74. package/templates/backend/src/lib/redirect-payment.ts +264 -0
  75. package/templates/backend/src/lib/review-token.ts +40 -0
  76. package/templates/backend/src/lib/reviews.ts +25 -0
  77. package/templates/backend/src/modules/naboopay/index.ts +6 -0
  78. package/templates/backend/src/modules/naboopay/service.ts +92 -0
  79. package/templates/backend/src/modules/paydunya/index.ts +6 -0
  80. package/templates/backend/src/modules/paydunya/service.ts +89 -0
  81. package/templates/backend/src/modules/paytech/index.ts +6 -0
  82. package/templates/backend/src/modules/paytech/service.ts +114 -0
  83. package/templates/backend/src/modules/product-review/index.ts +8 -0
  84. package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
  85. package/templates/backend/src/modules/product-review/models/review.ts +25 -0
  86. package/templates/backend/src/modules/product-review/service.ts +6 -0
  87. package/templates/backend/src/modules/product-review/validators.ts +27 -0
  88. package/templates/backend/src/modules/smtp/index.ts +6 -0
  89. package/templates/backend/src/modules/smtp/service.ts +73 -0
  90. package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
  91. package/templates/backend/src/scripts/seed.ts +60 -36
  92. package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
  93. package/templates/backend/src/workflows/reviews.ts +21 -0
  94. package/templates/backend/src/workflows/steps/create-review.ts +52 -0
  95. package/templates/backend/src/workflows/steps/update-review.ts +36 -0
  96. package/templates/storefront/PERSONNALISER.md +134 -0
  97. package/templates/storefront/locales/en.json +198 -11
  98. package/templates/storefront/locales/fr.json +198 -11
  99. package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
  100. package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
  101. package/templates/storefront/src/app/(main)/layout.tsx +8 -1
  102. package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
  103. package/templates/storefront/src/app/(main)/page.tsx +149 -4
  104. package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
  105. package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
  106. package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
  107. package/templates/storefront/src/app/(main)/template.tsx +17 -2
  108. package/templates/storefront/src/app/layout.tsx +13 -1
  109. package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
  110. package/templates/storefront/src/lib/boutique/config.ts +71 -23
  111. package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
  112. package/templates/storefront/src/lib/boutique/schema.ts +7 -2
  113. package/templates/storefront/src/lib/config.ts +37 -8
  114. package/templates/storefront/src/lib/constants.tsx +17 -0
  115. package/templates/storefront/src/lib/data/cart.ts +4 -1
  116. package/templates/storefront/src/lib/data/cookies.ts +14 -2
  117. package/templates/storefront/src/lib/data/review-actions.ts +28 -0
  118. package/templates/storefront/src/lib/data/reviews.ts +64 -0
  119. package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
  120. package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
  121. package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
  122. package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
  123. package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
  124. package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
  125. package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
  126. package/templates/storefront/src/modules/ub/album.tsx +2 -0
  127. package/templates/storefront/src/modules/ub/arena.tsx +182 -0
  128. package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
  129. package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
  130. package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
  131. package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
  132. package/templates/storefront/src/modules/ub/gala.tsx +2 -0
  133. package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
  134. package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
  135. package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
  136. package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
  137. package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
  138. package/templates/storefront/src/modules/ub/locker.tsx +180 -0
  139. package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
  140. package/templates/storefront/src/modules/ub/nav.tsx +8 -5
  141. package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
  142. package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
  143. package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
  144. package/templates/storefront/src/modules/ub/souk.tsx +214 -0
  145. package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
  146. package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
  147. package/templates/storefront/src/modules/ub/tech.tsx +2 -2
  148. package/templates/storefront/src/modules/ub/training.tsx +196 -0
  149. package/templates/storefront/src/proxy.ts +4 -1
  150. package/templates/storefront/src/styles/custom.css +26 -0
  151. package/templates/storefront/src/styles/ub/album.css +1 -1
  152. package/templates/storefront/src/styles/ub/arena.css +120 -0
  153. package/templates/storefront/src/styles/ub/cinema.css +1 -1
  154. package/templates/storefront/src/styles/ub/gala.css +1 -1
  155. package/templates/storefront/src/styles/ub/grimoire.css +23 -7
  156. package/templates/storefront/src/styles/ub/harvest.css +7 -6
  157. package/templates/storefront/src/styles/ub/lantern.css +157 -0
  158. package/templates/storefront/src/styles/ub/locker.css +115 -0
  159. package/templates/storefront/src/styles/ub/media.css +10 -0
  160. package/templates/storefront/src/styles/ub/mosaic.css +118 -0
  161. package/templates/storefront/src/styles/ub/motion.css +0 -2
  162. package/templates/storefront/src/styles/ub/overrides.css +10 -0
  163. package/templates/storefront/src/styles/ub/pages.css +176 -0
  164. package/templates/storefront/src/styles/ub/reviews.css +85 -0
  165. package/templates/storefront/src/styles/ub/riviera.css +103 -0
  166. package/templates/storefront/src/styles/ub/souk.css +144 -0
  167. package/templates/storefront/src/styles/ub/sunset.css +123 -0
  168. package/templates/storefront/src/styles/ub/tech.css +5 -8
  169. package/templates/storefront/src/styles/ub/training.css +120 -0
  170. package/templates/storefront/themes/jeux/campaign.en.json +31 -0
  171. package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
  172. package/templates/storefront/themes/jeux/theme.json +11 -0
  173. package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
  174. package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
  175. package/templates/storefront/themes/jeux-2/theme.json +11 -0
  176. package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
  177. package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
  178. package/templates/storefront/themes/mubarak/theme.json +11 -0
  179. package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
  180. package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
  181. package/templates/storefront/themes/mubarak-2/theme.json +11 -0
  182. package/templates/storefront/themes/sport/campaign.en.json +31 -0
  183. package/templates/storefront/themes/sport/campaign.fr.json +31 -0
  184. package/templates/storefront/themes/sport/theme.json +11 -0
  185. package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
  186. package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
  187. package/templates/storefront/themes/sport-2/theme.json +11 -0
  188. package/templates/storefront/themes/summer/campaign.en.json +31 -0
  189. package/templates/storefront/themes/summer/campaign.fr.json +31 -0
  190. package/templates/storefront/themes/summer/theme.json +11 -0
  191. package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
  192. package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
  193. package/templates/storefront/themes/summer-2/theme.json +11 -0
@@ -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
+ })
@@ -0,0 +1,89 @@
1
+ import { MedusaError } from "@medusajs/framework/utils"
2
+ import { type Checkout, type PayStatus, RedirectPaymentProvider, sameHex, sha } from "../../lib/redirect-payment"
3
+
4
+ type Options = {
5
+ master_key: string
6
+ private_key: string
7
+ token: string
8
+ /** « test » = sandbox PayDunya, « prod » = paiements réels */
9
+ env: "test" | "prod"
10
+ /** Nom affiché sur la page de paiement */
11
+ store_name?: string
12
+ }
13
+
14
+ /**
15
+ * PayDunya (paydunya.com) : Wave, Orange Money, Free Money, carte… sur une facture PayDunya (checkout-invoice).
16
+ * Doc : https://developers.paydunya.com/doc/FR/http_json — création POST /checkout-invoice/create,
17
+ * statut GET /checkout-invoice/confirm/{token}, notification (callback_url) en formulaire, champ data[…],
18
+ * authentifiée par data[hash] = SHA-512 de la clé principale. La session Medusa voyage dans custom_data.
19
+ * Devise : FCFA (XOF). Remboursement : dans le tableau de bord PayDunya.
20
+ */
21
+ class PaydunyaProviderService extends RedirectPaymentProvider<Options> {
22
+ static identifier = "paydunya"
23
+ protected currencies = ["xof"]
24
+ protected hookId = "paydunya_paydunya"
25
+
26
+ static validateOptions(options: Record<string, unknown>) {
27
+ if (!options.master_key || !options.private_key || !options.token) {
28
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, "PayDunya : PAYDUNYA_MASTER_KEY, PAYDUNYA_PRIVATE_KEY et PAYDUNYA_TOKEN requis")
29
+ }
30
+ }
31
+
32
+ private get api() {
33
+ return this.options.env === "prod" ? "https://app.paydunya.com/api/v1" : "https://app.paydunya.com/sandbox-api/v1"
34
+ }
35
+
36
+ private get headers() {
37
+ return {
38
+ "PAYDUNYA-MASTER-KEY": this.options.master_key,
39
+ "PAYDUNYA-PRIVATE-KEY": this.options.private_key,
40
+ "PAYDUNYA-TOKEN": this.options.token,
41
+ }
42
+ }
43
+
44
+ protected async createCheckout(c: Checkout) {
45
+ const name = [c.customer.first_name, c.customer.last_name].filter(Boolean).join(" ")
46
+ const res = await this.http<{ response_code?: string; response_text?: string; token?: string; description?: string }>(
47
+ `${this.api}/checkout-invoice/create`,
48
+ {
49
+ method: "POST",
50
+ headers: this.headers,
51
+ json: {
52
+ invoice: {
53
+ total_amount: c.amount,
54
+ description: c.label,
55
+ ...(name || c.customer.email || c.customer.phone ? { customer: { name, email: c.customer.email, phone: c.customer.phone } } : {}),
56
+ },
57
+ store: { name: this.options.store_name || c.label },
58
+ custom_data: { session_id: c.sessionId },
59
+ actions: { return_url: c.returnUrl, cancel_url: c.cancelUrl, ...(c.notifyUrl ? { callback_url: c.notifyUrl } : {}) },
60
+ },
61
+ }
62
+ )
63
+ if (res.response_code !== "00" || !res.token || !res.response_text) {
64
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `PayDunya : facture non créée (${res.description ?? res.response_text ?? "réponse inattendue"})`)
65
+ }
66
+ return { ref: res.token, redirect_url: res.response_text }
67
+ }
68
+
69
+ protected async fetchStatus(token: string): Promise<PayStatus> {
70
+ const res = await this.http<{ status?: string }>(`${this.api}/checkout-invoice/confirm/${encodeURIComponent(token)}`, { headers: this.headers })
71
+ const s = String(res.status ?? "").toLowerCase()
72
+ if (s === "completed") return "paid"
73
+ if (s === "cancelled" || s === "failed") return "failed"
74
+ return "pending"
75
+ }
76
+
77
+ protected parseWebhook(body: Record<string, unknown>) {
78
+ const data = (body.data ?? body) as Record<string, any>
79
+ if (!data?.hash || !sameHex(sha("sha512", this.options.master_key), String(data.hash))) return null
80
+ const custom = (data.custom_data ?? {}) as Record<string, unknown>
81
+ return {
82
+ ref: String(data.invoice?.token ?? ""),
83
+ sessionId: String(custom.session_id ?? ""),
84
+ amount: Number(data.invoice?.total_amount) || 0,
85
+ }
86
+ }
87
+ }
88
+
89
+ export default PaydunyaProviderService
@@ -0,0 +1,6 @@
1
+ import { ModuleProvider, Modules } from "@medusajs/framework/utils"
2
+ import PaytechProviderService from "./service"
3
+
4
+ export default ModuleProvider(Modules.PAYMENT, {
5
+ services: [PaytechProviderService],
6
+ })
@@ -0,0 +1,114 @@
1
+ import { MedusaError } from "@medusajs/framework/utils"
2
+ import { type Checkout, type PayStatus, RedirectPaymentProvider, hmac256, sameHex, sha } from "../../lib/redirect-payment"
3
+
4
+ type Options = {
5
+ api_key: string
6
+ api_secret: string
7
+ /** « test » (débite 100 à 150 FCFA quel que soit le montant) ou « prod » (compte activé chez PayTech) */
8
+ env: "test" | "prod"
9
+ }
10
+
11
+ const API = "https://paytech.sn/api"
12
+
13
+ /**
14
+ * PayTech (paytech.sn) : Wave, Orange Money, Free Money, carte bancaire… sur une page de paiement PayTech.
15
+ * Doc : https://docs.intech.sn/doc_paytech.php — création POST /payment/request-payment, statut GET /payment/get-status,
16
+ * IPN signée (HMAC-SHA256 « montant|ref_command|api_key » avec api_secret, ou SHA-256 des clés), remboursement.
17
+ * La référence du paiement Medusa (session) voyage dans custom_field.
18
+ */
19
+ class PaytechProviderService extends RedirectPaymentProvider<Options> {
20
+ static identifier = "paytech"
21
+ protected currencies = ["xof", "eur", "usd", "cad", "gbp", "mad"]
22
+ protected hookId = "paytech_paytech"
23
+
24
+ static validateOptions(options: Record<string, unknown>) {
25
+ if (!options.api_key || !options.api_secret) {
26
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, "PayTech : PAYTECH_API_KEY et PAYTECH_API_SECRET requis")
27
+ }
28
+ }
29
+
30
+ private get headers() {
31
+ return { API_KEY: this.options.api_key, API_SECRET: this.options.api_secret }
32
+ }
33
+
34
+ protected async createCheckout(c: Checkout) {
35
+ // Référence unique par tentative (la session peut créer plusieurs paiements si le panier change)
36
+ const ref = `${c.sessionId}_${Date.now().toString(36)}`
37
+ const res = await this.http<{ success?: number; token?: string; redirect_url?: string; redirectUrl?: string; message?: string }>(
38
+ `${API}/payment/request-payment`,
39
+ {
40
+ method: "POST",
41
+ headers: this.headers,
42
+ json: {
43
+ item_name: c.label,
44
+ item_price: c.amount,
45
+ currency: c.currency.toUpperCase(),
46
+ ref_command: ref,
47
+ command_name: c.label,
48
+ env: this.options.env === "prod" ? "prod" : "test",
49
+ ...(c.notifyUrl ? { ipn_url: c.notifyUrl } : {}),
50
+ success_url: c.returnUrl,
51
+ cancel_url: c.cancelUrl,
52
+ custom_field: JSON.stringify({ session_id: c.sessionId }),
53
+ },
54
+ }
55
+ )
56
+ const url = res.redirect_url ?? res.redirectUrl
57
+ if (res.success !== 1 || !res.token || !url) {
58
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `PayTech : paiement non créé (${res.message ?? "réponse inattendue"})`)
59
+ }
60
+ // ref = jeton PayTech (sert au statut) ; ref_command gardé pour le remboursement
61
+ return { ref: res.token, redirect_url: url, extra: { ref_command: ref } }
62
+ }
63
+
64
+ /**
65
+ * Le format exact de la réponse de get-status n'est pas documenté : lecture tolérante des champs usuels.
66
+ * L'IPN signée reste la voie sûre de confirmation (voir getWebhookActionAndData).
67
+ */
68
+ protected async fetchStatus(token: string): Promise<PayStatus> {
69
+ const res = await this.http<Record<string, any>>(`${API}/payment/get-status?token_payment=${encodeURIComponent(token)}`, { headers: this.headers })
70
+ const s = String(res.payment?.state ?? res.payment?.status ?? res.state ?? res.status ?? res.type_event ?? res.transaction?.status ?? "").toLowerCase()
71
+ if (/complet|success|paid|sale_complete|accepted/.test(s)) return "paid"
72
+ if (/cancel|fail|refus|expired|sale_canceled/.test(s)) return "failed"
73
+ return "pending"
74
+ }
75
+
76
+ protected parseWebhook(body: Record<string, unknown>) {
77
+ const f = (k: string) => (body[k] == null ? "" : String(body[k]))
78
+ const ref = f("token")
79
+ const refCommand = f("ref_command")
80
+ // Authenticité : HMAC (recommandé) sur le montant final ou initial, sinon empreintes SHA-256 des clés
81
+ const received = f("hmac_compute")
82
+ const okHmac =
83
+ !!received &&
84
+ [f("final_item_price"), f("item_price")].filter(Boolean).some((amount) => sameHex(hmac256(this.options.api_secret, `${amount}|${refCommand}|${this.options.api_key}`), received))
85
+ const okSha =
86
+ !!f("api_key_sha256") &&
87
+ sameHex(sha("sha256", this.options.api_key), f("api_key_sha256")) &&
88
+ sameHex(sha("sha256", this.options.api_secret), f("api_secret_sha256"))
89
+ if (!okHmac && !okSha) return null
90
+
91
+ let sessionId = ""
92
+ try {
93
+ const custom = f("custom_field")
94
+ const json = custom.trim().startsWith("{") ? custom : Buffer.from(custom, "base64").toString("utf8")
95
+ sessionId = String(JSON.parse(json).session_id ?? "")
96
+ } catch {
97
+ // repli : la référence commence par le numéro de session
98
+ sessionId = refCommand.slice(0, refCommand.lastIndexOf("_"))
99
+ }
100
+ return { ref, sessionId, amount: Number(f("final_item_price") || f("item_price")) || 0 }
101
+ }
102
+
103
+ /** Remboursement de la transaction (POST /payment/refund-payment avec ref_command) */
104
+ protected async refund(data: Record<string, unknown>) {
105
+ const res = await this.http<{ success?: number; message?: string }>(`${API}/payment/refund-payment`, {
106
+ method: "POST",
107
+ headers: this.headers,
108
+ json: { ref_command: String(data.ref_command ?? "") },
109
+ })
110
+ if (res.success !== 1) throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `PayTech : remboursement refusé (${res.message ?? "réponse inattendue"})`)
111
+ }
112
+ }
113
+
114
+ export default PaytechProviderService
@@ -0,0 +1,8 @@
1
+ import { Module } from "@medusajs/framework/utils"
2
+ import ProductReviewModuleService from "./service"
3
+
4
+ export const PRODUCT_REVIEW_MODULE = "productReview"
5
+
6
+ export default Module(PRODUCT_REVIEW_MODULE, {
7
+ service: ProductReviewModuleService,
8
+ })