@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.
- package/LICENSE +21 -21
- package/README.md +68 -34
- package/agents/adversary.md +27 -0
- package/agents/architect.md +19 -29
- package/agents/asset-creator.md +18 -4
- package/agents/audit.md +25 -4
- package/agents/autopsy.md +19 -4
- package/agents/ba.md +35 -0
- package/agents/brainstorm.md +31 -4
- package/agents/browser-pilot.md +21 -4
- package/agents/coder.md +21 -29
- package/agents/completion-gate.md +20 -4
- package/agents/constraint-check.md +18 -4
- package/agents/context-engine.md +22 -4
- package/agents/context-pack.md +32 -0
- package/agents/cook.md +41 -4
- package/agents/db.md +19 -4
- package/agents/debug.md +33 -4
- package/agents/dependency-doctor.md +20 -4
- package/agents/deploy.md +27 -4
- package/agents/design.md +22 -4
- package/agents/doc-processor.md +27 -0
- package/agents/docs-seeker.md +19 -4
- package/agents/docs.md +31 -0
- package/agents/fix.md +37 -4
- package/agents/git.md +29 -0
- package/agents/hallucination-guard.md +20 -4
- package/agents/incident.md +21 -4
- package/agents/integrity-check.md +18 -4
- package/agents/journal.md +19 -4
- package/agents/launch.md +32 -4
- package/agents/logic-guardian.md +26 -11
- package/agents/marketing.md +23 -4
- package/agents/mcp-builder.md +26 -0
- package/agents/neural-memory.md +30 -0
- package/agents/onboard.md +22 -4
- package/agents/perf.md +21 -4
- package/agents/plan.md +29 -4
- package/agents/preflight.md +22 -4
- package/agents/problem-solver.md +20 -4
- package/agents/rescue.md +23 -4
- package/agents/research.md +19 -4
- package/agents/researcher.md +19 -29
- package/agents/retro.md +32 -0
- package/agents/review-intake.md +20 -4
- package/agents/review.md +32 -4
- package/agents/reviewer.md +20 -28
- package/agents/safeguard.md +19 -4
- package/agents/sast.md +18 -4
- package/agents/scaffold.md +41 -0
- package/agents/scanner.md +19 -28
- package/agents/scope-guard.md +18 -4
- package/agents/scout.md +23 -4
- package/agents/sentinel-env.md +26 -0
- package/agents/sentinel.md +33 -4
- package/agents/sequential-thinking.md +20 -4
- package/agents/session-bridge.md +24 -4
- package/agents/skill-forge.md +22 -4
- package/agents/skill-router.md +26 -4
- package/agents/slides.md +24 -0
- package/agents/surgeon.md +19 -4
- package/agents/team.md +30 -4
- package/agents/test.md +36 -4
- package/agents/trend-scout.md +17 -4
- package/agents/verification.md +20 -4
- package/agents/video-creator.md +20 -4
- package/agents/watchdog.md +19 -4
- package/agents/worktree.md +17 -4
- package/commands/rune.md +168 -168
- package/compiler/__tests__/analytics.test.js +370 -0
- package/compiler/adapters/openclaw.js +2 -2
- package/compiler/analytics.js +385 -0
- package/compiler/bin/rune.js +68 -2
- package/compiler/dashboard.js +883 -0
- package/compiler/transforms/branding.js +1 -1
- package/contexts/dev.md +34 -34
- package/contexts/research.md +43 -43
- package/contexts/review.md +55 -55
- package/extensions/ai-ml/PACK.md +88 -88
- package/extensions/ai-ml/skills/ai-agents.md +172 -172
- package/extensions/ai-ml/skills/code-sandbox.md +187 -187
- package/extensions/ai-ml/skills/deep-research.md +146 -146
- package/extensions/ai-ml/skills/embedding-search.md +66 -66
- package/extensions/ai-ml/skills/fine-tuning-guide.md +74 -74
- package/extensions/ai-ml/skills/llm-architect.md +125 -125
- package/extensions/ai-ml/skills/llm-integration.md +64 -64
- package/extensions/ai-ml/skills/prompt-patterns.md +72 -72
- package/extensions/ai-ml/skills/rag-patterns.md +66 -66
- package/extensions/ai-ml/skills/web-extraction.md +114 -114
- package/extensions/analytics/PACK.md +92 -92
- package/extensions/analytics/skills/ab-testing.md +72 -72
- package/extensions/analytics/skills/dashboard-patterns.md +83 -83
- package/extensions/analytics/skills/data-validation.md +68 -68
- package/extensions/analytics/skills/funnel-analysis.md +81 -81
- package/extensions/analytics/skills/sql-patterns.md +57 -57
- package/extensions/analytics/skills/statistical-analysis.md +79 -79
- package/extensions/analytics/skills/tracking-setup.md +71 -71
- package/extensions/backend/PACK.md +104 -104
- package/extensions/backend/skills/api-patterns.md +84 -84
- package/extensions/backend/skills/async-pipeline.md +193 -193
- package/extensions/backend/skills/auth-patterns.md +97 -97
- package/extensions/backend/skills/background-jobs.md +133 -133
- package/extensions/backend/skills/caching-patterns.md +108 -108
- package/extensions/backend/skills/cli-generation.md +133 -133
- package/extensions/backend/skills/database-patterns.md +87 -87
- package/extensions/backend/skills/middleware-patterns.md +104 -104
- package/extensions/chrome-ext/PACK.md +93 -93
- package/extensions/chrome-ext/skills/cws-preflight.md +143 -143
- package/extensions/chrome-ext/skills/cws-publish.md +104 -104
- package/extensions/chrome-ext/skills/ext-ai-integration.md +251 -251
- package/extensions/chrome-ext/skills/ext-messaging.md +139 -139
- package/extensions/chrome-ext/skills/ext-storage.md +133 -133
- package/extensions/chrome-ext/skills/mv3-scaffold.md +164 -164
- package/extensions/content/PACK.md +96 -96
- package/extensions/content/skills/blog-patterns.md +88 -88
- package/extensions/content/skills/cms-integration.md +131 -131
- package/extensions/content/skills/content-scoring.md +107 -107
- package/extensions/content/skills/i18n.md +83 -83
- package/extensions/content/skills/mdx-authoring.md +137 -137
- package/extensions/content/skills/reference.md +1014 -1014
- package/extensions/content/skills/seo-patterns.md +67 -67
- package/extensions/content/skills/video-repurpose.md +153 -153
- package/extensions/devops/PACK.md +101 -101
- package/extensions/devops/skills/chaos-testing.md +67 -67
- package/extensions/devops/skills/ci-cd.md +75 -75
- package/extensions/devops/skills/docker.md +58 -58
- package/extensions/devops/skills/edge-serverless.md +163 -163
- package/extensions/devops/skills/infra-as-code.md +158 -158
- package/extensions/devops/skills/kubernetes.md +110 -110
- package/extensions/devops/skills/monitoring.md +57 -57
- package/extensions/devops/skills/server-setup.md +64 -64
- package/extensions/devops/skills/ssl-domain.md +42 -42
- package/extensions/ecommerce/PACK.md +116 -116
- package/extensions/ecommerce/skills/cart-system.md +79 -79
- package/extensions/ecommerce/skills/inventory-mgmt.md +102 -102
- package/extensions/ecommerce/skills/order-management.md +126 -126
- package/extensions/ecommerce/skills/payment-integration.md +472 -472
- package/extensions/ecommerce/skills/shopify-dev.md +69 -69
- package/extensions/ecommerce/skills/subscription-billing.md +93 -93
- package/extensions/ecommerce/skills/tax-compliance.md +117 -117
- package/extensions/gamedev/PACK.md +142 -142
- package/extensions/gamedev/skills/asset-pipeline.md +74 -74
- package/extensions/gamedev/skills/audio-system.md +129 -129
- package/extensions/gamedev/skills/camera-system.md +87 -87
- package/extensions/gamedev/skills/ecs.md +98 -98
- package/extensions/gamedev/skills/game-loops.md +72 -72
- package/extensions/gamedev/skills/input-system.md +199 -199
- package/extensions/gamedev/skills/multiplayer.md +180 -180
- package/extensions/gamedev/skills/particles.md +105 -105
- package/extensions/gamedev/skills/physics-engine.md +89 -89
- package/extensions/gamedev/skills/scene-management.md +146 -146
- package/extensions/gamedev/skills/threejs-patterns.md +90 -90
- package/extensions/gamedev/skills/webgl.md +71 -71
- package/extensions/mobile/PACK.md +106 -106
- package/extensions/mobile/skills/app-store-connect.md +152 -152
- package/extensions/mobile/skills/app-store-prep.md +66 -66
- package/extensions/mobile/skills/deep-linking.md +109 -109
- package/extensions/mobile/skills/flutter.md +60 -60
- package/extensions/mobile/skills/ios-build-pipeline.md +142 -142
- package/extensions/mobile/skills/native-bridge.md +66 -66
- package/extensions/mobile/skills/ota-updates.md +97 -97
- package/extensions/mobile/skills/push-notifications.md +111 -111
- package/extensions/mobile/skills/react-native.md +82 -82
- package/extensions/saas/PACK.md +116 -116
- package/extensions/saas/skills/billing-integration.md +200 -200
- package/extensions/saas/skills/feature-flags.md +130 -130
- package/extensions/saas/skills/multi-tenant.md +103 -103
- package/extensions/saas/skills/onboarding-flow.md +139 -139
- package/extensions/saas/skills/subscription-flow.md +95 -95
- package/extensions/saas/skills/team-management.md +144 -144
- package/extensions/security/PACK.md +99 -99
- package/extensions/security/skills/api-security.md +140 -140
- package/extensions/security/skills/compliance.md +68 -68
- package/extensions/security/skills/owasp-audit.md +64 -64
- package/extensions/security/skills/pentest-patterns.md +77 -77
- package/extensions/security/skills/secret-mgmt.md +65 -65
- package/extensions/security/skills/supply-chain.md +65 -65
- package/extensions/trading/PACK.md +80 -80
- package/extensions/trading/skills/chart-components.md +55 -55
- package/extensions/trading/skills/experiment-loop.md +125 -125
- package/extensions/trading/skills/fintech-patterns.md +47 -47
- package/extensions/trading/skills/indicator-library.md +58 -58
- package/extensions/trading/skills/quant-analysis.md +111 -111
- package/extensions/trading/skills/realtime-data.md +58 -58
- package/extensions/trading/skills/trade-logic.md +104 -104
- package/extensions/ui/PACK.md +130 -130
- package/extensions/ui/skills/a11y-audit.md +91 -91
- package/extensions/ui/skills/animation-patterns.md +127 -106
- package/extensions/ui/skills/component-patterns.md +100 -75
- package/extensions/ui/skills/design-decision.md +108 -108
- package/extensions/ui/skills/design-system.md +68 -68
- package/extensions/ui/skills/landing-patterns.md +155 -155
- package/extensions/ui/skills/palette-picker.md +173 -173
- package/extensions/ui/skills/react-health.md +90 -90
- package/extensions/ui/skills/type-system.md +125 -125
- package/extensions/ui/skills/web-vitals.md +153 -153
- package/extensions/zalo/PACK.md +145 -145
- package/extensions/zalo/skills/zalo-oa-mcp.md +317 -317
- package/extensions/zalo/skills/zalo-oa-messaging.md +429 -429
- package/extensions/zalo/skills/zalo-oa-setup.md +236 -236
- package/extensions/zalo/skills/zalo-oa-webhook.md +189 -189
- package/extensions/zalo/skills/zalo-personal-messaging.md +194 -194
- package/extensions/zalo/skills/zalo-personal-setup.md +153 -153
- package/extensions/zalo/skills/zalo-rate-guard.md +219 -219
- package/hooks/auto-format/index.cjs +48 -48
- package/hooks/context-watch/index.cjs +95 -68
- package/hooks/hooks.json +111 -111
- package/hooks/metrics-collector/index.cjs +86 -42
- package/hooks/post-session-reflect/index.cjs +189 -153
- package/hooks/pre-compact/index.cjs +95 -95
- package/hooks/run-hook.cmd +1 -1
- package/hooks/secrets-scan/index.cjs +100 -100
- package/hooks/session-start/index.cjs +71 -65
- package/hooks/typecheck/index.cjs +65 -65
- package/package.json +63 -63
- package/references/ui-pro-max-data/LICENSE-UI-PRO-MAX +21 -21
- package/references/ui-pro-max-data/charts.csv +26 -26
- package/references/ui-pro-max-data/colors.csv +161 -161
- package/references/ui-pro-max-data/styles.csv +68 -68
- package/references/ui-pro-max-data/typography.csv +74 -74
- package/references/ui-pro-max-data/ui-reasoning.csv +162 -162
- package/references/ui-pro-max-data/ux-guidelines.csv +99 -99
- package/skills/adversary/SKILL.md +283 -283
- package/skills/asset-creator/SKILL.md +157 -157
- package/skills/audit/SKILL.md +148 -2
- package/skills/autopsy/SKILL.md +335 -259
- package/skills/autopsy/references/repo-analysis-patterns.md +113 -0
- package/skills/ba/SKILL.md +72 -2
- package/skills/brainstorm/SKILL.md +342 -341
- package/skills/browser-pilot/SKILL.md +168 -168
- package/skills/constraint-check/SKILL.md +165 -165
- package/skills/context-engine/SKILL.md +404 -404
- package/skills/cook/SKILL.md +917 -834
- package/skills/cook/references/output-format.md +33 -0
- package/skills/db/SKILL.md +273 -272
- package/skills/debug/SKILL.md +465 -443
- package/skills/dependency-doctor/SKILL.md +265 -235
- package/skills/deploy/SKILL.md +274 -231
- package/skills/design/DESIGN-REFERENCE.md +365 -365
- package/skills/design/SKILL.md +589 -482
- package/skills/doc-processor/SKILL.md +254 -254
- package/skills/docs/SKILL.md +374 -373
- package/skills/docs-seeker/SKILL.md +177 -177
- package/skills/fix/SKILL.md +330 -308
- package/skills/git/SKILL.md +339 -339
- package/skills/graft/SKILL.md +352 -0
- package/skills/graft/references/challenge-framework.md +98 -0
- package/skills/graft/references/mode-decision.md +44 -0
- package/skills/hallucination-guard/SKILL.md +219 -219
- package/skills/incident/SKILL.md +254 -251
- package/skills/integrity-check/SKILL.md +169 -169
- package/skills/journal/SKILL.md +240 -238
- package/skills/launch/SKILL.md +344 -342
- package/skills/logic-guardian/SKILL.md +251 -251
- package/skills/marketing/SKILL.md +290 -245
- package/skills/mcp-builder/SKILL.md +425 -423
- package/skills/mcp-builder/references/auto-discovery-pattern.md +169 -0
- package/skills/neural-memory/SKILL.md +362 -362
- package/skills/onboard/SKILL.md +404 -403
- package/skills/perf/SKILL.md +346 -346
- package/skills/plan/SKILL.md +433 -370
- package/skills/plan/references/feature-map.md +84 -0
- package/skills/preflight/SKILL.md +415 -396
- package/skills/problem-solver/SKILL.md +380 -284
- package/skills/rescue/SKILL.md +474 -450
- package/skills/retro/SKILL.md +5 -1
- package/skills/review/SKILL.md +612 -535
- package/skills/review-intake/SKILL.md +249 -249
- package/skills/safeguard/SKILL.md +200 -200
- package/skills/sast/SKILL.md +190 -190
- package/skills/scaffold/SKILL.md +328 -286
- package/skills/scope-guard/SKILL.md +180 -162
- package/skills/scout/SKILL.md +263 -263
- package/skills/sentinel/SKILL.md +382 -353
- package/skills/sentinel-env/SKILL.md +254 -254
- package/skills/sequential-thinking/SKILL.md +234 -234
- package/skills/session-bridge/SKILL.md +543 -397
- package/skills/skill-forge/SKILL.md +581 -539
- package/skills/skill-router/{skill.md → SKILL.md} +30 -2
- package/skills/surgeon/SKILL.md +215 -215
- package/skills/team/SKILL.md +556 -514
- package/skills/test/SKILL.md +614 -587
- package/skills/trend-scout/SKILL.md +145 -145
- package/skills/verification/SKILL.md +326 -325
- package/skills/video-creator/SKILL.md +201 -201
- package/skills/watchdog/SKILL.md +168 -168
- 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
|
+
```
|