@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.
- package/cli/dist/code.js +110 -0
- package/cli/dist/commands.js +143 -10
- package/cli/dist/create.js +54 -7
- package/cli/dist/generate.js +65 -4
- package/cli/dist/guide.js +10 -2
- package/cli/dist/index.js +20 -7
- package/cli/dist/payments.js +150 -0
- package/docs/fiche-client.en.html +2 -2
- package/docs/fiche-client.html +2 -2
- package/docs/guide-complet.fr.html +681 -256
- package/docs/guide-images/categories.jpg +0 -0
- package/docs/guide-images/clients.jpg +0 -0
- package/docs/guide-images/collections.jpg +0 -0
- package/docs/guide-images/commande-detail.jpg +0 -0
- package/docs/guide-images/commande-expedier.jpg +0 -0
- package/docs/guide-images/commande-menu.jpg +0 -0
- package/docs/guide-images/commande-paiement.jpg +0 -0
- package/docs/guide-images/commandes-liste.jpg +0 -0
- package/docs/guide-images/connexion.jpg +0 -0
- package/docs/guide-images/groupes-clients.jpg +0 -0
- package/docs/guide-images/inventaire.jpg +0 -0
- package/docs/guide-images/listes-prix.jpg +0 -0
- package/docs/guide-images/menu.jpg +0 -0
- package/docs/guide-images/mes-boutiques-fermer.jpg +0 -0
- package/docs/guide-images/mes-boutiques-panneau.jpg +0 -0
- package/docs/guide-images/mes-boutiques.jpg +0 -0
- package/docs/guide-images/parametres-boutique.jpg +0 -0
- package/docs/guide-images/parametres-canaux.jpg +0 -0
- package/docs/guide-images/parametres-emplacements.jpg +0 -0
- package/docs/guide-images/parametres-profil.jpg +0 -0
- package/docs/guide-images/parametres-regions.jpg +0 -0
- package/docs/guide-images/parametres-utilisateurs.jpg +0 -0
- package/docs/guide-images/produit-creer-details.jpg +0 -0
- package/docs/guide-images/produit-detail.jpg +0 -0
- package/docs/guide-images/produit-variantes-menu.jpg +0 -0
- package/docs/guide-images/produit-variantes.jpg +0 -0
- package/docs/guide-images/produits-liste.jpg +0 -0
- package/docs/guide-images/promotion-details.jpg +0 -0
- package/docs/guide-images/promotion-type.jpg +0 -0
- package/docs/guide-images/promotions-liste.jpg +0 -0
- package/docs/guide-images/site-boutique.jpg +0 -0
- package/docs/guide-images/site-fermee.png +0 -0
- package/docs/guide-images/vue-ensemble.jpg +0 -0
- package/package.json +3 -2
- package/templates/backend/medusa-config.ts +31 -25
- package/templates/backend/package-lock.json +21 -0
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/admin/i18n/json/en.json +51 -3
- package/templates/backend/src/admin/i18n/json/fr.json +51 -3
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +2 -0
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +36 -1
- package/templates/backend/src/admin/routes/reviews/page.tsx +259 -0
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +3 -1
- package/templates/backend/src/api/admin/boutique-online/[salesChannelId]/route.ts +22 -0
- package/templates/backend/src/api/admin/reviews/[id]/route.ts +15 -0
- package/templates/backend/src/api/admin/reviews/route.ts +47 -0
- package/templates/backend/src/api/middlewares.ts +7 -0
- package/templates/backend/src/api/store/campaign/route.ts +5 -1
- package/templates/backend/src/api/store/reviews/latest/route.ts +39 -0
- package/templates/backend/src/api/store/reviews/order/route.ts +36 -0
- package/templates/backend/src/api/store/reviews/route.ts +43 -0
- package/templates/backend/src/api/store/reviews/summary/route.ts +28 -0
- package/templates/backend/src/emails/en.json +3 -1
- package/templates/backend/src/emails/fr.json +3 -1
- package/templates/backend/src/lib/email-config.ts +14 -0
- package/templates/backend/src/lib/emails.ts +9 -0
- package/templates/backend/src/lib/notify.ts +14 -8
- package/templates/backend/src/lib/payment-config.ts +66 -0
- package/templates/backend/src/lib/project.ts +12 -0
- package/templates/backend/src/lib/redirect-payment.ts +264 -0
- package/templates/backend/src/lib/review-token.ts +40 -0
- package/templates/backend/src/lib/reviews.ts +25 -0
- package/templates/backend/src/modules/naboopay/index.ts +6 -0
- package/templates/backend/src/modules/naboopay/service.ts +92 -0
- package/templates/backend/src/modules/paydunya/index.ts +6 -0
- package/templates/backend/src/modules/paydunya/service.ts +89 -0
- package/templates/backend/src/modules/paytech/index.ts +6 -0
- package/templates/backend/src/modules/paytech/service.ts +114 -0
- package/templates/backend/src/modules/product-review/index.ts +8 -0
- package/templates/backend/src/modules/product-review/migrations/Migration20261001090000.ts +18 -0
- package/templates/backend/src/modules/product-review/models/review.ts +25 -0
- package/templates/backend/src/modules/product-review/service.ts +6 -0
- package/templates/backend/src/modules/product-review/validators.ts +27 -0
- package/templates/backend/src/modules/smtp/index.ts +6 -0
- package/templates/backend/src/modules/smtp/service.ts +73 -0
- package/templates/backend/src/scripts/seed.ts +52 -36
- package/templates/backend/src/subscribers/fulfillment-emails.ts +1 -1
- package/templates/backend/src/workflows/reviews.ts +21 -0
- package/templates/backend/src/workflows/steps/create-review.ts +52 -0
- package/templates/backend/src/workflows/steps/update-review.ts +36 -0
- package/templates/storefront/PERSONNALISER.md +134 -0
- package/templates/storefront/locales/en.json +198 -11
- package/templates/storefront/locales/fr.json +198 -11
- package/templates/storefront/public/custom/LISEZMOI.txt +2 -0
- package/templates/storefront/src/app/(checkout)/layout.tsx +1 -1
- package/templates/storefront/src/app/(main)/layout.tsx +8 -1
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -7
- package/templates/storefront/src/app/(main)/page.tsx +149 -4
- package/templates/storefront/src/app/(main)/review/page.tsx +60 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +3 -1
- package/templates/storefront/src/app/(main)/template.tsx +17 -2
- package/templates/storefront/src/app/layout.tsx +13 -1
- package/templates/storefront/src/lib/boutique/campaign.ts +5 -1
- package/templates/storefront/src/lib/boutique/page-name.ts +25 -0
- package/templates/storefront/src/lib/boutique/schema.ts +3 -1
- package/templates/storefront/src/lib/constants.tsx +17 -0
- package/templates/storefront/src/lib/data/review-actions.ts +28 -0
- package/templates/storefront/src/lib/data/reviews.ts +64 -0
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +26 -4
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +85 -2
- package/templates/storefront/src/modules/products/templates/index.tsx +5 -0
- package/templates/storefront/src/modules/store/components/shop-search.tsx +85 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +16 -8
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +15 -3
- package/templates/storefront/src/modules/ub/album.tsx +2 -0
- package/templates/storefront/src/modules/ub/arena.tsx +182 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +3 -2
- package/templates/storefront/src/modules/ub/campaign-ended.tsx +27 -13
- package/templates/storefront/src/modules/ub/cinema.tsx +2 -0
- package/templates/storefront/src/modules/ub/cookie-banner.tsx +62 -0
- package/templates/storefront/src/modules/ub/gala.tsx +2 -0
- package/templates/storefront/src/modules/ub/grimoire.tsx +35 -8
- package/templates/storefront/src/modules/ub/harvest.tsx +2 -1
- package/templates/storefront/src/modules/ub/hero-image.tsx +14 -0
- package/templates/storefront/src/modules/ub/home-kit.ts +45 -0
- package/templates/storefront/src/modules/ub/lantern.tsx +244 -0
- package/templates/storefront/src/modules/ub/locker.tsx +180 -0
- package/templates/storefront/src/modules/ub/mosaic.tsx +189 -0
- package/templates/storefront/src/modules/ub/nav.tsx +8 -5
- package/templates/storefront/src/modules/ub/review-form.tsx +62 -0
- package/templates/storefront/src/modules/ub/reviews.tsx +131 -0
- package/templates/storefront/src/modules/ub/riviera.tsx +175 -0
- package/templates/storefront/src/modules/ub/souk.tsx +214 -0
- package/templates/storefront/src/modules/ub/sunset-checklist.tsx +95 -0
- package/templates/storefront/src/modules/ub/sunset.tsx +164 -0
- package/templates/storefront/src/modules/ub/tech.tsx +2 -2
- package/templates/storefront/src/modules/ub/training.tsx +196 -0
- package/templates/storefront/src/proxy.ts +2 -0
- package/templates/storefront/src/styles/custom.css +26 -0
- package/templates/storefront/src/styles/ub/album.css +1 -1
- package/templates/storefront/src/styles/ub/arena.css +120 -0
- package/templates/storefront/src/styles/ub/cinema.css +1 -1
- package/templates/storefront/src/styles/ub/gala.css +1 -1
- package/templates/storefront/src/styles/ub/grimoire.css +23 -7
- package/templates/storefront/src/styles/ub/harvest.css +7 -6
- package/templates/storefront/src/styles/ub/lantern.css +157 -0
- package/templates/storefront/src/styles/ub/locker.css +115 -0
- package/templates/storefront/src/styles/ub/media.css +10 -0
- package/templates/storefront/src/styles/ub/mosaic.css +118 -0
- package/templates/storefront/src/styles/ub/motion.css +0 -3
- package/templates/storefront/src/styles/ub/overrides.css +10 -0
- package/templates/storefront/src/styles/ub/pages.css +176 -0
- package/templates/storefront/src/styles/ub/reviews.css +85 -0
- package/templates/storefront/src/styles/ub/riviera.css +103 -0
- package/templates/storefront/src/styles/ub/souk.css +144 -0
- package/templates/storefront/src/styles/ub/sunset.css +123 -0
- package/templates/storefront/src/styles/ub/tech.css +2 -5
- package/templates/storefront/src/styles/ub/training.css +120 -0
- package/templates/storefront/themes/jeux/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux/theme.json +11 -0
- package/templates/storefront/themes/jeux-2/campaign.en.json +31 -0
- package/templates/storefront/themes/jeux-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/jeux-2/theme.json +11 -0
- package/templates/storefront/themes/mubarak/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak/theme.json +11 -0
- package/templates/storefront/themes/mubarak-2/campaign.en.json +31 -0
- package/templates/storefront/themes/mubarak-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/mubarak-2/theme.json +11 -0
- package/templates/storefront/themes/sport/campaign.en.json +31 -0
- package/templates/storefront/themes/sport/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport/theme.json +11 -0
- package/templates/storefront/themes/sport-2/campaign.en.json +31 -0
- package/templates/storefront/themes/sport-2/campaign.fr.json +31 -0
- package/templates/storefront/themes/sport-2/theme.json +11 -0
- package/templates/storefront/themes/summer/campaign.en.json +31 -0
- package/templates/storefront/themes/summer/campaign.fr.json +31 -0
- package/templates/storefront/themes/summer/theme.json +11 -0
- package/templates/storefront/themes/summer-2/campaign.en.json +31 -0
- package/templates/storefront/themes/summer-2/campaign.fr.json +31 -0
- 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
|
|
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 (!
|
|
28
|
-
if (!warned) logger.warn("[emails] RESEND_API_KEY
|
|
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.
|
|
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
|
|
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 (!
|
|
123
|
-
logger.warn(`[emails]
|
|
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,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
|