diff --git a/docs/pow-server/btcpay-webhook.php b/docs/pow-server/btcpay-webhook.php new file mode 100644 index 0000000..3243e7b --- /dev/null +++ b/docs/pow-server/btcpay-webhook.php @@ -0,0 +1,102 @@ + 'Method not allowed']); + exit; +} + +$rawBody = file_get_contents('php://input'); +$payload = json_decode($rawBody, true); + +// Verify HMAC signature if secret is configured +if (BTCPAY_WEBHOOK_SECRET) { + $sigHeader = $_SERVER['HTTP_BTCPAY_SIG'] ?? ''; + $expectedSig = 'sha256=' . hash_hmac('sha256', $rawBody, BTCPAY_WEBHOOK_SECRET); + + if (!hash_equals($expectedSig, $sigHeader)) { + http_response_code(403); + echo json_encode(['error' => 'Invalid signature']); + exit; + } +} + +$type = $payload['type'] ?? null; +$invoiceId = $payload['invoiceId'] ?? null; + +if (!$type || !$invoiceId) { + http_response_code(400); + echo json_encode(['error' => 'Missing type or invoiceId']); + exit; +} + +// Only handle settled invoices +if ($type !== 'InvoiceSettled' && $type !== 'InvoicePaymentSettled') { + echo json_encode(['ok' => true, 'action' => 'ignored', 'type' => $type]); + exit; +} + +// Fetch invoice from BTCPay to get listing ID from metadata +$btcpayUrl = BTCPAY_BASE_URL . '/api/v1/stores/' . BTCPAY_STORE_ID . '/invoices/' . urlencode($invoiceId); +$btcpayContext = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Authorization: token " . BTCPAY_API_KEY . "\r\n", + 'ignore_errors' => true, + 'timeout' => 10, + ], +]); + +$btcpayResponse = file_get_contents($btcpayUrl, false, $btcpayContext); +if ($btcpayResponse === false) { + http_response_code(502); + echo json_encode(['error' => 'Failed to fetch invoice from BTCPay']); + exit; +} + +$invoice = json_decode($btcpayResponse, true); +$listingId = $invoice['metadata']['listingId'] ?? null; + +if (!$listingId) { + http_response_code(400); + echo json_encode(['error' => 'No listingId in invoice metadata']); + exit; +} + +// Update listing in Directus: publish + set paid +$expiresAt = date('c', strtotime('+30 days')); +$now = date('c'); + +$directusPayload = json_encode([ + 'status' => 'published', + 'payment_status' => 'paid', + 'paid_at' => $now, + 'expires_at' => $expiresAt, +]); + +$directusUrl = DIRECTUS_URL . '/items/listings/' . urlencode($listingId); +$directusContext = stream_context_create([ + 'http' => [ + 'method' => 'PATCH', + 'header' => "Content-Type: application/json\r\nAuthorization: Bearer " . DIRECTUS_TOKEN . "\r\n", + 'content' => $directusPayload, + 'ignore_errors' => true, + 'timeout' => 10, + ], +]); + +$directusResponse = file_get_contents($directusUrl, false, $directusContext); + +$directusStatus = 500; +if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $matches)) { + $directusStatus = (int)$matches[0]; +} + +if ($directusStatus >= 400) { + http_response_code(502); + echo json_encode(['error' => 'Failed to update listing in Directus', 'status' => $directusStatus]); + exit; +} + +echo json_encode(['ok' => true, 'listingId' => $listingId, 'action' => 'published']); diff --git a/docs/pow-server/config.php b/docs/pow-server/config.php index f52c7c4..8f6833f 100644 --- a/docs/pow-server/config.php +++ b/docs/pow-server/config.php @@ -8,3 +8,6 @@ define('BTCPAY_API_KEY', getenv('BTCPAY_API_KEY') ?: 'CHANGE_ME'); define('BTCPAY_STORE_ID', getenv('BTCPAY_STORE_ID') ?: 'CHANGE_ME'); define('BTCPAY_WEBHOOK_SECRET', getenv('BTCPAY_WEBHOOK_SECRET') ?: ''); define('LISTING_FEE', ['EUR' => 1, 'USD' => 1, 'CHF' => 1, 'GBP' => 1, 'JPY' => 200]); + +define('DIRECTUS_URL', getenv('DIRECTUS_URL') ?: 'https://api.dgray.io'); +define('DIRECTUS_TOKEN', getenv('DIRECTUS_TOKEN') ?: 'CHANGE_ME'); diff --git a/docs/pow-server/index.php b/docs/pow-server/index.php index 46ac641..83cc9b2 100644 --- a/docs/pow-server/index.php +++ b/docs/pow-server/index.php @@ -1,6 +1,14 @@ 'Not found']); diff --git a/js/components/pages/page-create.js b/js/components/pages/page-create.js index b55cfb0..0dd9dc0 100644 --- a/js/components/pages/page-create.js +++ b/js/components/pages/page-create.js @@ -678,20 +678,21 @@ class PageCreate extends HTMLElement { return } - // Poll for final status after modal close - const result = await pollUntilDone(invoiceId, { - interval: 4000, - timeout: 60000, - onUpdate: (update) => { - if (update.status === 'Processing' && submitBtn) { - submitBtn.textContent = t('payment.processing') - } - } - }) + // Check status once after modal close + const currentStatus = await getInvoiceStatus(invoiceId) - if (result.status === 'Settled') { + if (currentStatus.status === 'Settled') { await this.onPaymentSuccess(listingId) - } else { + return + } + + // Processing = payment received, waiting for confirmation + if (currentStatus.status === 'Processing') { + await this.onPaymentReceived(listingId) + return + } + + if (currentStatus.status === 'Expired' || currentStatus.status === 'Invalid') { await directus.updateListing(listingId, { payment_status: 'expired' }) clearPendingInvoice(listingId) this.showError(t('payment.expired')) @@ -700,6 +701,15 @@ class PageCreate extends HTMLElement { submitBtn.disabled = false submitBtn.textContent = t('create.publish') } + return + } + + // Still "New" - user closed modal without paying + this.showError(t('payment.failed')) + this.submitting = false + if (submitBtn) { + submitBtn.disabled = false + submitBtn.textContent = t('create.publish') } } catch (error) { console.error('Payment failed:', error) @@ -728,6 +738,15 @@ class PageCreate extends HTMLElement { router.navigate(`/listing/${listingId}`) } + async onPaymentReceived(listingId) { + await directus.updateListing(listingId, { + payment_status: 'processing' + }) + + clearPendingInvoice(listingId) + router.navigate(`/listing/${listingId}`) + } + showError(message) { let errorDiv = this.querySelector('.form-error') if (!errorDiv) { diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index 29e2ab1..e53cb8d 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -265,7 +265,21 @@ class PageListing extends HTMLElement { renderSidebar() { // Owner view: show edit button instead of contact if (this.isOwner) { + const paymentProcessing = this.listing?.payment_status === 'processing' + return /* html */` + ${paymentProcessing ? ` +
+ ` : ''}