Skip to content

Commit b478884

Browse files
authored
Merge pull request #1 from moneydevkit/mdk-403
feat: dynamic product checkout buttons (MDK-403)
2 parents 9a94d46 + b2d2749 commit b478884

8 files changed

Lines changed: 413 additions & 249 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ coverage
1919
.idea
2020
.vscode
2121
*.tgz
22+
!**/local-packages/*.tgz
2223
.secrets
2324
.envrc
2425
.direnv

mdk-nextjs-demo/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ WORKDIR /app
99
# Install curl and jq for healthchecks and JSON manipulation
1010
RUN apk add --no-cache curl libc6-compat jq
1111

12-
# Copy package files
12+
# Copy package files and local packages
1313
COPY package.json package-lock.json* ./
14+
COPY local-packages ./local-packages
1415

1516
# Copy local tarball dependencies (created by CI when testing PRs from mdk-checkout)
1617
# These files are always created by the e2e-reusable workflow before building this image.
Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,145 @@
1-
export { POST } from "@moneydevkit/nextjs/server/route";
1+
import { NextRequest } from "next/server";
2+
3+
// Webhook secret header
4+
const WEBHOOK_SECRET_HEADER = 'x-moneydevkit-webhook-secret';
5+
6+
// Lazy load the default handler
7+
let defaultHandlerPromise: Promise<(request: Request) => Promise<Response>> | null = null;
8+
function getDefaultHandler() {
9+
if (!defaultHandlerPromise) {
10+
defaultHandlerPromise = import("@moneydevkit/nextjs/server/route").then(m => m.POST);
11+
}
12+
return defaultHandlerPromise;
13+
}
14+
15+
// Helper to sleep for a given number of milliseconds
16+
function sleep(ms: number): Promise<void> {
17+
return new Promise(resolve => setTimeout(resolve, ms));
18+
}
19+
20+
// Custom webhook handler with proper sync and retry logic
21+
async function handleWebhookWithSync(request: NextRequest): Promise<Response> {
22+
const body = await request.json();
23+
24+
// Validate webhook secret
25+
const expectedSecret = process.env.MDK_ACCESS_TOKEN;
26+
if (!expectedSecret) {
27+
console.error('[webhook] MDK_ACCESS_TOKEN not configured');
28+
return new Response(JSON.stringify({ error: 'Webhook secret not configured' }), {
29+
status: 500,
30+
headers: { 'Content-Type': 'application/json' },
31+
});
32+
}
33+
34+
const providedSecret = request.headers.get(WEBHOOK_SECRET_HEADER);
35+
if (!providedSecret || providedSecret !== expectedSecret) {
36+
console.error('[webhook] Unauthorized webhook request. Expected:', expectedSecret.substring(0, 8) + '..., Got:', providedSecret?.substring(0, 8) + '...');
37+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
38+
status: 401,
39+
headers: { 'Content-Type': 'application/json' },
40+
});
41+
}
42+
43+
if (body.event !== 'incoming-payment') {
44+
console.log('[webhook] Unknown event type:', body.event);
45+
return new Response('OK', { status: 200 });
46+
}
47+
48+
console.log('[webhook] Processing incoming-payment event with node sync and retry');
49+
50+
try {
51+
// Dynamically import to avoid bundling issues
52+
const { createMoneyDevKitNode, createMoneyDevKitClient, markPaymentReceived } = await import("@moneydevkit/core");
53+
54+
const client = createMoneyDevKitClient();
55+
56+
// Retry logic: try up to 5 times with increasing delays
57+
const maxRetries = 5;
58+
const delays = [1000, 2000, 3000, 5000, 8000]; // Total: up to 19 seconds of waiting
59+
60+
let payments: Array<{ paymentHash: string; amount: number }> = [];
61+
62+
for (let attempt = 0; attempt < maxRetries; attempt++) {
63+
// Create a fresh node instance for each attempt
64+
const node = createMoneyDevKitNode();
65+
66+
// CRITICAL: Sync wallets BEFORE checking for payments
67+
console.log(`[webhook] Attempt ${attempt + 1}/${maxRetries}: Syncing wallets...`);
68+
node.syncWallets();
69+
console.log(`[webhook] Attempt ${attempt + 1}/${maxRetries}: Wallet sync complete`);
70+
71+
// Now receive payments with the synced state
72+
console.log(`[webhook] Attempt ${attempt + 1}/${maxRetries}: Checking for received payments...`);
73+
payments = node.receivePayments();
74+
console.log(`[webhook] Attempt ${attempt + 1}/${maxRetries}: Found ${payments.length} payment(s)`);
75+
76+
if (payments.length > 0) {
77+
break; // Found payments, exit retry loop
78+
}
79+
80+
// If no payments found and we have more retries, wait before trying again
81+
if (attempt < maxRetries - 1) {
82+
const delayMs = delays[attempt];
83+
console.log(`[webhook] No payments found, waiting ${delayMs}ms before retry...`);
84+
await sleep(delayMs);
85+
}
86+
}
87+
88+
if (payments.length === 0) {
89+
console.log('[webhook] No payments found after all retries');
90+
return new Response('OK', { status: 200 });
91+
}
92+
93+
// Mark payments as received locally
94+
payments.forEach((payment: { paymentHash: string }) => {
95+
console.log(`[webhook] Marking payment ${payment.paymentHash} as received`);
96+
markPaymentReceived(payment.paymentHash);
97+
});
98+
99+
// Notify MDK API about received payments
100+
try {
101+
console.log('[webhook] Notifying MDK API about payments...');
102+
await client.checkouts.paymentReceived({
103+
payments: payments.map((payment: { paymentHash: string; amount: number }) => ({
104+
paymentHash: payment.paymentHash,
105+
amountSats: payment.amount / 1000,
106+
sandbox: false,
107+
})),
108+
});
109+
console.log('[webhook] MDK API notified successfully');
110+
} catch (error) {
111+
console.error('[webhook] Failed to notify MDK API:', error);
112+
// Don't throw - local state is already marked
113+
}
114+
115+
return new Response('OK', { status: 200 });
116+
} catch (error) {
117+
console.error('[webhook] Error processing webhook:', error);
118+
return new Response(JSON.stringify({ error: 'Internal server error' }), {
119+
status: 500,
120+
headers: { 'Content-Type': 'application/json' },
121+
});
122+
}
123+
}
124+
125+
export async function POST(request: NextRequest): Promise<Response> {
126+
// Clone the request so we can read the body multiple times
127+
const clonedRequest = request.clone();
128+
129+
try {
130+
const body = await clonedRequest.json();
131+
const handler = body?.handler?.toLowerCase?.() ?? body?.route?.toLowerCase?.() ?? body?.target?.toLowerCase?.();
132+
133+
// Handle webhook requests with our custom sync logic
134+
if (handler === 'webhooks' || handler === 'webhook') {
135+
// Create a new request with the parsed body since we already consumed it
136+
return handleWebhookWithSync(request);
137+
}
138+
} catch {
139+
// If JSON parsing fails, let the default handler deal with it
140+
}
141+
142+
// For all other requests, use the default handler
143+
const defaultHandler = await getDefaultHandler();
144+
return defaultHandler(request);
145+
}

mdk-nextjs-demo/app/page.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useCheckout } from "@moneydevkit/nextjs";
3+
import { useCheckout, useProducts } from "@moneydevkit/nextjs";
44
import Link from "next/link";
55
import { useMemo, useState } from "react";
66

@@ -21,6 +21,7 @@ export default function HomePage() {
2121
const [customerName, setCustomerName] = useState("Satoshi Nakamoto");
2222
const [note, setNote] = useState("Fast IBD snapshot with hosted checkout.");
2323
const { navigate, isNavigating } = useCheckout();
24+
const { products, isLoading: productsLoading } = useProducts();
2425

2526
const metadata = useMemo(
2627
() => ({
@@ -43,6 +44,26 @@ export default function HomePage() {
4344
});
4445
};
4546

47+
const handleProductCheckout = () => {
48+
if (products.length === 0) return;
49+
navigate({
50+
// Single product checkout - uses first available product
51+
productId: products[0].id,
52+
metadata,
53+
checkoutPath: "/checkout",
54+
});
55+
};
56+
57+
const handleMultiProductCheckout = () => {
58+
if (products.length < 2) return;
59+
navigate({
60+
// Multiple products checkout - uses first two available products
61+
products: [products[0].id, products[1].id],
62+
metadata,
63+
checkoutPath: "/checkout",
64+
});
65+
};
66+
4667
return (
4768
<main className="page">
4869
<div className="container">
@@ -95,8 +116,30 @@ export default function HomePage() {
95116
disabled={isNavigating}
96117
data-test="start-checkout"
97118
>
98-
{isNavigating ? "Creating checkout…" : "Launch checkout"}
119+
{isNavigating ? "Creating checkout…" : "Launch checkout (Amount)"}
99120
</button>
121+
{products.length >= 1 && (
122+
<button
123+
type="button"
124+
className="button"
125+
onClick={handleProductCheckout}
126+
disabled={isNavigating || productsLoading}
127+
style={{ marginTop: "0.5rem", background: "#2563eb" }}
128+
>
129+
{isNavigating ? "Creating checkout…" : `Launch checkout (${products[0].name})`}
130+
</button>
131+
)}
132+
{products.length >= 2 && (
133+
<button
134+
type="button"
135+
className="button"
136+
onClick={handleMultiProductCheckout}
137+
disabled={isNavigating || productsLoading}
138+
style={{ marginTop: "0.5rem", background: "#7c3aed" }}
139+
>
140+
{isNavigating ? "Creating checkout…" : "Launch checkout (2 Products)"}
141+
</button>
142+
)}
100143
<p className="hint">
101144
We create a checkout session with the values above and redirect to
102145
{" /checkout/[id] "} using <code>useCheckout</code>.
61.8 KB
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)