feat: BTCPay Server payment integration via pow.dgray.io proxy
This commit is contained in:
@@ -61,7 +61,8 @@ js/
|
|||||||
│ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt)
|
│ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt)
|
||||||
│ ├── crypto.js # NaCl Encryption
|
│ ├── crypto.js # NaCl Encryption
|
||||||
│ ├── currency.js # XMR/Fiat Umrechnung
|
│ ├── currency.js # XMR/Fiat Umrechnung
|
||||||
│ └── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify)
|
│ ├── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify)
|
||||||
|
│ └── btcpay.js # BTCPay Server Integration (Invoice, Checkout, Polling)
|
||||||
└── components/
|
└── components/
|
||||||
├── app-shell.js # Layout, registriert Routes
|
├── app-shell.js # Layout, registriert Routes
|
||||||
├── app-header.js # Header (Theme-Toggle, Lang-Dropdown, Profil-Dropdown)
|
├── app-header.js # Header (Theme-Toggle, Lang-Dropdown, Profil-Dropdown)
|
||||||
@@ -140,7 +141,7 @@ locales/
|
|||||||
4. ~~PoW-Captcha server-seitig~~ ✅ PHP-Server auf `pow.dgray.io`
|
4. ~~PoW-Captcha server-seitig~~ ✅ PHP-Server auf `pow.dgray.io`
|
||||||
5. ~~TweetNaCl self-hosted~~ ✅ In `js/vendor/`
|
5. ~~TweetNaCl self-hosted~~ ✅ In `js/vendor/`
|
||||||
6. ~~Chat-Crypto fix~~ ✅ `box.before` + `secretbox`
|
6. ~~Chat-Crypto fix~~ ✅ `box.before` + `secretbox`
|
||||||
7. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/)
|
7. ~~Payment-Integration mit BTCpay Server~~ ✅ Proxy auf `pow.dgray.io`, Frontend-Service `btcpay.js`
|
||||||
8. Reputation-System (5/15/50 Deals Stufen)
|
8. Reputation-System (5/15/50 Deals Stufen)
|
||||||
9. Push-Benachrichtigungen für neue Nachrichten
|
9. Push-Benachrichtigungen für neue Nachrichten
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
# PoW Captcha Server
|
# PoW Captcha & Payment Server
|
||||||
|
|
||||||
PHP-basierter Proof-of-Work Captcha Server für dgray.io.
|
PHP-basierter Server für dgray.io mit Proof-of-Work Captcha und BTCPay Payment-Proxy.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Subdomain `pow.dgray.io` auf den Server zeigen
|
1. Subdomain `pow.dgray.io` auf den Server zeigen
|
||||||
2. Dateien in das Web-Root kopieren
|
2. Dateien in das Web-Root kopieren
|
||||||
3. Secret setzen:
|
3. Secrets setzen:
|
||||||
```bash
|
```bash
|
||||||
# In .env oder Apache/Nginx config:
|
# In .env oder Apache/Nginx config:
|
||||||
SetEnv POW_SECRET $(openssl rand -hex 32)
|
SetEnv POW_SECRET $(openssl rand -hex 32)
|
||||||
|
SetEnv BTCPAY_API_KEY your_btcpay_api_key
|
||||||
|
SetEnv BTCPAY_STORE_ID your_btcpay_store_id
|
||||||
```
|
```
|
||||||
Oder direkt in `config.php` den Wert von `POW_SECRET` ändern.
|
Oder direkt in `config.php` die Werte ändern.
|
||||||
|
|
||||||
4. Testen:
|
4. Testen:
|
||||||
```bash
|
```bash
|
||||||
|
# PoW Challenge
|
||||||
curl https://pow.dgray.io/challenge
|
curl https://pow.dgray.io/challenge
|
||||||
|
|
||||||
|
# BTCPay Invoice erstellen
|
||||||
|
curl -X POST https://pow.dgray.io/btcpay/invoice \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"listingId": "test-123", "currency": "EUR"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### GET /challenge
|
### GET /challenge
|
||||||
Gibt eine signierte Challenge zurück.
|
Gibt eine signierte PoW-Challenge zurück.
|
||||||
|
|
||||||
### POST /verify
|
### POST /verify
|
||||||
Prüft die Lösung. Body (JSON):
|
Prüft die PoW-Lösung. Body (JSON):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"challenge": "...",
|
"challenge": "...",
|
||||||
@@ -35,9 +43,51 @@ Prüft die Lösung. Body (JSON):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### POST /btcpay/invoice
|
||||||
|
Erstellt eine BTCPay Server Invoice für eine Listing-Gebühr.
|
||||||
|
Body (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"listingId": "uuid-string",
|
||||||
|
"currency": "EUR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"invoiceId": "...",
|
||||||
|
"checkoutLink": "https://pay.xmr.rocks/i/...",
|
||||||
|
"status": "New",
|
||||||
|
"expirationTime": 1700000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /btcpay/status?id={invoiceId}
|
||||||
|
Prüft den Zahlungsstatus einer Invoice.
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"invoiceId": "...",
|
||||||
|
"status": "New|Processing|Settled|Expired|Invalid",
|
||||||
|
"additionalStatus": "None|PaidLate|PaidPartial|..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gebühren
|
||||||
|
|
||||||
|
| Währung | Betrag |
|
||||||
|
|---------|--------|
|
||||||
|
| EUR | 1 |
|
||||||
|
| USD | 1 |
|
||||||
|
| CHF | 1 |
|
||||||
|
| GBP | 1 |
|
||||||
|
| JPY | 200 |
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- HMAC-SHA256 signierte Challenges (nicht fälschbar)
|
- HMAC-SHA256 signierte Challenges (nicht fälschbar)
|
||||||
- TTL: 2 Minuten
|
- TTL: 2 Minuten
|
||||||
- CORS: nur `https://dgray.io`
|
- CORS: nur `https://dgray.io`
|
||||||
- `hash_equals()` gegen Timing-Attacks
|
- `hash_equals()` gegen Timing-Attacks
|
||||||
|
- BTCPay API-Key bleibt serverseitig (nie im Frontend)
|
||||||
|
- Gebühren serverseitig erzwungen (nicht manipulierbar)
|
||||||
|
|||||||
77
docs/pow-server/btcpay-invoice.php
Normal file
77
docs/pow-server/btcpay-invoice.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$listingId = $input['listingId'] ?? null;
|
||||||
|
$currency = $input['currency'] ?? 'EUR';
|
||||||
|
|
||||||
|
if (!$listingId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing listingId']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fees = LISTING_FEE;
|
||||||
|
if (!isset($fees[$currency])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Unsupported currency', 'supported' => array_keys($fees)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $fees[$currency];
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'amount' => $amount,
|
||||||
|
'currency' => $currency,
|
||||||
|
'metadata' => [
|
||||||
|
'listingId' => $listingId,
|
||||||
|
'orderId' => 'listing-' . $listingId,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = BTCPAY_BASE_URL . '/api/v1/stores/' . BTCPAY_STORE_ID . '/invoices';
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json\r\nAuthorization: token " . BTCPAY_API_KEY . "\r\n",
|
||||||
|
'content' => $payload,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'Failed to connect to payment server']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract HTTP status from response headers
|
||||||
|
$statusCode = 500;
|
||||||
|
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $matches)) {
|
||||||
|
$statusCode = (int)$matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if ($statusCode >= 400) {
|
||||||
|
http_response_code($statusCode);
|
||||||
|
echo json_encode(['error' => $data['message'] ?? 'Invoice creation failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'invoiceId' => $data['id'] ?? null,
|
||||||
|
'checkoutLink' => $data['checkoutLink'] ?? null,
|
||||||
|
'status' => $data['status'] ?? null,
|
||||||
|
'expirationTime' => $data['expirationTime'] ?? null,
|
||||||
|
]);
|
||||||
54
docs/pow-server/btcpay-status.php
Normal file
54
docs/pow-server/btcpay-status.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceId = $_GET['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$invoiceId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing id parameter']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = BTCPAY_BASE_URL . '/api/v1/stores/' . BTCPAY_STORE_ID . '/invoices/' . urlencode($invoiceId);
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "Authorization: token " . BTCPAY_API_KEY . "\r\n",
|
||||||
|
'ignore_errors' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'Failed to connect to payment server']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract HTTP status from response headers
|
||||||
|
$statusCode = 500;
|
||||||
|
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $matches)) {
|
||||||
|
$statusCode = (int)$matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if ($statusCode >= 400) {
|
||||||
|
http_response_code($statusCode);
|
||||||
|
echo json_encode(['error' => $data['message'] ?? 'Failed to fetch invoice status']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'invoiceId' => $data['id'] ?? null,
|
||||||
|
'status' => $data['status'] ?? null,
|
||||||
|
'additionalStatus' => $data['additionalStatus'] ?? null,
|
||||||
|
]);
|
||||||
@@ -2,3 +2,9 @@
|
|||||||
define('POW_SECRET', getenv('POW_SECRET') ?: 'CHANGE_ME_TO_A_RANDOM_64_CHAR_HEX_STRING');
|
define('POW_SECRET', getenv('POW_SECRET') ?: 'CHANGE_ME_TO_A_RANDOM_64_CHAR_HEX_STRING');
|
||||||
define('POW_DIFFICULTY', 4);
|
define('POW_DIFFICULTY', 4);
|
||||||
define('POW_TTL_SECONDS', 120);
|
define('POW_TTL_SECONDS', 120);
|
||||||
|
|
||||||
|
define('BTCPAY_BASE_URL', getenv('BTCPAY_BASE_URL') ?: 'https://pay.xmr.rocks');
|
||||||
|
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]);
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ switch ($uri) {
|
|||||||
case '/verify':
|
case '/verify':
|
||||||
require __DIR__ . '/verify.php';
|
require __DIR__ . '/verify.php';
|
||||||
break;
|
break;
|
||||||
|
case '/btcpay/invoice':
|
||||||
|
require __DIR__ . '/btcpay-invoice.php';
|
||||||
|
break;
|
||||||
|
case '/btcpay/status':
|
||||||
|
require __DIR__ . '/btcpay-status.php';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'Not found']);
|
echo json_encode(['error' => 'Not found']);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { auth } from '../../services/auth.js'
|
|||||||
import { directus } from '../../services/directus.js'
|
import { directus } from '../../services/directus.js'
|
||||||
import { categoriesService } from '../../services/categories.js'
|
import { categoriesService } from '../../services/categories.js'
|
||||||
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
|
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
|
||||||
|
import { createInvoice, openCheckout, pollUntilDone, getPendingInvoice, savePendingInvoice, clearPendingInvoice, getInvoiceStatus } from '../../services/btcpay.js'
|
||||||
import { escapeHTML } from '../../utils/helpers.js'
|
import { escapeHTML } from '../../utils/helpers.js'
|
||||||
import '../location-picker.js'
|
import '../location-picker.js'
|
||||||
import '../pow-captcha.js'
|
import '../pow-captcha.js'
|
||||||
@@ -527,7 +528,6 @@ class PageCreate extends HTMLElement {
|
|||||||
|
|
||||||
const form = e.target
|
const form = e.target
|
||||||
|
|
||||||
// Read current form values directly (more reliable than event listeners)
|
|
||||||
const formElements = {
|
const formElements = {
|
||||||
title: form.querySelector('#title')?.value || '',
|
title: form.querySelector('#title')?.value || '',
|
||||||
description: form.querySelector('#description')?.value || '',
|
description: form.querySelector('#description')?.value || '',
|
||||||
@@ -541,7 +541,6 @@ class PageCreate extends HTMLElement {
|
|||||||
moneroAddress: form.querySelector('#moneroAddress')?.value || ''
|
moneroAddress: form.querySelector('#moneroAddress')?.value || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate PoW Captcha (only for new accounts and new listings)
|
|
||||||
if (!this.editMode && this.isNewAccount) {
|
if (!this.editMode && this.isNewAccount) {
|
||||||
const captcha = this.querySelector('#pow-captcha')
|
const captcha = this.querySelector('#pow-captcha')
|
||||||
if (!captcha?.isSolved()) {
|
if (!captcha?.isSolved()) {
|
||||||
@@ -550,7 +549,6 @@ class PageCreate extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Monero address
|
|
||||||
if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) {
|
if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) {
|
||||||
this.showError(t('create.invalidMoneroAddress'))
|
this.showError(t('create.invalidMoneroAddress'))
|
||||||
return
|
return
|
||||||
@@ -564,14 +562,12 @@ class PageCreate extends HTMLElement {
|
|||||||
submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing')
|
submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload new images first
|
|
||||||
let newImageIds = []
|
let newImageIds = []
|
||||||
if (this.imageFiles.length > 0) {
|
if (this.imageFiles.length > 0) {
|
||||||
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
|
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
|
||||||
newImageIds = uploadedFiles.map(f => f.id)
|
newImageIds = uploadedFiles.map(f => f.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build listing data from form values
|
|
||||||
const listingData = {
|
const listingData = {
|
||||||
title: formElements.title,
|
title: formElements.title,
|
||||||
slug: this.generateSlug(formElements.title),
|
slug: this.generateSlug(formElements.title),
|
||||||
@@ -580,12 +576,6 @@ class PageCreate extends HTMLElement {
|
|||||||
currency: formElements.currency
|
currency: formElements.currency
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set status on create, not on edit
|
|
||||||
if (!this.editMode) {
|
|
||||||
listingData.status = 'published'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add optional fields only if set
|
|
||||||
if (formElements.price_mode) listingData.price_mode = formElements.price_mode
|
if (formElements.price_mode) listingData.price_mode = formElements.price_mode
|
||||||
if (formElements.category) listingData.category = formElements.category
|
if (formElements.category) listingData.category = formElements.category
|
||||||
if (formElements.condition) listingData.condition = formElements.condition
|
if (formElements.condition) listingData.condition = formElements.condition
|
||||||
@@ -594,16 +584,7 @@ class PageCreate extends HTMLElement {
|
|||||||
listingData.shipping_cost = parseFloat(formElements.shipping_cost)
|
listingData.shipping_cost = parseFloat(formElements.shipping_cost)
|
||||||
}
|
}
|
||||||
if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress
|
if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress
|
||||||
|
|
||||||
// Calculate expires_at (only on create) - 30 days for regular users, 60 for power users
|
|
||||||
if (!this.editMode) {
|
|
||||||
const days = 30 // TODO: 60 for power users
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + days)
|
|
||||||
listingData.expires_at = expiresAt.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle location - find or create in locations collection
|
|
||||||
if (this.formData.locationData) {
|
if (this.formData.locationData) {
|
||||||
const locationId = await this.findOrCreateLocation(this.formData.locationData)
|
const locationId = await this.findOrCreateLocation(this.formData.locationData)
|
||||||
if (locationId) {
|
if (locationId) {
|
||||||
@@ -612,8 +593,6 @@ class PageCreate extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.editMode) {
|
if (this.editMode) {
|
||||||
// Update existing listing
|
|
||||||
// Add new images if any
|
|
||||||
if (newImageIds.length > 0) {
|
if (newImageIds.length > 0) {
|
||||||
listingData.images = {
|
listingData.images = {
|
||||||
create: newImageIds.map((id, index) => ({
|
create: newImageIds.map((id, index) => ({
|
||||||
@@ -626,7 +605,10 @@ class PageCreate extends HTMLElement {
|
|||||||
await directus.updateListing(this.editId, listingData)
|
await directus.updateListing(this.editId, listingData)
|
||||||
router.navigate(`/listing/${this.editId}`)
|
router.navigate(`/listing/${this.editId}`)
|
||||||
} else {
|
} else {
|
||||||
// Create new listing
|
// Save as draft first, then trigger payment
|
||||||
|
listingData.status = 'draft'
|
||||||
|
listingData.payment_status = 'unpaid'
|
||||||
|
|
||||||
if (newImageIds.length > 0) {
|
if (newImageIds.length > 0) {
|
||||||
listingData.images = {
|
listingData.images = {
|
||||||
create: newImageIds.map((id, index) => ({
|
create: newImageIds.map((id, index) => ({
|
||||||
@@ -638,9 +620,9 @@ class PageCreate extends HTMLElement {
|
|||||||
|
|
||||||
const listing = await directus.createListing(listingData)
|
const listing = await directus.createListing(listingData)
|
||||||
this.clearDraft()
|
this.clearDraft()
|
||||||
|
|
||||||
if (listing?.id) {
|
if (listing?.id) {
|
||||||
router.navigate(`/listing/${listing.id}`)
|
await this.startPayment(listing.id, formElements.currency)
|
||||||
} else {
|
} else {
|
||||||
router.navigate('/')
|
router.navigate('/')
|
||||||
}
|
}
|
||||||
@@ -652,12 +634,100 @@ class PageCreate extends HTMLElement {
|
|||||||
submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish')
|
submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish')
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
|
|
||||||
// Extract detailed error message
|
|
||||||
const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed')
|
const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed')
|
||||||
this.showError(errorMsg)
|
this.showError(errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startPayment(listingId, currency = 'EUR') {
|
||||||
|
const submitBtn = this.querySelector('[type="submit"]')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for existing pending invoice
|
||||||
|
const pending = getPendingInvoice(listingId)
|
||||||
|
let invoiceId = null
|
||||||
|
|
||||||
|
if (pending?.invoiceId) {
|
||||||
|
const status = await getInvoiceStatus(pending.invoiceId)
|
||||||
|
if (status.status === 'New') {
|
||||||
|
invoiceId = pending.invoiceId
|
||||||
|
} else if (status.status === 'Settled') {
|
||||||
|
await this.onPaymentSuccess(listingId)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
clearPendingInvoice(listingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoiceId) {
|
||||||
|
if (submitBtn) submitBtn.textContent = t('payment.paying')
|
||||||
|
const invoice = await createInvoice(listingId, currency)
|
||||||
|
invoiceId = invoice.invoiceId
|
||||||
|
savePendingInvoice(listingId, invoiceId)
|
||||||
|
|
||||||
|
await directus.updateListing(listingId, {
|
||||||
|
payment_status: 'pending',
|
||||||
|
btcpay_invoice_id: invoiceId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalStatus = await openCheckout(invoiceId)
|
||||||
|
|
||||||
|
if (modalStatus === 'complete' || modalStatus === 'paid') {
|
||||||
|
await this.onPaymentSuccess(listingId)
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'Settled') {
|
||||||
|
await this.onPaymentSuccess(listingId)
|
||||||
|
} else {
|
||||||
|
await directus.updateListing(listingId, { payment_status: 'expired' })
|
||||||
|
clearPendingInvoice(listingId)
|
||||||
|
this.showError(t('payment.expired'))
|
||||||
|
this.submitting = false
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false
|
||||||
|
submitBtn.textContent = t('create.publish')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment failed:', error)
|
||||||
|
this.showError(t('payment.failed'))
|
||||||
|
this.submitting = false
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false
|
||||||
|
submitBtn.textContent = t('create.publish')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPaymentSuccess(listingId) {
|
||||||
|
const days = 30
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + days)
|
||||||
|
|
||||||
|
await directus.updateListing(listingId, {
|
||||||
|
status: 'published',
|
||||||
|
payment_status: 'paid',
|
||||||
|
paid_at: new Date().toISOString(),
|
||||||
|
expires_at: expiresAt.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
clearPendingInvoice(listingId)
|
||||||
|
router.navigate(`/listing/${listingId}`)
|
||||||
|
}
|
||||||
|
|
||||||
showError(message) {
|
showError(message) {
|
||||||
let errorDiv = this.querySelector('.form-error')
|
let errorDiv = this.querySelector('.form-error')
|
||||||
if (!errorDiv) {
|
if (!errorDiv) {
|
||||||
|
|||||||
170
js/services/btcpay.js
Normal file
170
js/services/btcpay.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const POW_SERVER = 'https://pow.dgray.io'
|
||||||
|
|
||||||
|
let modalScriptLoaded = false
|
||||||
|
let modalScriptLoading = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load BTCPay modal checkout script once
|
||||||
|
*/
|
||||||
|
async function ensureModalLoaded() {
|
||||||
|
if (modalScriptLoaded) return
|
||||||
|
if (modalScriptLoading) return modalScriptLoading
|
||||||
|
|
||||||
|
modalScriptLoading = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'https://pay.xmr.rocks/modal/btcpay.js'
|
||||||
|
script.onload = () => {
|
||||||
|
modalScriptLoaded = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
script.onerror = () => reject(new Error('Failed to load BTCPay checkout'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
|
||||||
|
return modalScriptLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a payment invoice for a listing
|
||||||
|
* @param {string} listingId - The listing UUID
|
||||||
|
* @param {string} [currency='EUR'] - Payment currency
|
||||||
|
* @returns {Promise<Object>} { invoiceId, checkoutLink, status, expirationTime }
|
||||||
|
*/
|
||||||
|
export async function createInvoice(listingId, currency = 'EUR') {
|
||||||
|
const response = await fetch(`${POW_SERVER}/btcpay/invoice`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ listingId, currency })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to create invoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check invoice payment status
|
||||||
|
* @param {string} invoiceId
|
||||||
|
* @returns {Promise<Object>} { invoiceId, status, additionalStatus }
|
||||||
|
*/
|
||||||
|
export async function getInvoiceStatus(invoiceId) {
|
||||||
|
const response = await fetch(`${POW_SERVER}/btcpay/status?id=${encodeURIComponent(invoiceId)}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to check invoice status')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the BTCPay modal checkout
|
||||||
|
* @param {string} invoiceId
|
||||||
|
* @returns {Promise<string>} Final status when modal closes
|
||||||
|
*/
|
||||||
|
export async function openCheckout(invoiceId) {
|
||||||
|
await ensureModalLoaded()
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let lastStatus = null
|
||||||
|
|
||||||
|
window.btcpay.onModalReceiveMessage((msg) => {
|
||||||
|
if (msg?.status) lastStatus = msg.status
|
||||||
|
})
|
||||||
|
|
||||||
|
window.btcpay.onModalWillLeave(() => {
|
||||||
|
resolve(lastStatus || 'unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
window.btcpay.showInvoice(invoiceId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll invoice status until settled, expired, or timeout
|
||||||
|
* @param {string} invoiceId
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {number} [options.interval=4000] - Poll interval in ms
|
||||||
|
* @param {number} [options.timeout=900000] - Timeout in ms (default 15min)
|
||||||
|
* @param {Function} [options.onUpdate] - Callback on status change
|
||||||
|
* @returns {Promise<Object>} Final status object
|
||||||
|
*/
|
||||||
|
export async function pollUntilDone(invoiceId, options = {}) {
|
||||||
|
const {
|
||||||
|
interval = 4000,
|
||||||
|
timeout = 900000,
|
||||||
|
onUpdate = null
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
let lastStatus = null
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const result = await getInvoiceStatus(invoiceId)
|
||||||
|
|
||||||
|
if (result.status !== lastStatus) {
|
||||||
|
lastStatus = result.status
|
||||||
|
if (onUpdate) onUpdate(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['Settled', 'Expired', 'Invalid'].includes(result.status)) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, interval))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Payment check timed out')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a listing has a pending invoice stored locally
|
||||||
|
* @param {string} listingId
|
||||||
|
* @returns {Object|null} { invoiceId, createdAt }
|
||||||
|
*/
|
||||||
|
export function getPendingInvoice(listingId) {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(`dgray_invoice_${listingId}`)
|
||||||
|
return data ? JSON.parse(data) : null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a pending invoice reference locally
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} invoiceId
|
||||||
|
*/
|
||||||
|
export function savePendingInvoice(listingId, invoiceId) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`dgray_invoice_${listingId}`, JSON.stringify({
|
||||||
|
invoiceId,
|
||||||
|
createdAt: Date.now()
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
// Storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove pending invoice reference
|
||||||
|
* @param {string} listingId
|
||||||
|
*/
|
||||||
|
export function clearPendingInvoice(listingId) {
|
||||||
|
localStorage.removeItem(`dgray_invoice_${listingId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createInvoice,
|
||||||
|
getInvoiceStatus,
|
||||||
|
openCheckout,
|
||||||
|
pollUntilDone,
|
||||||
|
getPendingInvoice,
|
||||||
|
savePendingInvoice,
|
||||||
|
clearPendingInvoice
|
||||||
|
}
|
||||||
@@ -308,5 +308,20 @@
|
|||||||
"about": "Über",
|
"about": "Über",
|
||||||
"currency": "Währung",
|
"currency": "Währung",
|
||||||
"currencyChanged": "Währung geändert"
|
"currencyChanged": "Währung geändert"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"title": "Zahlung",
|
||||||
|
"listingFee": "Anzeigengebühr",
|
||||||
|
"feeInfo": "1 Anzeige = 1 Monat = {{amount}} {{currency}}",
|
||||||
|
"payNow": "Jetzt bezahlen",
|
||||||
|
"paying": "Zahlung wird verarbeitet...",
|
||||||
|
"processing": "Zahlung eingegangen, warte auf Bestätigung...",
|
||||||
|
"success": "Zahlung erfolgreich! Deine Anzeige ist jetzt online.",
|
||||||
|
"expired": "Zahlung abgelaufen. Bitte versuche es erneut.",
|
||||||
|
"failed": "Zahlung fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
|
"resume": "Zahlung fortsetzen",
|
||||||
|
"pending": "Zahlung ausstehend",
|
||||||
|
"required": "Zum Veröffentlichen ist eine Gebühr von {{amount}} {{currency}} erforderlich.",
|
||||||
|
"paidViaXmr": "Bezahlt via Monero (XMR)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,5 +308,20 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"currencyChanged": "Currency changed"
|
"currencyChanged": "Currency changed"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"title": "Payment",
|
||||||
|
"listingFee": "Listing Fee",
|
||||||
|
"feeInfo": "1 listing = 1 month = {{amount}} {{currency}}",
|
||||||
|
"payNow": "Pay Now",
|
||||||
|
"paying": "Processing payment...",
|
||||||
|
"processing": "Payment received, waiting for confirmation...",
|
||||||
|
"success": "Payment successful! Your listing is now live.",
|
||||||
|
"expired": "Payment expired. Please try again.",
|
||||||
|
"failed": "Payment failed. Please try again.",
|
||||||
|
"resume": "Resume payment",
|
||||||
|
"pending": "Payment pending",
|
||||||
|
"required": "A fee of {{amount}} {{currency}} is required to publish.",
|
||||||
|
"paidViaXmr": "Paid via Monero (XMR)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,5 +308,20 @@
|
|||||||
"about": "À propos",
|
"about": "À propos",
|
||||||
"currency": "Devise",
|
"currency": "Devise",
|
||||||
"currencyChanged": "Devise modifiée"
|
"currencyChanged": "Devise modifiée"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"title": "Paiement",
|
||||||
|
"listingFee": "Frais d'annonce",
|
||||||
|
"feeInfo": "1 annonce = 1 mois = {{amount}} {{currency}}",
|
||||||
|
"payNow": "Payer maintenant",
|
||||||
|
"paying": "Traitement du paiement...",
|
||||||
|
"processing": "Paiement reçu, en attente de confirmation...",
|
||||||
|
"success": "Paiement réussi ! Votre annonce est maintenant en ligne.",
|
||||||
|
"expired": "Paiement expiré. Veuillez réessayer.",
|
||||||
|
"failed": "Paiement échoué. Veuillez réessayer.",
|
||||||
|
"resume": "Reprendre le paiement",
|
||||||
|
"pending": "Paiement en attente",
|
||||||
|
"required": "Des frais de {{amount}} {{currency}} sont requis pour publier.",
|
||||||
|
"paidViaXmr": "Payé via Monero (XMR)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'dgray-v41';
|
const CACHE_NAME = 'dgray-v42';
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -29,6 +29,7 @@ const STATIC_ASSETS = [
|
|||||||
'/js/services/crypto.js',
|
'/js/services/crypto.js',
|
||||||
'/js/services/currency.js',
|
'/js/services/currency.js',
|
||||||
'/js/services/pow-captcha.js',
|
'/js/services/pow-captcha.js',
|
||||||
|
'/js/services/btcpay.js',
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
'/js/components/app-shell.js',
|
'/js/components/app-shell.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user