@rune-kit/rune 2.8.0 → 2.11.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 (287) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +68 -34
  3. package/agents/adversary.md +27 -0
  4. package/agents/architect.md +19 -29
  5. package/agents/asset-creator.md +18 -4
  6. package/agents/audit.md +25 -4
  7. package/agents/autopsy.md +19 -4
  8. package/agents/ba.md +35 -0
  9. package/agents/brainstorm.md +31 -4
  10. package/agents/browser-pilot.md +21 -4
  11. package/agents/coder.md +21 -29
  12. package/agents/completion-gate.md +20 -4
  13. package/agents/constraint-check.md +18 -4
  14. package/agents/context-engine.md +22 -4
  15. package/agents/context-pack.md +32 -0
  16. package/agents/cook.md +41 -4
  17. package/agents/db.md +19 -4
  18. package/agents/debug.md +33 -4
  19. package/agents/dependency-doctor.md +20 -4
  20. package/agents/deploy.md +27 -4
  21. package/agents/design.md +22 -4
  22. package/agents/doc-processor.md +27 -0
  23. package/agents/docs-seeker.md +19 -4
  24. package/agents/docs.md +31 -0
  25. package/agents/fix.md +37 -4
  26. package/agents/git.md +29 -0
  27. package/agents/hallucination-guard.md +20 -4
  28. package/agents/incident.md +21 -4
  29. package/agents/integrity-check.md +18 -4
  30. package/agents/journal.md +19 -4
  31. package/agents/launch.md +32 -4
  32. package/agents/logic-guardian.md +26 -11
  33. package/agents/marketing.md +23 -4
  34. package/agents/mcp-builder.md +26 -0
  35. package/agents/neural-memory.md +30 -0
  36. package/agents/onboard.md +22 -4
  37. package/agents/perf.md +21 -4
  38. package/agents/plan.md +29 -4
  39. package/agents/preflight.md +22 -4
  40. package/agents/problem-solver.md +20 -4
  41. package/agents/rescue.md +23 -4
  42. package/agents/research.md +19 -4
  43. package/agents/researcher.md +19 -29
  44. package/agents/retro.md +32 -0
  45. package/agents/review-intake.md +20 -4
  46. package/agents/review.md +32 -4
  47. package/agents/reviewer.md +20 -28
  48. package/agents/safeguard.md +19 -4
  49. package/agents/sast.md +18 -4
  50. package/agents/scaffold.md +41 -0
  51. package/agents/scanner.md +19 -28
  52. package/agents/scope-guard.md +18 -4
  53. package/agents/scout.md +23 -4
  54. package/agents/sentinel-env.md +26 -0
  55. package/agents/sentinel.md +33 -4
  56. package/agents/sequential-thinking.md +20 -4
  57. package/agents/session-bridge.md +24 -4
  58. package/agents/skill-forge.md +22 -4
  59. package/agents/skill-router.md +26 -4
  60. package/agents/slides.md +24 -0
  61. package/agents/surgeon.md +19 -4
  62. package/agents/team.md +30 -4
  63. package/agents/test.md +36 -4
  64. package/agents/trend-scout.md +17 -4
  65. package/agents/verification.md +20 -4
  66. package/agents/video-creator.md +20 -4
  67. package/agents/watchdog.md +19 -4
  68. package/agents/worktree.md +17 -4
  69. package/commands/rune.md +168 -168
  70. package/compiler/__tests__/analytics.test.js +370 -0
  71. package/compiler/adapters/openclaw.js +2 -2
  72. package/compiler/analytics.js +385 -0
  73. package/compiler/bin/rune.js +68 -2
  74. package/compiler/dashboard.js +883 -0
  75. package/compiler/transforms/branding.js +1 -1
  76. package/contexts/dev.md +34 -34
  77. package/contexts/research.md +43 -43
  78. package/contexts/review.md +55 -55
  79. package/extensions/ai-ml/PACK.md +88 -88
  80. package/extensions/ai-ml/skills/ai-agents.md +172 -172
  81. package/extensions/ai-ml/skills/code-sandbox.md +187 -187
  82. package/extensions/ai-ml/skills/deep-research.md +146 -146
  83. package/extensions/ai-ml/skills/embedding-search.md +66 -66
  84. package/extensions/ai-ml/skills/fine-tuning-guide.md +74 -74
  85. package/extensions/ai-ml/skills/llm-architect.md +125 -125
  86. package/extensions/ai-ml/skills/llm-integration.md +64 -64
  87. package/extensions/ai-ml/skills/prompt-patterns.md +72 -72
  88. package/extensions/ai-ml/skills/rag-patterns.md +66 -66
  89. package/extensions/ai-ml/skills/web-extraction.md +114 -114
  90. package/extensions/analytics/PACK.md +92 -92
  91. package/extensions/analytics/skills/ab-testing.md +72 -72
  92. package/extensions/analytics/skills/dashboard-patterns.md +83 -83
  93. package/extensions/analytics/skills/data-validation.md +68 -68
  94. package/extensions/analytics/skills/funnel-analysis.md +81 -81
  95. package/extensions/analytics/skills/sql-patterns.md +57 -57
  96. package/extensions/analytics/skills/statistical-analysis.md +79 -79
  97. package/extensions/analytics/skills/tracking-setup.md +71 -71
  98. package/extensions/backend/PACK.md +104 -104
  99. package/extensions/backend/skills/api-patterns.md +84 -84
  100. package/extensions/backend/skills/async-pipeline.md +193 -193
  101. package/extensions/backend/skills/auth-patterns.md +97 -97
  102. package/extensions/backend/skills/background-jobs.md +133 -133
  103. package/extensions/backend/skills/caching-patterns.md +108 -108
  104. package/extensions/backend/skills/cli-generation.md +133 -133
  105. package/extensions/backend/skills/database-patterns.md +87 -87
  106. package/extensions/backend/skills/middleware-patterns.md +104 -104
  107. package/extensions/chrome-ext/PACK.md +93 -93
  108. package/extensions/chrome-ext/skills/cws-preflight.md +143 -143
  109. package/extensions/chrome-ext/skills/cws-publish.md +104 -104
  110. package/extensions/chrome-ext/skills/ext-ai-integration.md +251 -251
  111. package/extensions/chrome-ext/skills/ext-messaging.md +139 -139
  112. package/extensions/chrome-ext/skills/ext-storage.md +133 -133
  113. package/extensions/chrome-ext/skills/mv3-scaffold.md +164 -164
  114. package/extensions/content/PACK.md +96 -96
  115. package/extensions/content/skills/blog-patterns.md +88 -88
  116. package/extensions/content/skills/cms-integration.md +131 -131
  117. package/extensions/content/skills/content-scoring.md +107 -107
  118. package/extensions/content/skills/i18n.md +83 -83
  119. package/extensions/content/skills/mdx-authoring.md +137 -137
  120. package/extensions/content/skills/reference.md +1014 -1014
  121. package/extensions/content/skills/seo-patterns.md +67 -67
  122. package/extensions/content/skills/video-repurpose.md +153 -153
  123. package/extensions/devops/PACK.md +101 -101
  124. package/extensions/devops/skills/chaos-testing.md +67 -67
  125. package/extensions/devops/skills/ci-cd.md +75 -75
  126. package/extensions/devops/skills/docker.md +58 -58
  127. package/extensions/devops/skills/edge-serverless.md +163 -163
  128. package/extensions/devops/skills/infra-as-code.md +158 -158
  129. package/extensions/devops/skills/kubernetes.md +110 -110
  130. package/extensions/devops/skills/monitoring.md +57 -57
  131. package/extensions/devops/skills/server-setup.md +64 -64
  132. package/extensions/devops/skills/ssl-domain.md +42 -42
  133. package/extensions/ecommerce/PACK.md +116 -116
  134. package/extensions/ecommerce/skills/cart-system.md +79 -79
  135. package/extensions/ecommerce/skills/inventory-mgmt.md +102 -102
  136. package/extensions/ecommerce/skills/order-management.md +126 -126
  137. package/extensions/ecommerce/skills/payment-integration.md +472 -472
  138. package/extensions/ecommerce/skills/shopify-dev.md +69 -69
  139. package/extensions/ecommerce/skills/subscription-billing.md +93 -93
  140. package/extensions/ecommerce/skills/tax-compliance.md +117 -117
  141. package/extensions/gamedev/PACK.md +142 -142
  142. package/extensions/gamedev/skills/asset-pipeline.md +74 -74
  143. package/extensions/gamedev/skills/audio-system.md +129 -129
  144. package/extensions/gamedev/skills/camera-system.md +87 -87
  145. package/extensions/gamedev/skills/ecs.md +98 -98
  146. package/extensions/gamedev/skills/game-loops.md +72 -72
  147. package/extensions/gamedev/skills/input-system.md +199 -199
  148. package/extensions/gamedev/skills/multiplayer.md +180 -180
  149. package/extensions/gamedev/skills/particles.md +105 -105
  150. package/extensions/gamedev/skills/physics-engine.md +89 -89
  151. package/extensions/gamedev/skills/scene-management.md +146 -146
  152. package/extensions/gamedev/skills/threejs-patterns.md +90 -90
  153. package/extensions/gamedev/skills/webgl.md +71 -71
  154. package/extensions/mobile/PACK.md +106 -106
  155. package/extensions/mobile/skills/app-store-connect.md +152 -152
  156. package/extensions/mobile/skills/app-store-prep.md +66 -66
  157. package/extensions/mobile/skills/deep-linking.md +109 -109
  158. package/extensions/mobile/skills/flutter.md +60 -60
  159. package/extensions/mobile/skills/ios-build-pipeline.md +142 -142
  160. package/extensions/mobile/skills/native-bridge.md +66 -66
  161. package/extensions/mobile/skills/ota-updates.md +97 -97
  162. package/extensions/mobile/skills/push-notifications.md +111 -111
  163. package/extensions/mobile/skills/react-native.md +82 -82
  164. package/extensions/saas/PACK.md +116 -116
  165. package/extensions/saas/skills/billing-integration.md +200 -200
  166. package/extensions/saas/skills/feature-flags.md +130 -130
  167. package/extensions/saas/skills/multi-tenant.md +103 -103
  168. package/extensions/saas/skills/onboarding-flow.md +139 -139
  169. package/extensions/saas/skills/subscription-flow.md +95 -95
  170. package/extensions/saas/skills/team-management.md +144 -144
  171. package/extensions/security/PACK.md +99 -99
  172. package/extensions/security/skills/api-security.md +140 -140
  173. package/extensions/security/skills/compliance.md +68 -68
  174. package/extensions/security/skills/owasp-audit.md +64 -64
  175. package/extensions/security/skills/pentest-patterns.md +77 -77
  176. package/extensions/security/skills/secret-mgmt.md +65 -65
  177. package/extensions/security/skills/supply-chain.md +65 -65
  178. package/extensions/trading/PACK.md +80 -80
  179. package/extensions/trading/skills/chart-components.md +55 -55
  180. package/extensions/trading/skills/experiment-loop.md +125 -125
  181. package/extensions/trading/skills/fintech-patterns.md +47 -47
  182. package/extensions/trading/skills/indicator-library.md +58 -58
  183. package/extensions/trading/skills/quant-analysis.md +111 -111
  184. package/extensions/trading/skills/realtime-data.md +58 -58
  185. package/extensions/trading/skills/trade-logic.md +104 -104
  186. package/extensions/ui/PACK.md +130 -130
  187. package/extensions/ui/skills/a11y-audit.md +91 -91
  188. package/extensions/ui/skills/animation-patterns.md +127 -106
  189. package/extensions/ui/skills/component-patterns.md +100 -75
  190. package/extensions/ui/skills/design-decision.md +108 -108
  191. package/extensions/ui/skills/design-system.md +68 -68
  192. package/extensions/ui/skills/landing-patterns.md +155 -155
  193. package/extensions/ui/skills/palette-picker.md +173 -173
  194. package/extensions/ui/skills/react-health.md +90 -90
  195. package/extensions/ui/skills/type-system.md +125 -125
  196. package/extensions/ui/skills/web-vitals.md +153 -153
  197. package/extensions/zalo/PACK.md +145 -145
  198. package/extensions/zalo/skills/zalo-oa-mcp.md +317 -317
  199. package/extensions/zalo/skills/zalo-oa-messaging.md +429 -429
  200. package/extensions/zalo/skills/zalo-oa-setup.md +236 -236
  201. package/extensions/zalo/skills/zalo-oa-webhook.md +189 -189
  202. package/extensions/zalo/skills/zalo-personal-messaging.md +194 -194
  203. package/extensions/zalo/skills/zalo-personal-setup.md +153 -153
  204. package/extensions/zalo/skills/zalo-rate-guard.md +219 -219
  205. package/hooks/auto-format/index.cjs +48 -48
  206. package/hooks/context-watch/index.cjs +95 -68
  207. package/hooks/hooks.json +111 -111
  208. package/hooks/metrics-collector/index.cjs +86 -42
  209. package/hooks/post-session-reflect/index.cjs +189 -153
  210. package/hooks/pre-compact/index.cjs +95 -95
  211. package/hooks/run-hook.cmd +1 -1
  212. package/hooks/secrets-scan/index.cjs +100 -100
  213. package/hooks/session-start/index.cjs +71 -65
  214. package/hooks/typecheck/index.cjs +65 -65
  215. package/package.json +63 -63
  216. package/references/ui-pro-max-data/LICENSE-UI-PRO-MAX +21 -21
  217. package/references/ui-pro-max-data/charts.csv +26 -26
  218. package/references/ui-pro-max-data/colors.csv +161 -161
  219. package/references/ui-pro-max-data/styles.csv +68 -68
  220. package/references/ui-pro-max-data/typography.csv +74 -74
  221. package/references/ui-pro-max-data/ui-reasoning.csv +162 -162
  222. package/references/ui-pro-max-data/ux-guidelines.csv +99 -99
  223. package/skills/adversary/SKILL.md +283 -283
  224. package/skills/asset-creator/SKILL.md +157 -157
  225. package/skills/audit/SKILL.md +148 -2
  226. package/skills/autopsy/SKILL.md +335 -259
  227. package/skills/autopsy/references/repo-analysis-patterns.md +113 -0
  228. package/skills/ba/SKILL.md +72 -2
  229. package/skills/brainstorm/SKILL.md +342 -341
  230. package/skills/browser-pilot/SKILL.md +168 -168
  231. package/skills/constraint-check/SKILL.md +165 -165
  232. package/skills/context-engine/SKILL.md +404 -404
  233. package/skills/cook/SKILL.md +917 -834
  234. package/skills/cook/references/output-format.md +33 -0
  235. package/skills/db/SKILL.md +273 -272
  236. package/skills/debug/SKILL.md +465 -443
  237. package/skills/dependency-doctor/SKILL.md +265 -235
  238. package/skills/deploy/SKILL.md +274 -231
  239. package/skills/design/DESIGN-REFERENCE.md +365 -365
  240. package/skills/design/SKILL.md +589 -482
  241. package/skills/doc-processor/SKILL.md +254 -254
  242. package/skills/docs/SKILL.md +374 -373
  243. package/skills/docs-seeker/SKILL.md +177 -177
  244. package/skills/fix/SKILL.md +330 -308
  245. package/skills/git/SKILL.md +339 -339
  246. package/skills/graft/SKILL.md +352 -0
  247. package/skills/graft/references/challenge-framework.md +98 -0
  248. package/skills/graft/references/mode-decision.md +44 -0
  249. package/skills/hallucination-guard/SKILL.md +219 -219
  250. package/skills/incident/SKILL.md +254 -251
  251. package/skills/integrity-check/SKILL.md +169 -169
  252. package/skills/journal/SKILL.md +240 -238
  253. package/skills/launch/SKILL.md +344 -342
  254. package/skills/logic-guardian/SKILL.md +251 -251
  255. package/skills/marketing/SKILL.md +290 -245
  256. package/skills/mcp-builder/SKILL.md +425 -423
  257. package/skills/mcp-builder/references/auto-discovery-pattern.md +169 -0
  258. package/skills/neural-memory/SKILL.md +362 -362
  259. package/skills/onboard/SKILL.md +404 -403
  260. package/skills/perf/SKILL.md +346 -346
  261. package/skills/plan/SKILL.md +433 -370
  262. package/skills/plan/references/feature-map.md +84 -0
  263. package/skills/preflight/SKILL.md +415 -396
  264. package/skills/problem-solver/SKILL.md +380 -284
  265. package/skills/rescue/SKILL.md +474 -450
  266. package/skills/retro/SKILL.md +5 -1
  267. package/skills/review/SKILL.md +612 -535
  268. package/skills/review-intake/SKILL.md +249 -249
  269. package/skills/safeguard/SKILL.md +200 -200
  270. package/skills/sast/SKILL.md +190 -190
  271. package/skills/scaffold/SKILL.md +328 -286
  272. package/skills/scope-guard/SKILL.md +180 -162
  273. package/skills/scout/SKILL.md +263 -263
  274. package/skills/sentinel/SKILL.md +382 -353
  275. package/skills/sentinel-env/SKILL.md +254 -254
  276. package/skills/sequential-thinking/SKILL.md +234 -234
  277. package/skills/session-bridge/SKILL.md +543 -397
  278. package/skills/skill-forge/SKILL.md +581 -539
  279. package/skills/skill-router/{skill.md → SKILL.md} +30 -2
  280. package/skills/surgeon/SKILL.md +215 -215
  281. package/skills/team/SKILL.md +556 -514
  282. package/skills/test/SKILL.md +614 -587
  283. package/skills/trend-scout/SKILL.md +145 -145
  284. package/skills/verification/SKILL.md +326 -325
  285. package/skills/video-creator/SKILL.md +201 -201
  286. package/skills/watchdog/SKILL.md +168 -168
  287. package/skills/worktree/SKILL.md +140 -140
