feat: BTCPay webhook for auto-publish after confirmation, processing badge

This commit is contained in:
2026-02-06 15:03:56 +01:00
parent fcf22617d0
commit d9202f9ca2
8 changed files with 191 additions and 16 deletions

View File

@@ -0,0 +1,102 @@
<?php
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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']);

View File

@@ -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');

View File

@@ -1,6 +1,14 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://dgray.io');
$allowedOrigins = ['https://dgray.io', 'http://localhost:5500', 'http://localhost:8080'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header('Access-Control-Allow-Origin: ' . $origin);
} else {
header('Access-Control-Allow-Origin: https://dgray.io');
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
@@ -25,6 +33,9 @@ switch ($uri) {
case '/btcpay/status':
require __DIR__ . '/btcpay-status.php';
break;
case '/btcpay/webhook':
require __DIR__ . '/btcpay-webhook.php';
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Not found']);

View File

@@ -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) {

View File

@@ -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 ? `
<div class="sidebar-card payment-processing-card">
<div class="processing-badge">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<strong>${t('payment.awaitingConfirmation')}</strong>
</div>
<p class="processing-hint">${t('payment.awaitingHint')}</p>
</div>
` : ''}
<div class="sidebar-card">
<a href="#/edit/${this.listingId}" class="btn btn-primary btn-lg sidebar-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -986,6 +1000,26 @@ style.textContent = /* css */`
text-align: center;
}
/* Payment Processing Badge */
page-listing .payment-processing-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-warning, #e6a700);
}
page-listing .processing-badge {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-warning, #e6a700);
}
page-listing .processing-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-top: var(--space-sm);
line-height: 1.5;
}
/* Loading & Empty States */
page-listing .loading {
display: flex;

View File

@@ -322,6 +322,8 @@
"resume": "Zahlung fortsetzen",
"pending": "Zahlung ausstehend",
"required": "Zum Veröffentlichen ist eine Gebühr von {{amount}} {{currency}} erforderlich.",
"paidViaXmr": "Bezahlt via Monero (XMR)"
"paidViaXmr": "Bezahlt via Monero (XMR)",
"awaitingConfirmation": "Warte auf Blockchain-Bestätigung",
"awaitingHint": "Deine Zahlung wurde empfangen. Die Anzeige wird nach 1 Bestätigung automatisch veröffentlicht."
}
}

View File

@@ -322,6 +322,8 @@
"resume": "Resume payment",
"pending": "Payment pending",
"required": "A fee of {{amount}} {{currency}} is required to publish.",
"paidViaXmr": "Paid via Monero (XMR)"
"paidViaXmr": "Paid via Monero (XMR)",
"awaitingConfirmation": "Awaiting blockchain confirmation",
"awaitingHint": "Your payment has been received. The listing will be published automatically after 1 confirmation."
}
}

View File

@@ -322,6 +322,8 @@
"resume": "Reprendre le paiement",
"pending": "Paiement en attente",
"required": "Des frais de {{amount}} {{currency}} sont requis pour publier.",
"paidViaXmr": "Payé via Monero (XMR)"
"paidViaXmr": "Payé via Monero (XMR)",
"awaitingConfirmation": "En attente de confirmation blockchain",
"awaitingHint": "Votre paiement a été reçu. L'annonce sera publiée automatiquement après 1 confirmation."
}
}