@@ -1,472 +1,472 @@
1
- ---
2
- name: "payment-integration"
3
- pack: "@rune/ecommerce"
4
- description: "Payment integration — Stripe Payment Intents, 3D Secure, webhook handling, refunds, idempotency, PCI compliance, multi-currency, fraud detection, Vietnamese payment gateways (SePay, VNPay, MoMo)."
5
- model: sonnet
6
- tools: [Read, Edit, Write, Grep, Glob, Bash]
7
- ---
8
-
9
- # payment-integration
10
-
11
- Payment integration — Stripe Payment Intents, 3D Secure, webhook handling, refunds, idempotency, PCI compliance, multi-currency, fraud detection, Vietnamese payment gateways (SePay, VNPay, MoMo).
12
-
13
- #### Workflow
14
-
15
- **Step 1 — Detect payment setup**
16
- Use Grep to find `stripe`, `paypal`, `@stripe/stripe-js`, `@stripe/react-stripe-js`, payment-related endpoints. Read checkout handlers and webhook processors to understand: payment flow type (Payment Intents vs Checkout Sessions), webhook events handled, and error recovery.
17
-
18
- **Step 2 — Audit payment security**
19
- Check for:
20
- - Missing idempotency keys on payment creation (double charges on retry)
21
- - Webhook signature not verified (`stripe.webhooks.constructEvent` with `req.rawBody` — NOT parsed JSON body)
22
- - Payment amount calculated client-side (price manipulation risk)
23
- - No 3D Secure handling (`requires_action` status not handled in frontend)
24
- - Secret keys in client bundle (check for `sk_live_` or `sk_test_` in frontend code)
25
- - Missing failed payment recovery flow (no retry or dunning)
26
- - Webhook processing not idempotent (same event processed twice creates duplicate orders)
27
- - `req.body` used instead of `req.rawBody` for webhook signature verification (always fails)
28
-
29
- **Step 3 — Emit robust payment flow**
30
- Emit: server-side Payment Intent creation with idempotency, 3D Secure handling loop, comprehensive webhook handler with event deduplication, and refund flow with audit trail.
31
-
32
- #### Example
33
-
34
- ```typescript
35
- // Stripe Payment Intent — server-side, idempotent, 3DS-ready
36
- import Stripe from 'stripe';
37
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
38
-
39
- app.post('/api/checkout', async (req, res) => {
40
- const { cartId, paymentMethodId } = req.body;
41
- const cart = await cartService.getVerified(cartId); // server-side price calculation
42
-
43
- // Idempotency key derived from CART, not timestamp — prevents double charge on retry
44
- const idempotencyKey = `checkout-${cartId}-v${cart.version}`;
45
-
46
- const intent = await stripe.paymentIntents.create({
47
- amount: cart.totalInCents, // ALWAYS server-calculated
48
- currency: cart.currency,
49
- payment_method: paymentMethodId,
50
- confirm: true,
51
- return_url: `${process.env.APP_URL}/checkout/complete`,
52
- metadata: { cartId, userId: req.user.id },
53
- idempotencyKey,
54
- });
55
-
56
- if (intent.status === 'requires_action') {
57
- return res.json({ requiresAction: true, clientSecret: intent.client_secret });
58
- }
59
- if (intent.status === 'succeeded') {
60
- await orderService.create(cart, intent.id);
61
- return res.json({ success: true, orderId: intent.metadata.orderId });
62
- }
63
- res.status(400).json({ error: 'PAYMENT_FAILED' });
64
- });
65
-
66
- // Webhook — MUST use raw body for signature, deduplicate events
67
- app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
68
- const sig = req.headers['stripe-signature']!;
69
- let event: Stripe.Event;
70
-
71
- try {
72
- event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
73
- } catch {
74
- return res.status(400).send('Signature verification failed');
75
- }
76
-
77
- // Deduplicate: check if event already processed
78
- const existing = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
79
- if (existing) return res.json({ received: true, duplicate: true });
80
-
81
- // Process within transaction
82
- await db.$transaction(async (tx) => {
83
- await tx.webhookEvent.create({ data: { stripeEventId: event.id, type: event.type } });
84
-
85
- if (event.type === 'payment_intent.succeeded') {
86
- const intent = event.data.object as Stripe.PaymentIntent;
87
- await orderService.confirmPayment(tx, intent.metadata.cartId, intent.id);
88
- }
89
- });
90
-
91
- res.json({ received: true });
92
- });
93
- ```
94
-
95
- #### Multi-Currency & Localization
96
-
97
- ```typescript
98
- // Locale-aware price formatting — ALWAYS use Intl, never manual toFixed()
99
- function formatPrice(amountInCents: number, currency: string, locale: string): string {
100
- return new Intl.NumberFormat(locale, {
101
- style: 'currency',
102
- currency,
103
- minimumFractionDigits: 2,
104
- maximumFractionDigits: 2,
105
- }).format(amountInCents / 100);
106
- }
107
-
108
- // Examples
109
- formatPrice(1999, 'USD', 'en-US'); // $19.99
110
- formatPrice(1999, 'EUR', 'de-DE'); // 19,99 €
111
- formatPrice(1999, 'JPY', 'ja-JP'); // ¥1,999 (JPY has no minor units)
112
-
113
- // Currency conversion with FX rate cache
114
- interface FxRate { from: string; to: string; rate: number; fetchedAt: Date }
115
-
116
- class FxService {
117
- private cache = new Map<string, FxRate>();
118
-
119
- async convert(amountInCents: number, from: string, to: string): Promise<number> {
120
- if (from === to) return amountInCents;
121
- const key = `${from}:${to}`;
122
- let rate = this.cache.get(key);
123
-
124
- // Refresh if stale (>15 min)
125
- if (!rate || Date.now() - rate.fetchedAt.getTime() > 15 * 60 * 1000) {
126
- const fresh = await this.fetchRate(from, to);
127
- rate = { from, to, rate: fresh, fetchedAt: new Date() };
128
- this.cache.set(key, rate);
129
- }
130
- return Math.round(amountInCents * rate.rate);
131
- }
132
-
133
- private async fetchRate(from: string, to: string): Promise<number> {
134
- // Use a reliable FX API (e.g., Frankfurter, Open Exchange Rates)
135
- const res = await fetch(`https://api.frankfurter.app/latest?from=${from}&to=${to}`);
136
- const data = await res.json();
137
- return data.rates[to];
138
- }
139
- }
140
-
141
- // Locale-aware pricing: show price in user's currency, charge in store's base currency
142
- interface LocalizedPrice {
143
- displayAmount: string; // "€18.45" — shown to user
144
- chargeAmount: number; // 1999 cents USD — what actually gets charged
145
- currency: string; // 'USD'
146
- displayCurrency: string; // 'EUR'
147
- exchangeRate: number;
148
- }
149
-
150
- async function getLocalizedPrice(
151
- amountInCents: number,
152
- storeCurrency: string,
153
- userLocale: string,
154
- userCurrency: string
155
- ): Promise<LocalizedPrice> {
156
- const fx = new FxService();
157
- const displayAmountInCents = await fx.convert(amountInCents, storeCurrency, userCurrency);
158
- return {
159
- displayAmount: formatPrice(displayAmountInCents, userCurrency, userLocale),
160
- chargeAmount: amountInCents, // charge in store base currency
161
- currency: storeCurrency,
162
- displayCurrency: userCurrency,
163
- exchangeRate: displayAmountInCents / amountInCents,
164
- };
165
- }
166
- ```
167
-
168
- #### Vietnamese Payment Gateways (SePay, VNPay, MoMo, ZaloPay)
169
-
170
- Vietnam market uses QR-based bank transfers and e-wallets instead of card payments. SePay is the simplest (webhook on bank transfer), VNPay is the most widely adopted gateway, MoMo/ZaloPay are e-wallet leaders.
171
-
172
- **SePay — QR Bank Transfer (simplest integration)**
173
-
174
- ```typescript
175
- // SePay: generate QR code for bank transfer, webhook on payment received
176
- // Docs: https://my.sepay.vn/docs
177
-
178
- interface SePayConfig {
179
- apiKey: string;
180
- bankAccount: string; // your receiving bank account
181
- bankCode: string; // e.g., 'MB', 'VCB', 'TCB', 'ACB'
182
- webhookSecret: string;
183
- }
184
-
185
- // Generate payment QR — user scans with banking app
186
- async function createSePayQR(orderId: string, amountVND: number, config: SePayConfig) {
187
- // SePay uses structured transfer content for auto-matching
188
- const transferContent = `DH${orderId}`; // prefix for order matching
189
-
190
- return {
191
- bankCode: config.bankCode,
192
- bankAccount: config.bankAccount,
193
- amount: amountVND,
194
- content: transferContent,
195
- // QR follows VietQR standard (NAPAS)
196
- qrUrl: `https://qr.sepay.vn/img?acc=${config.bankAccount}&bank=${config.bankCode}&amount=${amountVND}&des=${transferContent}`,
197
- };
198
- }
199
-
200
- // Webhook — SePay calls this when bank transfer is detected
201
- app.post('/api/webhooks/sepay', async (req, res) => {
202
- // Verify webhook signature
203
- const signature = req.headers['x-sepay-signature'] as string;
204
- const payload = JSON.stringify(req.body);
205
- const expected = crypto.createHmac('sha256', process.env.SEPAY_WEBHOOK_SECRET!)
206
- .update(payload).digest('hex');
207
-
208
- if (signature !== expected) {
209
- return res.status(401).json({ error: 'Invalid signature' });
210
- }
211
-
212
- const { transferAmount, transferContent, transactionDate, id } = req.body;
213
-
214
- // Deduplicate
215
- const existing = await db.payment.findFirst({ where: { externalId: String(id) } });
216
- if (existing) return res.json({ success: true, duplicate: true });
217
-
218
- // Match order by transfer content (DH{orderId})
219
- const orderIdMatch = transferContent.match(/DH(\w+)/);
220
- if (!orderIdMatch) {
221
- console.error('SePay: unmatched transfer', { transferContent, id });
222
- return res.json({ success: true, matched: false });
223
- }
224
-
225
- await db.$transaction(async (tx) => {
226
- await tx.payment.create({
227
- data: {
228
- orderId: orderIdMatch[1],
229
- amount: transferAmount,
230
- method: 'BANK_TRANSFER',
231
- provider: 'sepay',
232
- externalId: String(id),
233
- paidAt: new Date(transactionDate),
234
- },
235
- });
236
- await tx.order.update({
237
- where: { id: orderIdMatch[1] },
238
- data: { status: 'PAID', paidAt: new Date(transactionDate) },
239
- });
240
- });
241
-
242
- res.json({ success: true });
243
- });
244
- ```
245
-
246
- **VNPay — Vietnam's largest payment gateway**
247
-
248
- ```typescript
249
- // VNPay: redirect-based payment with HMAC-SHA512 signature
250
- // Docs: https://sandbox.vnpayment.vn/apis/docs/huong-dan-tich-hop/
251
-
252
- import crypto from 'crypto';
253
- import qs from 'qs';
254
-
255
- interface VNPayConfig {
256
- tmnCode: string; // merchant code
257
- hashSecret: string; // secret key
258
- vnpUrl: string; // 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html' (sandbox)
259
- returnUrl: string; // your callback URL
260
- }
261
-
262
- function createVNPayUrl(orderId: string, amountVND: number, ipAddr: string, config: VNPayConfig): string {
263
- const now = new Date();
264
- const createDate = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
265
-
266
- const params: Record<string, string> = {
267
- vnp_Version: '2.1.0',
268
- vnp_Command: 'pay',
269
- vnp_TmnCode: config.tmnCode,
270
- vnp_Locale: 'vn',
271
- vnp_CurrCode: 'VND',
272
- vnp_TxnRef: orderId,
273
- vnp_OrderInfo: `Thanh toan don hang ${orderId}`,
274
- vnp_OrderType: 'other',
275
- vnp_Amount: String(amountVND * 100), // VNPay uses smallest unit (x100)
276
- vnp_ReturnUrl: config.returnUrl,
277
- vnp_IpAddr: ipAddr,
278
- vnp_CreateDate: createDate,
279
- };
280
-
281
- // Sort params alphabetically — REQUIRED by VNPay
282
- const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
283
- acc[key] = params[key];
284
- return acc;
285
- }, {} as Record<string, string>);
286
-
287
- const signData = qs.stringify(sortedParams, { encode: false });
288
- const hmac = crypto.createHmac('sha512', config.hashSecret);
289
- const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');
290
-
291
- return `${config.vnpUrl}?${signData}&vnp_SecureHash=${signed}`;
292
- }
293
-
294
- // IPN (Instant Payment Notification) — VNPay server-to-server callback
295
- app.get('/api/webhooks/vnpay-ipn', async (req, res) => {
296
- const vnpParams = { ...req.query } as Record<string, string>;
297
- const secureHash = vnpParams.vnp_SecureHash;
298
- delete vnpParams.vnp_SecureHash;
299
- delete vnpParams.vnp_SecureHashType;
300
-
301
- // Verify hash
302
- const sortedParams = Object.keys(vnpParams).sort().reduce((acc, key) => {
303
- acc[key] = vnpParams[key];
304
- return acc;
305
- }, {} as Record<string, string>);
306
-
307
- const signData = qs.stringify(sortedParams, { encode: false });
308
- const expectedHash = crypto.createHmac('sha512', process.env.VNPAY_HASH_SECRET!)
309
- .update(Buffer.from(signData, 'utf-8')).digest('hex');
310
-
311
- if (secureHash !== expectedHash) {
312
- return res.json({ RspCode: '97', Message: 'Invalid signature' });
313
- }
314
-
315
- const orderId = vnpParams.vnp_TxnRef;
316
- const responseCode = vnpParams.vnp_ResponseCode;
317
-
318
- if (responseCode === '00') {
319
- await orderService.confirmPayment(orderId, vnpParams.vnp_TransactionNo);
320
- return res.json({ RspCode: '00', Message: 'Confirm Success' });
321
- }
322
-
323
- await orderService.failPayment(orderId, responseCode);
324
- res.json({ RspCode: '00', Message: 'Confirm Success' }); // always return 00 to VNPay
325
- });
326
- ```
327
-
328
- **MoMo — E-wallet payment**
329
-
330
- ```typescript
331
- // MoMo: QR or app-switch payment
332
- // Docs: https://developers.momo.vn/v3/docs/payment/api/
333
-
334
- interface MoMoConfig {
335
- partnerCode: string;
336
- accessKey: string;
337
- secretKey: string;
338
- endpoint: string; // 'https://test-payment.momo.vn/v2/gateway/api/create'
339
- redirectUrl: string;
340
- ipnUrl: string;
341
- }
342
-
343
- async function createMoMoPayment(orderId: string, amountVND: number, config: MoMoConfig) {
344
- const requestId = `${config.partnerCode}-${Date.now()}`;
345
- const orderInfo = `Thanh toan don hang ${orderId}`;
346
- const extraData = ''; // base64 encoded extra data
347
-
348
- // HMAC SHA256 signature — order of fields matters!
349
- const rawSignature = [
350
- `accessKey=${config.accessKey}`,
351
- `amount=${amountVND}`,
352
- `extraData=${extraData}`,
353
- `ipnUrl=${config.ipnUrl}`,
354
- `orderId=${orderId}`,
355
- `orderInfo=${orderInfo}`,
356
- `partnerCode=${config.partnerCode}`,
357
- `redirectUrl=${config.redirectUrl}`,
358
- `requestId=${requestId}`,
359
- `requestType=payWithMethod`,
360
- ].join('&');
361
-
362
- const signature = crypto.createHmac('sha256', config.secretKey)
363
- .update(rawSignature).digest('hex');
364
-
365
- const response = await fetch(config.endpoint, {
366
- method: 'POST',
367
- headers: { 'Content-Type': 'application/json' },
368
- body: JSON.stringify({
369
- partnerCode: config.partnerCode,
370
- accessKey: config.accessKey,
371
- requestId,
372
- amount: amountVND,
373
- orderId,
374
- orderInfo,
375
- redirectUrl: config.redirectUrl,
376
- ipnUrl: config.ipnUrl,
377
- extraData,
378
- requestType: 'payWithMethod',
379
- signature,
380
- lang: 'vi',
381
- }),
382
- });
383
-
384
- const data = await response.json();
385
- return { payUrl: data.payUrl, qrCodeUrl: data.qrCodeUrl, deeplink: data.deeplink };
386
- }
387
- ```
388
-
389
- **Sharp Edges — VN Payment Gotchas:**
390
- - SePay: transfer content MUST be exact match — users sometimes add extra text → payment not auto-matched. Always show exact content to copy.
391
- - VNPay: `vnp_Amount` is multiplied by 100 (not cents — VND has no decimals). Common bug: double-multiplying.
392
- - VNPay: ALWAYS return `RspCode: '00'` to IPN even on failure — otherwise VNPay retries indefinitely.
393
- - MoMo: signature field order is strict — wrong order = invalid signature. Copy exact order from docs.
394
- - ZaloPay: similar to MoMo but uses HMAC-SHA256 with different field ordering. Check docs at `https://docs.zalopay.vn/`.
395
- - All VN gateways: amounts are in VND (integer, no decimals). Never use floating point for VND.
396
- - Sandbox environments often have rate limits and expire — test with real small amounts (10,000 VND) before go-live.
397
-
398
- #### Fraud Detection
399
-
400
- ```typescript
401
- // Risk scoring before order fulfilment
402
- interface FraudSignals {
403
- ipAddress: string;
404
- userAgent: string;
405
- deviceFingerprint: string;
406
- email: string;
407
- billingCountry: string;
408
- shippingCountry: string;
409
- orderAmountCents: number;
410
- isFirstOrder: boolean;
411
- }
412
-
413
- interface RiskScore {
414
- score: number; // 0–100, higher = riskier
415
- action: 'allow' | 'review' | 'block';
416
- reasons: string[];
417
- }
418
-
419
- async function scoreFraudRisk(signals: FraudSignals): Promise<RiskScore> {
420
- const reasons: string[] = [];
421
- let score = 0;
422
-
423
- // Velocity check — same IP, multiple orders in short window
424
- const recentOrdersFromIp = await db.order.count({
425
- where: { ipAddress: signals.ipAddress, createdAt: { gte: new Date(Date.now() - 3600_000) } },
426
- });
427
- if (recentOrdersFromIp >= 3) { score += 30; reasons.push('HIGH_VELOCITY_IP'); }
428
-
429
- // Card BIN country mismatch
430
- if (signals.billingCountry !== signals.shippingCountry) {
431
- score += 15; reasons.push('BILLING_SHIPPING_MISMATCH');
432
- }
433
-
434
- // High-value first order — common pattern for stolen cards
435
- if (signals.isFirstOrder && signals.orderAmountCents > 50000) {
436
- score += 25; reasons.push('HIGH_VALUE_FIRST_ORDER');
437
- }
438
-
439
- // Email domain is disposable (temp-mail.org, mailinator.com, etc.)
440
- const domain = signals.email.split('@')[1];
441
- const isDisposable = await disposableEmailService.check(domain);
442
- if (isDisposable) { score += 20; reasons.push('DISPOSABLE_EMAIL'); }
443
-
444
- // Device fingerprint seen with multiple different emails (account farm)
445
- const fingerprintEmails = await db.order.findMany({
446
- where: { deviceFingerprint: signals.deviceFingerprint },
447
- select: { email: true },
448
- distinct: ['email'],
449
- });
450
- if (fingerprintEmails.length > 5) { score += 25; reasons.push('FINGERPRINT_MULTI_ACCOUNT'); }
451
-
452
- const action = score >= 70 ? 'block' : score >= 40 ? 'review' : 'allow';
453
- return { score, action, reasons };
454
- }
455
-
456
- // Apply fraud check in checkout flow
457
- app.post('/api/checkout/confirm', async (req, res) => {
458
- const { cartId } = req.body;
459
- const signals = extractFraudSignals(req);
460
- const risk = await scoreFraudRisk(signals);
461
-
462
- if (risk.action === 'block') {
463
- await db.fraudAttempt.create({ data: { ...signals, score: risk.score, reasons: risk.reasons } });
464
- return res.status(403).json({ error: 'ORDER_BLOCKED', code: 'FRAUD_RISK' });
465
- }
466
- if (risk.action === 'review') {
467
- // Proceed but flag for manual review after payment
468
- await db.order.create({ data: { cartId, fraudScore: risk.score, requiresReview: true } });
469
- }
470
- // ... normal checkout flow
471
- });
472
- ```
1
+ ---
2
+ name: "payment-integration"
3
+ pack: "@rune/ecommerce"
4
+ description: "Payment integration — Stripe Payment Intents, 3D Secure, webhook handling, refunds, idempotency, PCI compliance, multi-currency, fraud detection, Vietnamese payment gateways (SePay, VNPay, MoMo)."
5
+ model: sonnet
6
+ tools: [Read, Edit, Write, Grep, Glob, Bash]
7
+ ---
8
+
9
+ # payment-integration
10
+
11
+ Payment integration — Stripe Payment Intents, 3D Secure, webhook handling, refunds, idempotency, PCI compliance, multi-currency, fraud detection, Vietnamese payment gateways (SePay, VNPay, MoMo).
12
+
13
+ #### Workflow
14
+
15
+ **Step 1 — Detect payment setup**
16
+ Use Grep to find `stripe`, `paypal`, `@stripe/stripe-js`, `@stripe/react-stripe-js`, payment-related endpoints. Read checkout handlers and webhook processors to understand: payment flow type (Payment Intents vs Checkout Sessions), webhook events handled, and error recovery.
17
+
18
+ **Step 2 — Audit payment security**
19
+ Check for:
20
+ - Missing idempotency keys on payment creation (double charges on retry)
21
+ - Webhook signature not verified (`stripe.webhooks.constructEvent` with `req.rawBody` — NOT parsed JSON body)
22
+ - Payment amount calculated client-side (price manipulation risk)
23
+ - No 3D Secure handling (`requires_action` status not handled in frontend)
24
+ - Secret keys in client bundle (check for `sk_live_` or `sk_test_` in frontend code)
25
+ - Missing failed payment recovery flow (no retry or dunning)
26
+ - Webhook processing not idempotent (same event processed twice creates duplicate orders)
27
+ - `req.body` used instead of `req.rawBody` for webhook signature verification (always fails)
28
+
29
+ **Step 3 — Emit robust payment flow**
30
+ Emit: server-side Payment Intent creation with idempotency, 3D Secure handling loop, comprehensive webhook handler with event deduplication, and refund flow with audit trail.
31
+
32
+ #### Example
33
+
34
+ ```typescript
35
+ // Stripe Payment Intent — server-side, idempotent, 3DS-ready
36
+ import Stripe from 'stripe';
37
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
38
+
39
+ app.post('/api/checkout', async (req, res) => {
40
+ const { cartId, paymentMethodId } = req.body;
41
+ const cart = await cartService.getVerified(cartId); // server-side price calculation
42
+
43
+ // Idempotency key derived from CART, not timestamp — prevents double charge on retry
44
+ const idempotencyKey = `checkout-${cartId}-v${cart.version}`;
45
+
46
+ const intent = await stripe.paymentIntents.create({
47
+ amount: cart.totalInCents, // ALWAYS server-calculated
48
+ currency: cart.currency,
49
+ payment_method: paymentMethodId,
50
+ confirm: true,
51
+ return_url: `${process.env.APP_URL}/checkout/complete`,
52
+ metadata: { cartId, userId: req.user.id },
53
+ idempotencyKey,
54
+ });
55
+
56
+ if (intent.status === 'requires_action') {
57
+ return res.json({ requiresAction: true, clientSecret: intent.client_secret });
58
+ }
59
+ if (intent.status === 'succeeded') {
60
+ await orderService.create(cart, intent.id);
61
+ return res.json({ success: true, orderId: intent.metadata.orderId });
62
+ }
63
+ res.status(400).json({ error: 'PAYMENT_FAILED' });
64
+ });
65
+
66
+ // Webhook — MUST use raw body for signature, deduplicate events
67
+ app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
68
+ const sig = req.headers['stripe-signature']!;
69
+ let event: Stripe.Event;
70
+
71
+ try {
72
+ event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
73
+ } catch {
74
+ return res.status(400).send('Signature verification failed');
75
+ }
76
+
77
+ // Deduplicate: check if event already processed
78
+ const existing = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
79
+ if (existing) return res.json({ received: true, duplicate: true });
80
+
81
+ // Process within transaction
82
+ await db.$transaction(async (tx) => {
83
+ await tx.webhookEvent.create({ data: { stripeEventId: event.id, type: event.type } });
84
+
85
+ if (event.type === 'payment_intent.succeeded') {
86
+ const intent = event.data.object as Stripe.PaymentIntent;
87
+ await orderService.confirmPayment(tx, intent.metadata.cartId, intent.id);
88
+ }
89
+ });
90
+
91
+ res.json({ received: true });
92
+ });
93
+ ```
94
+
95
+ #### Multi-Currency & Localization
96
+
97
+ ```typescript
98
+ // Locale-aware price formatting — ALWAYS use Intl, never manual toFixed()
99
+ function formatPrice(amountInCents: number, currency: string, locale: string): string {
100
+ return new Intl.NumberFormat(locale, {
101
+ style: 'currency',
102
+ currency,
103
+ minimumFractionDigits: 2,
104
+ maximumFractionDigits: 2,
105
+ }).format(amountInCents / 100);
106
+ }
107
+
108
+ // Examples
109
+ formatPrice(1999, 'USD', 'en-US'); // $19.99
110
+ formatPrice(1999, 'EUR', 'de-DE'); // 19,99 €
111
+ formatPrice(1999, 'JPY', 'ja-JP'); // ¥1,999 (JPY has no minor units)
112
+
113
+ // Currency conversion with FX rate cache
114
+ interface FxRate { from: string; to: string; rate: number; fetchedAt: Date }
115
+
116
+ class FxService {
117
+ private cache = new Map<string, FxRate>();
118
+
119
+ async convert(amountInCents: number, from: string, to: string): Promise<number> {
120
+ if (from === to) return amountInCents;
121
+ const key = `${from}:${to}`;
122
+ let rate = this.cache.get(key);
123
+
124
+ // Refresh if stale (>15 min)
125
+ if (!rate || Date.now() - rate.fetchedAt.getTime() > 15 * 60 * 1000) {
126
+ const fresh = await this.fetchRate(from, to);
127
+ rate = { from, to, rate: fresh, fetchedAt: new Date() };
128
+ this.cache.set(key, rate);
129
+ }
130
+ return Math.round(amountInCents * rate.rate);
131
+ }
132
+
133
+ private async fetchRate(from: string, to: string): Promise<number> {
134
+ // Use a reliable FX API (e.g., Frankfurter, Open Exchange Rates)
135
+ const res = await fetch(`https://api.frankfurter.app/latest?from=${from}&to=${to}`);
136
+ const data = await res.json();
137
+ return data.rates[to];
138
+ }
139
+ }
140
+
141
+ // Locale-aware pricing: show price in user's currency, charge in store's base currency
142
+ interface LocalizedPrice {
143
+ displayAmount: string; // "€18.45" — shown to user
144
+ chargeAmount: number; // 1999 cents USD — what actually gets charged
145
+ currency: string; // 'USD'
146
+ displayCurrency: string; // 'EUR'
147
+ exchangeRate: number;
148
+ }
149
+
150
+ async function getLocalizedPrice(
151
+ amountInCents: number,
152
+ storeCurrency: string,
153
+ userLocale: string,
154
+ userCurrency: string
155
+ ): Promise<LocalizedPrice> {
156
+ const fx = new FxService();
157
+ const displayAmountInCents = await fx.convert(amountInCents, storeCurrency, userCurrency);
158
+ return {
159
+ displayAmount: formatPrice(displayAmountInCents, userCurrency, userLocale),
160
+ chargeAmount: amountInCents, // charge in store base currency
161
+ currency: storeCurrency,
162
+ displayCurrency: userCurrency,
163
+ exchangeRate: displayAmountInCents / amountInCents,
164
+ };
165
+ }
166
+ ```
167
+
168
+ #### Vietnamese Payment Gateways (SePay, VNPay, MoMo, ZaloPay)
169
+
170
+ Vietnam market uses QR-based bank transfers and e-wallets instead of card payments. SePay is the simplest (webhook on bank transfer), VNPay is the most widely adopted gateway, MoMo/ZaloPay are e-wallet leaders.
171
+
172
+ **SePay — QR Bank Transfer (simplest integration)**
173
+
174
+ ```typescript
175
+ // SePay: generate QR code for bank transfer, webhook on payment received
176
+ // Docs: https://my.sepay.vn/docs
177
+
178
+ interface SePayConfig {
179
+ apiKey: string;
180
+ bankAccount: string; // your receiving bank account
181
+ bankCode: string; // e.g., 'MB', 'VCB', 'TCB', 'ACB'
182
+ webhookSecret: string;
183
+ }
184
+
185
+ // Generate payment QR — user scans with banking app
186
+ async function createSePayQR(orderId: string, amountVND: number, config: SePayConfig) {
187
+ // SePay uses structured transfer content for auto-matching
188
+ const transferContent = `DH${orderId}`; // prefix for order matching
189
+
190
+ return {
191
+ bankCode: config.bankCode,
192
+ bankAccount: config.bankAccount,
193
+ amount: amountVND,
194
+ content: transferContent,
195
+ // QR follows VietQR standard (NAPAS)
196
+ qrUrl: `https://qr.sepay.vn/img?acc=${config.bankAccount}&bank=${config.bankCode}&amount=${amountVND}&des=${transferContent}`,
197
+ };
198
+ }
199
+
200
+ // Webhook — SePay calls this when bank transfer is detected
201
+ app.post('/api/webhooks/sepay', async (req, res) => {
202
+ // Verify webhook signature
203
+ const signature = req.headers['x-sepay-signature'] as string;
204
+ const payload = JSON.stringify(req.body);
205
+ const expected = crypto.createHmac('sha256', process.env.SEPAY_WEBHOOK_SECRET!)
206
+ .update(payload).digest('hex');
207
+
208
+ if (signature !== expected) {
209
+ return res.status(401).json({ error: 'Invalid signature' });
210
+ }
211
+
212
+ const { transferAmount, transferContent, transactionDate, id } = req.body;
213
+
214
+ // Deduplicate
215
+ const existing = await db.payment.findFirst({ where: { externalId: String(id) } });
216
+ if (existing) return res.json({ success: true, duplicate: true });
217
+
218
+ // Match order by transfer content (DH{orderId})
219
+ const orderIdMatch = transferContent.match(/DH(\w+)/);
220
+ if (!orderIdMatch) {
221
+ console.error('SePay: unmatched transfer', { transferContent, id });
222
+ return res.json({ success: true, matched: false });
223
+ }
224
+
225
+ await db.$transaction(async (tx) => {
226
+ await tx.payment.create({
227
+ data: {
228
+ orderId: orderIdMatch[1],
229
+ amount: transferAmount,
230
+ method: 'BANK_TRANSFER',
231
+ provider: 'sepay',
232
+ externalId: String(id),
233
+ paidAt: new Date(transactionDate),
234
+ },
235
+ });
236
+ await tx.order.update({
237
+ where: { id: orderIdMatch[1] },
238
+ data: { status: 'PAID', paidAt: new Date(transactionDate) },
239
+ });
240
+ });
241
+
242
+ res.json({ success: true });
243
+ });
244
+ ```
245
+
246
+ **VNPay — Vietnam's largest payment gateway**
247
+
248
+ ```typescript
249
+ // VNPay: redirect-based payment with HMAC-SHA512 signature
250
+ // Docs: https://sandbox.vnpayment.vn/apis/docs/huong-dan-tich-hop/
251
+
252
+ import crypto from 'crypto';
253
+ import qs from 'qs';
254
+
255
+ interface VNPayConfig {
256
+ tmnCode: string; // merchant code
257
+ hashSecret: string; // secret key
258
+ vnpUrl: string; // 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html' (sandbox)
259
+ returnUrl: string; // your callback URL
260
+ }
261
+
262
+ function createVNPayUrl(orderId: string, amountVND: number, ipAddr: string, config: VNPayConfig): string {
263
+ const now = new Date();
264
+ const createDate = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
265
+
266
+ const params: Record<string, string> = {
267
+ vnp_Version: '2.1.0',
268
+ vnp_Command: 'pay',
269
+ vnp_TmnCode: config.tmnCode,
270
+ vnp_Locale: 'vn',
271
+ vnp_CurrCode: 'VND',
272
+ vnp_TxnRef: orderId,
273
+ vnp_OrderInfo: `Thanh toan don hang ${orderId}`,
274
+ vnp_OrderType: 'other',
275
+ vnp_Amount: String(amountVND * 100), // VNPay uses smallest unit (x100)
276
+ vnp_ReturnUrl: config.returnUrl,
277
+ vnp_IpAddr: ipAddr,
278
+ vnp_CreateDate: createDate,
279
+ };
280
+
281
+ // Sort params alphabetically — REQUIRED by VNPay
282
+ const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
283
+ acc[key] = params[key];
284
+ return acc;
285
+ }, {} as Record<string, string>);
286
+
287
+ const signData = qs.stringify(sortedParams, { encode: false });
288
+ const hmac = crypto.createHmac('sha512', config.hashSecret);
289
+ const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');
290
+
291
+ return `${config.vnpUrl}?${signData}&vnp_SecureHash=${signed}`;
292
+ }
293
+
294
+ // IPN (Instant Payment Notification) — VNPay server-to-server callback
295
+ app.get('/api/webhooks/vnpay-ipn', async (req, res) => {
296
+ const vnpParams = { ...req.query } as Record<string, string>;
297
+ const secureHash = vnpParams.vnp_SecureHash;
298
+ delete vnpParams.vnp_SecureHash;
299
+ delete vnpParams.vnp_SecureHashType;
300
+
301
+ // Verify hash
302
+ const sortedParams = Object.keys(vnpParams).sort().reduce((acc, key) => {
303
+ acc[key] = vnpParams[key];
304
+ return acc;
305
+ }, {} as Record<string, string>);
306
+
307
+ const signData = qs.stringify(sortedParams, { encode: false });
308
+ const expectedHash = crypto.createHmac('sha512', process.env.VNPAY_HASH_SECRET!)
309
+ .update(Buffer.from(signData, 'utf-8')).digest('hex');
310
+
311
+ if (secureHash !== expectedHash) {
312
+ return res.json({ RspCode: '97', Message: 'Invalid signature' });
313
+ }
314
+
315
+ const orderId = vnpParams.vnp_TxnRef;
316
+ const responseCode = vnpParams.vnp_ResponseCode;
317
+
318
+ if (responseCode === '00') {
319
+ await orderService.confirmPayment(orderId, vnpParams.vnp_TransactionNo);
320
+ return res.json({ RspCode: '00', Message: 'Confirm Success' });
321
+ }
322
+
323
+ await orderService.failPayment(orderId, responseCode);
324
+ res.json({ RspCode: '00', Message: 'Confirm Success' }); // always return 00 to VNPay
325
+ });
326
+ ```
327
+
328
+ **MoMo — E-wallet payment**
329
+
330
+ ```typescript
331
+ // MoMo: QR or app-switch payment
332
+ // Docs: https://developers.momo.vn/v3/docs/payment/api/
333
+
334
+ interface MoMoConfig {
335
+ partnerCode: string;
336
+ accessKey: string;
337
+ secretKey: string;
338
+ endpoint: string; // 'https://test-payment.momo.vn/v2/gateway/api/create'
339
+ redirectUrl: string;
340
+ ipnUrl: string;
341
+ }
342
+
343
+ async function createMoMoPayment(orderId: string, amountVND: number, config: MoMoConfig) {
344
+ const requestId = `${config.partnerCode}-${Date.now()}`;
345
+ const orderInfo = `Thanh toan don hang ${orderId}`;
346
+ const extraData = ''; // base64 encoded extra data
347
+
348
+ // HMAC SHA256 signature — order of fields matters!
349
+ const rawSignature = [
350
+ `accessKey=${config.accessKey}`,
351
+ `amount=${amountVND}`,
352
+ `extraData=${extraData}`,
353
+ `ipnUrl=${config.ipnUrl}`,
354
+ `orderId=${orderId}`,
355
+ `orderInfo=${orderInfo}`,
356
+ `partnerCode=${config.partnerCode}`,
357
+ `redirectUrl=${config.redirectUrl}`,
358
+ `requestId=${requestId}`,
359
+ `requestType=payWithMethod`,
360
+ ].join('&');
361
+
362
+ const signature = crypto.createHmac('sha256', config.secretKey)
363
+ .update(rawSignature).digest('hex');
364
+
365
+ const response = await fetch(config.endpoint, {
366
+ method: 'POST',
367
+ headers: { 'Content-Type': 'application/json' },
368
+ body: JSON.stringify({
369
+ partnerCode: config.partnerCode,
370
+ accessKey: config.accessKey,
371
+ requestId,
372
+ amount: amountVND,
373
+ orderId,
374
+ orderInfo,
375
+ redirectUrl: config.redirectUrl,
376
+ ipnUrl: config.ipnUrl,
377
+ extraData,
378
+ requestType: 'payWithMethod',
379
+ signature,
380
+ lang: 'vi',
381
+ }),
382
+ });
383
+
384
+ const data = await response.json();
385
+ return { payUrl: data.payUrl, qrCodeUrl: data.qrCodeUrl, deeplink: data.deeplink };
386
+ }
387
+ ```
388
+
389
+ **Sharp Edges — VN Payment Gotchas:**
390
+ - SePay: transfer content MUST be exact match — users sometimes add extra text → payment not auto-matched. Always show exact content to copy.
391
+ - VNPay: `vnp_Amount` is multiplied by 100 (not cents — VND has no decimals). Common bug: double-multiplying.
392
+ - VNPay: ALWAYS return `RspCode: '00'` to IPN even on failure — otherwise VNPay retries indefinitely.
393
+ - MoMo: signature field order is strict — wrong order = invalid signature. Copy exact order from docs.
394
+ - ZaloPay: similar to MoMo but uses HMAC-SHA256 with different field ordering. Check docs at `https://docs.zalopay.vn/`.
395
+ - All VN gateways: amounts are in VND (integer, no decimals). Never use floating point for VND.
396
+ - Sandbox environments often have rate limits and expire — test with real small amounts (10,000 VND) before go-live.
397
+
398
+ #### Fraud Detection
399
+
400
+ ```typescript
401
+ // Risk scoring before order fulfilment
402
+ interface FraudSignals {
403
+ ipAddress: string;
404
+ userAgent: string;
405
+ deviceFingerprint: string;
406
+ email: string;
407
+ billingCountry: string;
408
+ shippingCountry: string;
409
+ orderAmountCents: number;
410
+ isFirstOrder: boolean;
411
+ }
412
+
413
+ interface RiskScore {
414
+ score: number; // 0–100, higher = riskier
415
+ action: 'allow' | 'review' | 'block';
416
+ reasons: string[];
417
+ }
418
+
419
+ async function scoreFraudRisk(signals: FraudSignals): Promise<RiskScore> {
420
+ const reasons: string[] = [];
421
+ let score = 0;
422
+
423
+ // Velocity check — same IP, multiple orders in short window
424
+ const recentOrdersFromIp = await db.order.count({
425
+ where: { ipAddress: signals.ipAddress, createdAt: { gte: new Date(Date.now() - 3600_000) } },
426
+ });
427
+ if (recentOrdersFromIp >= 3) { score += 30; reasons.push('HIGH_VELOCITY_IP'); }
428
+
429
+ // Card BIN country mismatch
430
+ if (signals.billingCountry !== signals.shippingCountry) {
431
+ score += 15; reasons.push('BILLING_SHIPPING_MISMATCH');
432
+ }
433
+
434
+ // High-value first order — common pattern for stolen cards
435
+ if (signals.isFirstOrder && signals.orderAmountCents > 50000) {
436
+ score += 25; reasons.push('HIGH_VALUE_FIRST_ORDER');
437
+ }
438
+
439
+ // Email domain is disposable (temp-mail.org, mailinator.com, etc.)
440
+ const domain = signals.email.split('@')[1];
441
+ const isDisposable = await disposableEmailService.check(domain);
442
+ if (isDisposable) { score += 20; reasons.push('DISPOSABLE_EMAIL'); }
443
+
444
+ // Device fingerprint seen with multiple different emails (account farm)
445
+ const fingerprintEmails = await db.order.findMany({
446
+ where: { deviceFingerprint: signals.deviceFingerprint },
447
+ select: { email: true },
448
+ distinct: ['email'],
449
+ });
450
+ if (fingerprintEmails.length > 5) { score += 25; reasons.push('FINGERPRINT_MULTI_ACCOUNT'); }
451
+
452
+ const action = score >= 70 ? 'block' : score >= 40 ? 'review' : 'allow';
453
+ return { score, action, reasons };
454
+ }
455
+
456
+ // Apply fraud check in checkout flow
457
+ app.post('/api/checkout/confirm', async (req, res) => {
458
+ const { cartId } = req.body;
459
+ const signals = extractFraudSignals(req);
460
+ const risk = await scoreFraudRisk(signals);
461
+
462
+ if (risk.action === 'block') {
463
+ await db.fraudAttempt.create({ data: { ...signals, score: risk.score, reasons: risk.reasons } });
464
+ return res.status(403).json({ error: 'ORDER_BLOCKED', code: 'FRAUD_RISK' });
465
+ }
466
+ if (risk.action === 'review') {
467
+ // Proceed but flag for manual review after payment
468
+ await db.order.create({ data: { cartId, fraudScore: risk.score, requiresReview: true } });
469
+ }
470
+ // ... normal checkout flow
471
+ });
472
+ ```