From 96a108db5ca97fe2685921cc42f10d81c871882e Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:07:03 +0530 Subject: [PATCH 1/8] feat: add razorpay --- biome.json | 3 +- client/components/razorpayCheckout/index.js | 197 +++++++++ client/pages/FAQs/index.js | 26 +- client/pages/changePassword/index.js | 36 +- client/pages/plugin/index.js | 82 ++-- client/pages/plugin/style.scss | 71 ++++ client/pages/registerUser/index.js | 8 +- client/pages/user/index.js | 26 +- package.json | 4 +- server/apis/plugin.js | 59 ++- server/apis/razorpay.js | 398 ++++++++++++++++++ server/entities/purchaseOrder.js | 12 +- server/main.js | 1 + .../migrations/add_purchase_order_columns.js | 50 +++ server/routes/apis.js | 1 + 15 files changed, 886 insertions(+), 88 deletions(-) create mode 100644 client/components/razorpayCheckout/index.js create mode 100644 server/apis/razorpay.js create mode 100644 server/migrations/add_purchase_order_columns.js diff --git a/biome.json b/biome.json index a0f9afd..f87a2ec 100644 --- a/biome.json +++ b/biome.json @@ -29,8 +29,7 @@ "noDelete": "warn" }, "complexity": { - "noForEach": "warn", - "noImportantStyles": "off" + "noForEach": "warn" }, "suspicious": { "noConsole": { "level": "error", "options": { "allow": ["error"] } } diff --git a/client/components/razorpayCheckout/index.js b/client/components/razorpayCheckout/index.js new file mode 100644 index 0000000..fdbcfa5 --- /dev/null +++ b/client/components/razorpayCheckout/index.js @@ -0,0 +1,197 @@ +/** + * Razorpay Checkout Component + * Handles web-based plugin purchases via Razorpay payment gateway + */ + +import alert from 'components/dialogs/alert'; +import Ref from 'html-tag-js/ref'; + +// Load Razorpay script dynamically +let razorpayLoaded = false; +function loadRazorpayScript() { + return new Promise((resolve, reject) => { + if (razorpayLoaded) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + script.onload = () => { + razorpayLoaded = true; + resolve(); + }; + script.onerror = () => reject(new Error('Failed to load Razorpay script')); + document.head.appendChild(script); + }); +} + +/** + * Check if user owns a plugin + * @param {string} pluginId + * @returns {Promise} + */ +export async function checkPluginOwnership(pluginId) { + try { + const res = await fetch(`/api/razorpay/check-ownership/${pluginId}`); + const data = await res.json(); + return data.owned === true; + } catch { + return false; + } +} + +/** + * Razorpay checkout configuration + */ +const RAZORPAY_CONFIG = { + theme: { + color: '#2563eb', + backdrop_color: 'rgba(15, 23, 42, 0.8)', + }, + branding: { + name: 'Acode Plugin Store', + image: '/logo-512.png', + }, +}; + +/** + * Initiate Razorpay checkout for a plugin + * @param {string} pluginId + * @param {Object} userInfo - User information for prefill + * @param {string} [userInfo.email] - User's email address + * @param {string} [userInfo.name] - User's name + * @param {Function} onSuccess - Callback on successful payment + * @returns {Promise} + */ +export async function initiateCheckout(pluginId, userInfo = {}, onSuccess) { + try { + // Load Razorpay script if not already loaded + await loadRazorpayScript(); + + // Create order on server + const orderRes = await fetch('/api/razorpay/create-order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId }), + }); + + const orderData = await orderRes.json(); + + if (orderData.error) { + alert('ERROR', orderData.error); + return; + } + + const { orderId, amount, currency, keyId, pluginName, userEmail } = orderData; + + // Open Razorpay checkout with customization + const options = { + key: keyId, + amount, + currency, + name: RAZORPAY_CONFIG.branding.name, + description: `Purchase: ${pluginName}`, + image: RAZORPAY_CONFIG.branding.image, + order_id: orderId, + handler: async (response) => { + // Verify payment on server + const verifyRes = await fetch('/api/razorpay/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + razorpay_order_id: response.razorpay_order_id, + razorpay_payment_id: response.razorpay_payment_id, + razorpay_signature: response.razorpay_signature, + pluginId, + }), + }); + + const verifyData = await verifyRes.json(); + + if (verifyData.success) { + alert('SUCCESS', 'Payment successful! You can now download this plugin.'); + if (onSuccess) onSuccess(); + } else { + alert('ERROR', verifyData.error || 'Payment verification failed'); + } + }, + // Prefill user information (email preferred over contact for web) + prefill: { + email: userInfo.email || userEmail || '', + name: userInfo.name || '', + // Note: contact (phone) is required by Razorpay for Indian regulations + // but email will be shown as primary identifier + }, + // Theme customization + theme: { + color: RAZORPAY_CONFIG.theme.color, + backdrop_color: RAZORPAY_CONFIG.theme.backdrop_color, + }, + // Modal behavior + modal: { + confirm_close: true, // Ask before closing + escape: true, // Allow ESC to close + animation: true, // Enable animations + ondismiss: () => { + // Checkout closed by user + }, + }, + // Additional checkout preferences + notes: { + pluginId, + source: 'acode_web', + }, + }; + + const rzp = new window.Razorpay(options); + rzp.on('payment.failed', (response) => { + alert('ERROR', `Payment failed: ${response.error.description}`); + }); + rzp.open(); + } catch (error) { + console.error('Checkout error:', error); + alert('ERROR', error.message || 'Failed to initiate checkout'); + } +} + +/** + * Buy Button Component for paid plugins + * @param {Object} props + * @param {string} props.pluginId - Plugin ID + * @param {number} props.price - Plugin price in INR + * @param {Object} [props.user] - Logged in user object + * @param {Function} [props.onPurchaseComplete] - Callback after successful purchase + * @returns {HTMLElement} + */ +export default function BuyButton({ pluginId, price, user, onPurchaseComplete }) { + const buttonRef = Ref(); + const buttonTextRef = Ref(); + + const handleClick = async () => { + buttonRef.el.disabled = true; + buttonTextRef.el.textContent = 'Processing...'; + + // Pass user info for email prefill + const userInfo = user ? { email: user.email, name: user.name } : {}; + await initiateCheckout(pluginId, userInfo, () => { + buttonTextRef.el.textContent = 'Purchased ✓'; + buttonRef.el.disabled = true; + if (onPurchaseComplete) onPurchaseComplete(); + }); + + // Reset button if payment was cancelled + if (buttonTextRef.el.textContent === 'Processing...') { + buttonTextRef.el.textContent = `Buy ₹${price}`; + buttonRef.el.disabled = false; + } + }; + + return ( + + ); +} diff --git a/client/pages/FAQs/index.js b/client/pages/FAQs/index.js index a60d99e..a67a7bb 100644 --- a/client/pages/FAQs/index.js +++ b/client/pages/FAQs/index.js @@ -69,13 +69,11 @@ export default async function FAQs({ mode, oldQ, a, qHash }) {
- {isUpdate ? ( - - ) : ( - '' - )} + {isUpdate + ? + : ''}
@@ -123,14 +121,12 @@ export default async function FAQs({ mode, oldQ, a, qHash }) {

- {isAdmin ? ( -

- editFaq(q, ans)} title='Edit this FAQ' className='link icon create' /> - deleteFaq(q)} title='Delete this FAQ' className='link icon delete danger' /> -
- ) : ( - '' - )} + {isAdmin + ?
+ editFaq(q, ans)} title='Edit this FAQ' className='link icon create' /> + deleteFaq(q)} title='Delete this FAQ' className='link icon delete danger' /> +
+ : ''}
); } diff --git a/client/pages/changePassword/index.js b/client/pages/changePassword/index.js index b03e5e7..046a6b9 100644 --- a/client/pages/changePassword/index.js +++ b/client/pages/changePassword/index.js @@ -32,25 +32,23 @@ export default async function changePassword({ mode, redirect }) { loading={(form) => loadingStart(form, errorText, successText)} loadingEnd={(form) => loadingEnd(form, 'Change password')} > - {mode === 'reset' ? ( -
-
- { - email = e.target.value; - }} - type='email' - name='email' - label='Email' - placeholder='e.g. john@gmail.com' - /> - -
- email} /> -
- ) : ( - - )} + {mode === 'reset' + ?
+
+ { + email = e.target.value; + }} + type='email' + name='email' + label='Email' + placeholder='e.g. john@gmail.com' + /> + +
+ email} /> +
+ : } {errorText} {successText} diff --git a/client/pages/plugin/index.js b/client/pages/plugin/index.js index 0b87854..e255583 100644 --- a/client/pages/plugin/index.js +++ b/client/pages/plugin/index.js @@ -7,6 +7,7 @@ import confirm from 'components/dialogs/confirm'; import prompt from 'components/dialogs/prompt'; import Input from 'components/input'; import MonthSelect from 'components/MonthSelect'; +import BuyButton, { checkPluginOwnership } from 'components/razorpayCheckout'; import YearSelect from 'components/YearSelect'; import hilightjs from 'highlight.js'; import Ref from 'html-tag-js/ref'; @@ -64,6 +65,12 @@ export default async function Plugin({ id: pluginId, section = 'description' }) const shouldShowOrders = user && (user.id === userId || user.isAdmin) && !!plugin.price; let canInstall = /android/i.test(navigator.userAgent); + let userOwnsPlugin = false; + + // Check if logged-in user owns this paid plugin (for web purchases) + if (user && price > 0) { + userOwnsPlugin = await checkPluginOwnership(id); + } if (user?.isAdmin && plugin.status !== 'approved') { canInstall = false; @@ -87,6 +94,35 @@ export default async function Plugin({ id: pluginId, section = 'description' }) table.replaceWith(
{table.cloneNode(true)}
); } + function renderPurchaseButton() { + if (userOwnsPlugin) { + return ( +
+ + Purchased +
+ ); + } + if (user) { + return ( + { + window.location.reload(); + }} + /> + ); + } + return ( + + + Login to Purchase + + ); + } + return (
@@ -105,25 +141,21 @@ export default async function Plugin({ id: pluginId, section = 'description' })
v {version} - {+downloads ? ( -
- - {downloads.toLocaleString()} -
- ) : ( -
- New -
- )} + {+downloads + ?
+ + {downloads.toLocaleString()} +
+ :
+ New +
}
- {price ? ( - <> - - {price} - - ) : ( - Free - )} + {price + ? <> + + {price} + + : Free}
{commentCount > 0 && (
changeSection('comments')}> @@ -160,6 +192,8 @@ export default async function Plugin({ id: pluginId, section = 'description' }) )}
+ {/* Payment Section for paid plugins - placed after plugin info */} + {price > 0 && !canInstall &&
{renderPurchaseButton()}
}
@@ -406,13 +440,11 @@ function CommentsContainerAndForm({ plugin, listRef, user, id, userComment }) {
- {commentId ? ( - - ) : ( - '' - )} + {commentId + ? + : ''}
diff --git a/client/pages/plugin/style.scss b/client/pages/plugin/style.scss index 748be66..a88795f 100644 --- a/client/pages/plugin/style.scss +++ b/client/pages/plugin/style.scss @@ -40,6 +40,77 @@ margin: 0 10px; } } + + .purchase-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + } + + .owned-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border-radius: 8px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); + + .icon { + font-size: 1.2em; + } + } + + .login-to-buy { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); + color: white; + border-radius: 8px; + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); + } + } + + .buy-button { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + } } } diff --git a/client/pages/registerUser/index.js b/client/pages/registerUser/index.js index a58ab23..5e5cc9b 100644 --- a/client/pages/registerUser/index.js +++ b/client/pages/registerUser/index.js @@ -53,11 +53,9 @@ export default async function registerUser({ mode, redirect }) { - {mode === 'edit' ? ( - Change password - ) : ( - - )} + {mode === 'edit' + ? Change password + : }
{errorText}
{successText}
diff --git a/client/pages/user/index.js b/client/pages/user/index.js index 07448ab..8c9577a 100644 --- a/client/pages/user/index.js +++ b/client/pages/user/index.js @@ -69,22 +69,18 @@ export default async function User({ userId }) {
- {shouldShowSensitiveInfo ? ( - - |{moment().format('YYYY MMMM')} - - ) : ( - '' - )} + {shouldShowSensitiveInfo + ? + |{moment().format('YYYY MMMM')} + + : ''}
- {isSelf ? ( -
- - Payment method -
- ) : ( - '' - )} + {isSelf + ?
+ + Payment method +
+ : ''}
e.target.dataset.href && Router.loadUrl(e.target.dataset.href)}> - {isUpdate - ? - : ''} + {isUpdate ? ( + + ) : ( + '' + )}
@@ -121,12 +123,14 @@ export default async function FAQs({ mode, oldQ, a, qHash }) {

- {isAdmin - ?

- editFaq(q, ans)} title='Edit this FAQ' className='link icon create' /> - deleteFaq(q)} title='Delete this FAQ' className='link icon delete danger' /> -
- : ''} + {isAdmin ? ( +
+ editFaq(q, ans)} title='Edit this FAQ' className='link icon create' /> + deleteFaq(q)} title='Delete this FAQ' className='link icon delete danger' /> +
+ ) : ( + '' + )} ); } diff --git a/client/pages/admin/index.js b/client/pages/admin/index.js index 4fb1849..ea5996b 100644 --- a/client/pages/admin/index.js +++ b/client/pages/admin/index.js @@ -18,6 +18,7 @@ export default async function Admin() {

Admin Panel

+
@@ -70,6 +71,69 @@ function Card({ title, text, icon, onclick }) { ); } +function AppSettings() { + const priceRef = Ref(); + const statusRef = Ref(); + + (async () => { + try { + const res = await fetch('/api/admin/config'); + const config = await res.json(); + if (priceRef.el) { + priceRef.el.value = config.acode_pro_price || '370'; + } + } catch { + if (statusRef.el) { + statusRef.el.textContent = 'Failed to load config'; + } + } + })(); + + const onSave = async () => { + const price = priceRef.el.value; + const numPrice = Number(price); + if (Number.isNaN(numPrice) || numPrice <= 0) { + alert('ERROR', 'Price must be a positive number'); + return; + } + + try { + const res = await fetch('/api/admin/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'acode_pro_price', value: price }), + }); + const json = await res.json(); + if (json.error) { + alert('ERROR', json.error); + } else { + statusRef.el.textContent = 'Saved!'; + setTimeout(() => { + if (statusRef.el) statusRef.el.textContent = ''; + }, 2000); + } + } catch { + alert('ERROR', 'Failed to save config'); + } + }; + + return ( +
+

App Settings

+
+ +
+ + + +
+
+
+ ); +} + function Users() { const currentPage = Reactive(0); const totalPages = Reactive(1); diff --git a/client/pages/admin/style.scss b/client/pages/admin/style.scss index 6d04ddc..b0845f3 100644 --- a/client/pages/admin/style.scss +++ b/client/pages/admin/style.scss @@ -86,6 +86,60 @@ overflow: auto; } + .app-settings { + margin-top: 32px; + max-width: 600px; + + h2 { + margin: 0 0 16px; + } + + .setting-row { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-weight: 600; + } + + .setting-input { + display: flex; + align-items: center; + gap: 10px; + + input { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.2); + background-color: var(--secondary-color); + color: inherit; + font-size: 1em; + width: 120px; + } + + button { + padding: 8px 20px; + border-radius: 8px; + background-color: var(--primary-color); + color: #fff; + border: none; + font-size: 0.9em; + cursor: pointer; + + &:hover { + opacity: 0.9; + } + } + + .status { + color: lightgreen; + font-size: 0.9em; + } + } + } + } + .email-users { margin-top: 32px; diff --git a/client/pages/changePassword/index.js b/client/pages/changePassword/index.js index 046a6b9..b03e5e7 100644 --- a/client/pages/changePassword/index.js +++ b/client/pages/changePassword/index.js @@ -32,23 +32,25 @@ export default async function changePassword({ mode, redirect }) { loading={(form) => loadingStart(form, errorText, successText)} loadingEnd={(form) => loadingEnd(form, 'Change password')} > - {mode === 'reset' - ?
-
- { - email = e.target.value; - }} - type='email' - name='email' - label='Email' - placeholder='e.g. john@gmail.com' - /> - -
- email} /> -
- : } + {mode === 'reset' ? ( +
+
+ { + email = e.target.value; + }} + type='email' + name='email' + label='Email' + placeholder='e.g. john@gmail.com' + /> + +
+ email} /> +
+ ) : ( + + )} {errorText} {successText} diff --git a/client/pages/plugin/index.js b/client/pages/plugin/index.js index bf95fd6..cf9fd09 100644 --- a/client/pages/plugin/index.js +++ b/client/pages/plugin/index.js @@ -7,8 +7,8 @@ import confirm from 'components/dialogs/confirm'; import prompt from 'components/dialogs/prompt'; import Input from 'components/input'; import MonthSelect from 'components/MonthSelect'; -import BuyButton, { checkPluginOwnership } from 'components/razorpayCheckout'; import PluginStatus from 'components/pluginStatus'; +import BuyButton, { checkPluginOwnership } from 'components/razorpayCheckout'; import YearSelect from 'components/YearSelect'; import hilightjs from 'highlight.js'; import Ref from 'html-tag-js/ref'; @@ -75,10 +75,19 @@ export default async function Plugin({ id: pluginId, section = 'description' }) let canInstall = /android/i.test(navigator.userAgent); let userOwnsPlugin = false; + let purchaseInfo = null; // Check if logged-in user owns this paid plugin (for web purchases) if (user && price > 0) { userOwnsPlugin = await checkPluginOwnership(id); + if (userOwnsPlugin) { + try { + const purchases = await fetch('/api/razorpay/my-purchases').then((r) => r.json()); + purchaseInfo = purchases.find((p) => p.id === id); + } catch (err) { + console.error('Failed to fetch purchase info:', err); + } + } } if (user?.isAdmin && plugin.status !== 'approved') { @@ -103,32 +112,105 @@ export default async function Plugin({ id: pluginId, section = 'description' }) table.replaceWith(
{table.cloneNode(true)}
); } - function renderPurchaseButton() { + function renderPurchaseSection() { if (userOwnsPlugin) { + const refundHandler = async (e) => { + const ok = await confirm('REFUND', 'Are you sure you want to refund this plugin? This action cannot be undone.'); + if (!ok) return; + const btn = e.target.closest('.refund-button'); + btn.disabled = true; + btn.querySelector('span:last-child').textContent = 'Processing...'; + try { + const res = await fetch('/api/razorpay/refund-plugin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId: purchaseInfo.purchaseOrderId }), + }); + const data = await res.json(); + if (data.success) { + window.location.reload(); + } else { + await alert('ERROR', data.error || 'Refund failed'); + btn.disabled = false; + btn.querySelector('span:last-child').textContent = 'Request Refund'; + } + } catch (err) { + console.error('Plugin refund error:', err); + await alert('ERROR', 'Failed to process refund. Please try again.'); + btn.disabled = false; + btn.querySelector('span:last-child').textContent = 'Request Refund'; + } + }; + return ( -
- - Purchased +
+
+
+ + You own this plugin +
+ {purchaseInfo && ( +
+ Paid ₹{purchaseInfo.purchaseAmount} + · + {moment(purchaseInfo.purchasedAt).format('DD MMM YYYY')} + · + {purchaseInfo.purchaseProvider === 'razorpay' ? 'Razorpay' : 'Google Play'} +
+ )} +
+ {purchaseInfo?.refundEligible && ( + + )}
); } + if (user) { return ( - { - window.location.reload(); - }} - /> +
+
+
+ + {price} +
+
+ One-time purchase + · + Instant access +
+
+ { + window.location.reload(); + }} + /> +
); } + return ( - - - Login to Purchase - +
+
+
+ + {price} +
+
+ One-time purchase +
+
+ + + Login to Purchase + +
); } @@ -151,21 +233,25 @@ export default async function Plugin({ id: pluginId, section = 'description' })
v {version} - {+downloads - ?
- - {downloads.toLocaleString()} -
- :
- New -
} + {+downloads ? ( +
+ + {downloads.toLocaleString()} +
+ ) : ( +
+ New +
+ )}
- {price - ? <> - - {price} - - : Free} + {price ? ( + <> + + {price} + + ) : ( + Free + )}
{commentCount > 0 && (
changeSection('comments')}> @@ -207,8 +293,21 @@ export default async function Plugin({ id: pluginId, section = 'description' }) )}
- {/* Payment Section for paid plugins - placed after plugin info */} - {price > 0 && !canInstall &&
{renderPurchaseButton()}
} + {/* Payment Section for paid plugins */} + {price > 0 && (!canInstall || userOwnsPlugin) && renderPurchaseSection()} + {price > 0 && canInstall && !userOwnsPlugin && ( +
+
+
+ + {price} +
+
+ Purchase this plugin from within the Acode app +
+
+
+ )}
@@ -292,13 +391,14 @@ export default async function Plugin({ id: pluginId, section = 'description' }) Date Package + Provider Amount Status - Loading... + Loading... @@ -315,16 +415,30 @@ async function renderOrders(ref, pluginId, year, month) { const url = `/api/plugin/orders/${pluginId}/${year}/${month}`; const orders = await fetch(url).then((res) => res.json()); + if (!orders.length) { + ref.append( + + + No orders for this period + + , + ); + return; + } + for (const order of orders) { const date = moment(order.created_at).format('DD MMMM YYYY'); - const status = Number(order.state) === 0 ? 'Completed' : 'Cancelled'; + const statusLabel = Number(order.state) === 0 ? 'Completed' : 'Cancelled'; + const statusClass = statusLabel.toLowerCase(); const packageName = /free$/.test(order.package) ? 'Free' : 'Paid'; + const provider = order.provider === 'razorpay' ? 'Razorpay' : 'Google Play'; ref.append( {date} {packageName} + {provider} ₹ {order.amount.toFixed(2)} - {status} + {statusLabel} , ); } @@ -334,7 +448,7 @@ async function renderComments(ref, pluginUserId, user, id, author) { const comments = await fetch(`/api/comments/${id}`).then((res) => res.json()); for (const comment of comments) { - if (!comment.comment) confirm; + if (!comment.comment) continue; comment.user = user; comment.pluginUserId = pluginUserId; comment.pluginAuthor = author; @@ -456,11 +570,13 @@ function CommentsContainerAndForm({ plugin, listRef, user, id, userComment }) {
- {commentId - ? - : ''} + {commentId ? ( + + ) : ( + '' + )}
diff --git a/client/pages/plugin/style.scss b/client/pages/plugin/style.scss index 4258f50..eb40cf7 100644 --- a/client/pages/plugin/style.scss +++ b/client/pages/plugin/style.scss @@ -70,19 +70,121 @@ } } - .owned-badge { - display: inline-flex; + .purchase-card { + display: flex; align-items: center; - gap: 6px; - padding: 10px 20px; - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; - border-radius: 8px; - font-weight: 600; - box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 16px 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + + &.purchased { + background: rgba(16, 185, 129, 0.06); + border-color: rgba(16, 185, 129, 0.2); + } - .icon { - font-size: 1.2em; + .purchase-card-main { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .purchase-card-badge { + display: flex; + align-items: center; + gap: 8px; + color: #10b981; + font-weight: 600; + font-size: 15px; + + .icon { + font-size: 1.1em; + } + } + + .purchase-card-price { + display: flex; + align-items: baseline; + gap: 2px; + + .currency { + font-size: 18px; + font-weight: 500; + opacity: 0.7; + } + + .amount { + font-size: 28px; + font-weight: 700; + } + } + + .purchase-card-details { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + opacity: 0.55; + flex-wrap: wrap; + + .dot { + font-weight: 700; + } + } + + .refund-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: transparent; + color: #f87171; + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 8px; + font-weight: 500; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + + .icon { + font-size: 1em; + } + + &:hover { + background: rgba(248, 113, 113, 0.1); + border-color: rgba(248, 113, 113, 0.5); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + @media (max-width: 500px) { + flex-direction: column; + align-items: stretch; + text-align: center; + + .purchase-card-main { + align-items: center; + } + + .purchase-card-details { + justify-content: center; + } + + .refund-button, + .buy-button, + .login-to-buy { + justify-content: center; + width: 100%; + } } } diff --git a/client/pages/pro/index.js b/client/pages/pro/index.js new file mode 100644 index 0000000..138c6fd --- /dev/null +++ b/client/pages/pro/index.js @@ -0,0 +1,183 @@ +import './style.scss'; +import alert from 'components/dialogs/alert'; +import confirm from 'components/dialogs/confirm'; +import { initiateProCheckout } from 'components/razorpayCheckout'; +import Ref from 'html-tag-js/ref'; +import { getLoggedInUser, hideLoading, showLoading } from 'lib/helpers'; + +export default async function Pro() { + const statusRef = Ref(); + const loggedInUser = await getLoggedInUser(); + + showLoading(); + let proStatus; + try { + const res = await fetch('/api/razorpay/pro-status'); + proStatus = await res.json(); + } catch { + proStatus = { isPro: false, price: 370, refundEligible: false }; + } + hideLoading(); + + const { isPro, price, refundEligible, purchasedAt } = proStatus; + + return ( +
+
+ One-time purchase +

+ Support Acode +

+

+ Acode is a free, open-source code editor built for mobile developers. Your support keeps the project alive, independent, and growing. +

+
+ +
+
+
+ +
+

Ad-free experience

+

Code without interruptions or distractions

+
+
+
+ +
+

Exclusive themes

+

Access premium editor themes and color schemes

+
+
+
+ +
+

Supporter badge

+

A Pro badge on your profile to show your support

+
+
+ +
+ {renderAction()} +
+ +
+

100% of proceeds go to the maintainers of this open-source project.

+

Refunds available within 2 hours of purchase — no questions asked.

+
+
+ ); + + function renderAction() { + if (!loggedInUser) { + return ( +
+
+ + {price} +
+ + Login to Purchase + +
+ ); + } + + if (isPro) { + return ( +
+
+ + You have Acode Pro +
+

Thank you for supporting open source!

+ {refundEligible && ( +
+

+ Purchased {formatTimeAgo(purchasedAt)} · Refund window closes in {formatTimeRemaining(purchasedAt)} +

+ +
+ )} +
+ ); + } + + return ( +
+
+ + {price} +
+ +

One-time payment. No subscriptions.

+
+ ); + } + + async function handleBuy() { + const btn = statusRef.el.get('.btn-buy'); + if (btn) btn.disabled = true; + + const userInfo = { email: loggedInUser.email, name: loggedInUser.name }; + await initiateProCheckout( + userInfo, + () => { + window.location.reload(); + }, + () => { + if (btn) btn.disabled = false; + }, + ); + } + + async function handleRefund() { + const confirmed = await confirm('Refund Acode Pro', 'Are you sure you want to refund? You will lose all Pro features.'); + if (!confirmed) return; + + showLoading(); + try { + const res = await fetch('/api/razorpay/refund-pro', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + + if (data.success) { + window.location.reload(); + } else { + alert('Error', data.error || 'Failed to process refund'); + } + } catch (error) { + alert('Error', error.message || 'Failed to process refund'); + } finally { + hideLoading(); + } + } + + function formatTimeAgo(dateStr) { + if (!dateStr) return ''; + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m ago`; + } + + function formatTimeRemaining(dateStr) { + if (!dateStr) return ''; + const twoHours = 2 * 60 * 60 * 1000; + const remaining = twoHours - (Date.now() - new Date(dateStr).getTime()); + if (remaining <= 0) return '0m'; + const hours = Math.floor(remaining / 3600000); + const minutes = Math.floor((remaining % 3600000) / 60000); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + } +} diff --git a/client/pages/pro/style.scss b/client/pages/pro/style.scss new file mode 100644 index 0000000..01c177f --- /dev/null +++ b/client/pages/pro/style.scss @@ -0,0 +1,276 @@ +#pro-page { + max-width: 800px; + margin: 0 auto; + padding: 60px 24px 40px; + + .pro-hero { + text-align: center; + margin-bottom: 50px; + + .pro-badge-label { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.25); + border-radius: 20px; + padding: 6px 16px; + margin-bottom: 20px; + } + + h1 { + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 800; + line-height: 1.1; + margin-bottom: 16px; + letter-spacing: -0.02em; + + .highlight { + background: linear-gradient(135deg, #f59e0b, #f97316); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + } + + .subtitle { + color: rgba(255, 255, 255, 0.6); + font-size: 1.1rem; + line-height: 1.7; + max-width: 520px; + margin: 0 auto; + } + } + + .pro-perks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 50px; + + .perk-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 28px 20px; + text-align: center; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(245, 158, 11, 0.3); + transform: translateY(-4px); + } + + .perk-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: rgba(245, 158, 11, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + + .icon { + font-size: 1.3rem; + color: #f59e0b; + } + } + + h3 { + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 8px; + color: rgba(255, 255, 255, 0.9); + } + + p { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + line-height: 1.5; + margin: 0; + } + } + + @media screen and (max-width: 600px) { + grid-template-columns: 1fr; + gap: 12px; + + .perk-card { + display: flex; + align-items: center; + text-align: left; + padding: 20px; + gap: 16px; + + .perk-icon { + margin: 0; + flex-shrink: 0; + } + + h3 { + margin-bottom: 4px; + } + } + } + } + + .pro-action { + text-align: center; + margin-bottom: 40px; + + .price-tag { + display: flex; + align-items: baseline; + justify-content: center; + gap: 2px; + margin-bottom: 20px; + + .currency { + font-size: 1.5rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + } + + .amount { + font-size: 3.5rem; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1; + } + } + + .btn-buy { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px 40px; + border: none; + border-radius: 12px; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + background: linear-gradient(135deg, #f59e0b, #f97316); + color: #000; + transition: all 0.3s ease; + box-shadow: 0 4px 20px rgba(245, 158, 11, 0.3); + + .icon { + font-size: 1.2rem; + margin: 0; + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 28px rgba(245, 158, 11, 0.4); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + } + + .btn.primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 14px 40px; + border: none; + border-radius: 12px; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + text-decoration: none; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + opacity: 0.9; + } + } + + .one-time { + margin-top: 12px; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); + } + + .pro-active { + .active-badge { + display: inline-flex; + align-items: center; + gap: 10px; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + padding: 14px 28px; + border-radius: 12px; + font-size: 1.1rem; + font-weight: 700; + + .icon { + color: #f59e0b; + font-size: 1.4rem; + } + } + + .thank-you { + margin-top: 12px; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.5); + } + + .refund-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + + .refund-info { + color: rgba(255, 255, 255, 0.4); + font-size: 0.85rem; + margin-bottom: 12px; + } + + .btn-refund { + background: transparent; + color: orangered; + border: 1px solid rgba(255, 69, 0, 0.4); + padding: 8px 24px; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 69, 0, 0.1); + border-color: orangered; + } + } + } + } + } + + .pro-footer-note { + text-align: center; + padding-top: 24px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + + p { + color: rgba(255, 255, 255, 0.35); + font-size: 0.8rem; + line-height: 1.8; + margin: 0; + } + } +} \ No newline at end of file diff --git a/client/pages/registerUser/index.js b/client/pages/registerUser/index.js index 5e5cc9b..a58ab23 100644 --- a/client/pages/registerUser/index.js +++ b/client/pages/registerUser/index.js @@ -53,9 +53,11 @@ export default async function registerUser({ mode, redirect }) { - {mode === 'edit' - ? Change password - : } + {mode === 'edit' ? ( + Change password + ) : ( + + )}
{errorText}
{successText}
diff --git a/client/pages/user/index.js b/client/pages/user/index.js index 5b4d521..e037ca2 100644 --- a/client/pages/user/index.js +++ b/client/pages/user/index.js @@ -65,22 +65,27 @@ export default async function User({ userId }) { {user.name}
{isSelf && user.role === 'admin' && Admin} + {Boolean(user.acode_pro) && Pro}
- {shouldShowSensitiveInfo - ? - |{moment().format('YYYY MMMM')} - - : ''} + {shouldShowSensitiveInfo ? ( + + |{moment().format('YYYY MMMM')} + + ) : ( + '' + )}
- {isSelf - ?
- - Payment method -
- : ''} + {isSelf ? ( +
+ + Payment method +
+ ) : ( + '' + )}
e.target.dataset.href && Router.loadUrl(e.target.dataset.href)}> {shouldShowSensitiveInfo && ); } diff --git a/client/lib/helpers.js b/client/lib/helpers.js index 65a927c..9a3c751 100644 --- a/client/lib/helpers.js +++ b/client/lib/helpers.js @@ -1,5 +1,15 @@ import moment from 'moment'; +/** + * Format a currency amount, fixing floating point noise (e.g. 39.199999999 → 39.2). + * Removes unnecessary trailing zeros (40.00 → 40, 39.20 → 39.2). + * @param {number} amount + * @returns {number} + */ +export function formatPrice(amount) { + return parseFloat(Number(amount).toFixed(2)); +} + const on = { showloading: [], hideloading: [], diff --git a/client/pages/plugin/index.js b/client/pages/plugin/index.js index cf9fd09..c6381c7 100644 --- a/client/pages/plugin/index.js +++ b/client/pages/plugin/index.js @@ -12,7 +12,7 @@ import BuyButton, { checkPluginOwnership } from 'components/razorpayCheckout'; import YearSelect from 'components/YearSelect'; import hilightjs from 'highlight.js'; import Ref from 'html-tag-js/ref'; -import { calcRating, getLoggedInUser, gravatar, since } from 'lib/helpers'; +import { calcRating, formatPrice, getLoggedInUser, gravatar, since } from 'lib/helpers'; import Router from 'lib/Router'; import { marked } from 'marked'; import moment from 'moment/moment'; @@ -151,7 +151,7 @@ export default async function Plugin({ id: pluginId, section = 'description' })
{purchaseInfo && (
- Paid ₹{purchaseInfo.purchaseAmount} + Paid ₹{formatPrice(purchaseInfo.purchaseAmount)} · {moment(purchaseInfo.purchasedAt).format('DD MMM YYYY')} · @@ -175,7 +175,7 @@ export default async function Plugin({ id: pluginId, section = 'description' })
- {price} + {formatPrice(price)}
One-time purchase @@ -247,7 +247,7 @@ export default async function Plugin({ id: pluginId, section = 'description' }) {price ? ( <> - {price} + {formatPrice(price)} ) : ( Free @@ -300,7 +300,7 @@ export default async function Plugin({ id: pluginId, section = 'description' })
- {price} + {formatPrice(price)}
Purchase this plugin from within the Acode app diff --git a/client/res/icons/acode.ttf b/client/res/icons/acode.ttf index cd6c2db360aa01b3e0318182325d79cf9957aea0..a0205e7b3b78d21434b383dcf6f12ca890d278c5 100755 GIT binary patch delta 1253 zcmZWpO>7%g5T3Voyt`gEvAutGZO2Z$@nXj~A^ua0LqFiBGj)u~G( z8U&FSD5qA4%1HE7<-vG=0i01z+w4~|w&*3sGl zC9>)mmk>4lnALd)w#-mMgU*=VH&-(7%sGEZ2{pT){$724B_RgUR&Z zT>A`>n=g}v@D=Hd^qKUj^s#hGdRKZ=+!5~!KL~e)kAzdgoG`=x%g^u;JWiUwlINNa z3A@2kf9`8gZBrXw!1R)AEK#5>v=~f=)Q}pN;uGmqGNFc&iF7KRjEj0mFcci7EuujF ziTZqjfWr|Ezog|yN6lsC{hY_6ZAKyow0tl~mCe44%!Q7GL$lTp?)21VWxbnp|hq_-O!?lwq83WY-C>Ke*b zLsO%exMd{P*47$bP}jX>_Q5br0kA|(%jrIyC)qJ1TXcrHjLHU=$ma+WX)J?Aq|)G8 zbquMgbc)*Q+`Exm-P(TS_fIVCKsh%u;`1dE2M@k>Ae9P-W$YiG2J}E;1X5z>-u3M2 z*2TH$>DVG-nL~$4h1uE3Nkw6bjL#Q~O-|;Y0gPz83ZtRV;9W!B2fzN9<_jlIOij(td7VzX z-RbnM*HOqN+#%A{t=){>yiatiH;QPBi=WTuau_TLf(uXA>+6^e=Y{%z0{cJ&7$GWA zX>26()Uc2i(XyRe=d+}DVtyi8&H!7xGh~d*#$N+>X>a1g{^VN&*}s7^<23-kS=RUM z+U75En#7xe>w0tC?sY?55RX9kynzMeAAX eP0q|kxE%Km_a%3S+p^_tUl>2N_(sItBL4w?9Ti9b delta 381 zcmexhbH%itfsuiMft#U$ftkU;KUm+0Ux-T@D6$8L6OwZi3-oMs${83KWq|xQ>50V! zKw5x-fr$f1bEM}~rp?vP^aJvNVxlWDQWI13W-nD{U=ZH{lsC%&3a}qyeh1`#0P!cmi1r5+XoiOHO{WV`BZS)-OPT6F`BO+{B6k22+sbk}5#HLSABS>O?)=Tp-^C z=!m?6{NfUzLxDhQ3rL=Unfc|!1Ko^*lYJPIHCKwYiq(tNidBmhilvB!i~bb7C2~^a zxJa2up@@r!lkgW|Ct($!cE-sM88s$|$UC8_NW=T6ptgnO;T!_dZ{T diff --git a/client/res/icons/style.css b/client/res/icons/style.css index d6edf26..597b83f 100755 --- a/client/res/icons/style.css +++ b/client/res/icons/style.css @@ -1,6 +1,6 @@ @font-face { font-family: 'acode'; - src: url('acode.ttf?k7bos8'); + src: url('acode.ttf?g9y2uj') format('truetype'); font-weight: normal; font-style: normal; font-display: block; @@ -9,6 +9,7 @@ .icon { /* use !important to prevent issues with browser extensions that change fonts */ font-family: 'acode' !important; + speak: never; font-style: normal; font-weight: normal; font-variant: normal; @@ -21,144 +22,168 @@ } .icon.bullhorn:before { - content: "\e910"; + content: '\e910'; } .icon.megaphone:before { - content: "\e910"; + content: '\e910'; } .icon.announcement:before { - content: "\e910"; + content: '\e910'; } .icon.advertisement:before { - content: "\e910"; + content: '\e910'; } .icon.news:before { - content: "\e910"; + content: '\e910'; } .icon.mail:before { - content: "\e902"; + content: '\e902'; } .icon.earth:before { - content: "\e900"; + content: '\e900'; } .icon.code-fork:before { - content: "\e911"; + content: '\e911'; } .icon.certificate:before { - content: "\e90d"; + content: '\e90d'; } .icon.googleplay:before { - content: "\e90f"; + content: '\e90f'; color: #607d8b; } .icon.f-droid:before { - content: "\e90e"; + content: '\e90e'; color: #1976d2; } .icon.bitcoin:before { - content: "\e90c"; + content: '\e90c'; color: #f7931a; } .icon.github:before { - content: "\e901"; + content: '\e901'; } .icon.paypal:before { - content: "\e905"; + content: '\e905'; color: #00457c; } +.icon.attach_money:before { + content: '\e912'; +} +.icon.credit_card:before { + content: '\e913'; +} +.icon.payment:before { + content: '\e913'; +} +.icon.info:before { + content: '\e914'; +} +.icon.published_with_changes:before { + content: '\e915'; +} +.icon.color_lens:before { + content: '\e916'; +} +.icon.palette:before { + content: '\e916'; +} +.icon.check_circle:before { + content: '\e917'; +} .icon.navigate_next:before { - content: "\e90a"; + content: '\e90a'; } .icon.navigate_before:before { - content: "\e90b"; + content: '\e90b'; } .icon.chevron_left:before { - content: "\e90b"; + content: '\e90b'; } .icon.verified:before { - content: "\e909"; + content: '\e909'; } .icon.star_outline:before { - content: "\e906"; + content: '\e906'; } .icon.star:before { - content: "\e907"; + content: '\e907'; } .icon.favorite:before { - content: "\e908"; + content: '\e908'; } .icon.account_balance:before { - content: "\e904"; + content: '\e904'; } .icon.warning:before { - content: "\e903"; + content: '\e903'; } .icon.chat_bubble:before { - content: "\e96d"; + content: '\e96d'; } .icon.add:before { - content: "\e992"; + content: '\e992'; } .icon.clear:before { - content: "\e999"; + content: '\e999'; } .icon.content_copy:before { - content: "\e99a"; + content: '\e99a'; } .icon.create:before { - content: "\e99d"; + content: '\e99d'; } .icon.flag:before { - content: "\e9a0"; + content: '\e9a0'; } .icon.report:before { - content: "\e9ab"; + content: '\e9ab'; } .icon.outlined_flag:before { - content: "\e9b9"; + content: '\e9b9'; } .icon.access_time:before { - content: "\e9c7"; + content: '\e9c7'; } .icon.publish:before { - content: "\ea26"; + content: '\ea26'; } .icon.download:before { - content: "\ea46"; + content: '\ea46'; } .icon.menu:before { - content: "\eb58"; + content: '\eb58'; } .icon.refresh:before { - content: "\eb5b"; + content: '\eb5b'; } .icon.person:before { - content: "\ebad"; + content: '\ebad'; } .icon.share:before { - content: "\ebb3"; + content: '\ebb3'; } .icon.delete:before { - content: "\ebe1"; + content: '\ebe1'; } .icon.done:before { - content: "\ebe4"; + content: '\ebe4'; } .icon.done_all:before { - content: "\ebe5"; + content: '\ebe5'; } .icon.thumb_down:before { - content: "\ec34"; + content: '\ec34'; } .icon.thumb_up:before { - content: "\ec35"; + content: '\ec35'; } .icon.logout:before { - content: "\ece8"; + content: '\ece8'; } .icon.thumb_down_alt:before { - content: "\ed07"; + content: '\ed07'; } .icon.thumb_up_alt:before { - content: "\ed08"; + content: '\ed08'; } diff --git a/server/apis/plugin.js b/server/apis/plugin.js index c57fded..d991418 100644 --- a/server/apis/plugin.js +++ b/server/apis/plugin.js @@ -10,6 +10,7 @@ const Order = require('../entities/purchaseOrder'); const Download = require('../entities/download'); const badWords = require('../badWords.json'); const { getLoggedInUser, getPluginSKU } = require('../lib/helpers'); +const getRazorpay = require('../lib/razorpay'); const sendEmail = require('../lib/sendEmail'); const androidpublisher = google.androidpublisher('v3'); @@ -60,52 +61,29 @@ router.get('/download/:id', async (req, res) => { const clientIp = req.headers['x-forwarded-for'] || req.ip; - /** - * Helper function to record plugin download - * @param {string} pkgName - Package name (or 'web' for web downloads) - */ - async function recordDownload(pkgName) { - try { - if (!device || !clientIp || !pkgName) return; - const columns = [ - [Download.PLUGIN_ID, id], - [Download.DEVICE_ID, device], - [Download.CLIENT_IP, clientIp], - [Download.PACKAGE_NAME, pkgName], - ]; - const deviceCountOnIp = await Download.count([ - [Download.CLIENT_IP, clientIp], - [Download.PLUGIN_ID, id], - ]); - if (deviceCountOnIp < 5) { - const [download] = await Download.get([ - [Download.PLUGIN_ID, id], - [Download.DEVICE_ID, device], - ]); - if (!download) { - await Download.insert(...columns); - await Plugin.increment(Plugin.DOWNLOADS, 1, [Plugin.ID, id]); - } - } - } catch (error) { - console.error('Failed to record download:', error); - } - } - if (row.price) { const loggedInUser = await getLoggedInUser(req); // Check for user-linked purchase (Razorpay or any provider) if (loggedInUser) { - const [userOrder] = await Order.get([ - [Order.USER_ID, loggedInUser.id], - [Order.PLUGIN_ID, row.id], - [Order.STATE, Order.STATE_PURCHASED], - ]); + const [userOrder] = await Order.for('internal').get( + [Order.ID, Order.TOKEN, Order.PROVIDER, Order.PACKAGE], + [ + [Order.USER_ID, loggedInUser.id], + [Order.PLUGIN_ID, row.id], + [Order.STATE, Order.STATE_PURCHASED], + ], + ); if (userOrder) { - // User has valid purchase, allow download - res.sendFile(path.resolve(__dirname, '../../data/plugins', `${id}.zip`)); - await recordDownload(packageName || 'web'); + const purchaseValid = await verifyPurchase(userOrder, row); + if (purchaseValid) { + res.sendFile(path.resolve(__dirname, '../../data/plugins', `${id}.zip`)); + await recordDownload(id, device, clientIp, packageName || 'web'); + return; + } + // Purchase revoked (refunded outside our system) — cancel in DB + await Order.update([[Order.STATE, Order.STATE_CANCELED]], [Order.ID, userOrder.id]); + res.status(403).send({ error: 'Purchase is no longer active.' }); return; } } @@ -116,7 +94,7 @@ router.get('/download/:id', async (req, res) => { return; } - const [order] = await Order.get([Order.TOKEN, token]); + const [order] = await Order.for('internal').get([Order.TOKEN, token]); if (order?.state && Number.parseInt(order.state, 10) !== Order.STATE_PURCHASED) { res.status(403).send({ error: 'Purchase not active.' }); @@ -132,13 +110,18 @@ router.get('/download/:id', async (req, res) => { const { purchaseState } = purchase.data; if (!order) { - await Order.insert( + const orderInsert = [ [Order.TOKEN, token], [Order.PACKAGE, packageName], [Order.AMOUNT, row.price], [Order.PLUGIN_ID, row.id], [Order.STATE, Number(purchaseState)], - ); + [Order.PROVIDER, Order.PROVIDER_GOOGLE_PLAY], + ]; + if (loggedInUser) { + orderInsert.push([Order.USER_ID, loggedInUser.id]); + } + await Order.insert(...orderInsert); } if (Number(purchaseState) !== 0) { @@ -152,7 +135,7 @@ router.get('/download/:id', async (req, res) => { } res.sendFile(path.resolve(__dirname, '../../data/plugins', `${id}.zip`)); - await recordDownload(packageName); + await recordDownload(id, device, clientIp, packageName); } catch (error) { res.status(500).send({ error: error.message }); } @@ -244,7 +227,7 @@ router.get('/description/:id', async (req, res) => { router.get('{/:pluginId}', async (req, res) => { try { const { pluginId } = req.params; - const { user, name, status, page, limit, orderBy, supported_editor } = req.query; + const { user, name, status, page, limit, orderBy, supported_editor, owned } = req.query; const loggedInUser = await getLoggedInUser(req); const columns = Plugin.minColumns; const where = []; @@ -293,6 +276,25 @@ router.get('{/:pluginId}', async (req, res) => { where.push([Plugin.STATUS, status]); } + if (owned === 'true') { + if (!loggedInUser) { + res.status(401).send({ error: 'Unauthorized' }); + return; + } + const ownedOrders = await Order.for('internal').get( + [Order.PLUGIN_ID], + [ + [Order.USER_ID, loggedInUser.id], + [Order.STATE, Order.STATE_PURCHASED], + ], + ); + if (!ownedOrders.length) { + res.send([]); + return; + } + where.push([Plugin.ID, ownedOrders.map((o) => String(o.plugin_id)), 'IN']); + } + const origin = req.headers.origin || req.headers.referer; const allowAllEditors = Boolean(origin?.startsWith(process.env.HOST)); @@ -333,10 +335,47 @@ router.get('{/:pluginId}', async (req, res) => { return; } + if (loggedInUser) { + if (row.price) { + const [userOrder] = await Order.for('internal').get( + [Order.PLUGIN_ID], + [ + [Order.USER_ID, loggedInUser.id], + [Order.PLUGIN_ID, row.id], + [Order.STATE, Order.STATE_PURCHASED], + ], + ); + row.owned = !!userOrder; + } else { + row.owned = true; + } + } + res.send(row); return; } + if (loggedInUser) { + const paidPluginIds = rows.filter((r) => r.price).map((r) => r.id); + let ownedIds = new Set(); + + if (paidPluginIds.length) { + const ownedOrders = await Order.for('internal').get( + [Order.PLUGIN_ID], + [ + [Order.USER_ID, loggedInUser.id], + [Order.PLUGIN_ID, paidPluginIds, 'IN'], + [Order.STATE, Order.STATE_PURCHASED], + ], + ); + ownedIds = new Set(ownedOrders.map((o) => String(o.plugin_id))); + } + + for (const row of rows) { + row.owned = !row.price || ownedIds.has(String(row.id)); + } + } + res.send(rows); } catch (error) { res.status(500).send({ error: error.message }); @@ -957,6 +996,66 @@ function isValidPrice(price) { return price && !Number.isNaN(price) && price >= MIN_PRICE && price <= MAX_PRICE; } +/** + * Verify a purchase is still active with the payment provider. + * Returns true if valid, false if revoked. Falls back to true on API errors + * so a provider outage doesn't block legitimate downloads. + * @param {object} order - purchase_order row (id, token, provider, package) + * @param {object} plugin - plugin row (sku) + */ +async function verifyPurchase(order, plugin) { + try { + if (order.provider === Order.PROVIDER_RAZORPAY) { + const payment = await getRazorpay().payments.fetch(order.token); + return payment.status === 'captured'; + } + + if (order.provider === Order.PROVIDER_GOOGLE_PLAY) { + const purchase = await androidpublisher.purchases.products.get({ + packageName: order.package, + productId: plugin.sku, + token: order.token, + }); + return Number(purchase.data.purchaseState) === 0; + } + + // Unknown provider — don't trust without verification + console.warn(`Unknown purchase provider: ${order.provider} (order=${order.id})`); + return false; + } catch (err) { + // Provider API unreachable — don't block download, log for monitoring + console.error(`Purchase verification failed (provider=${order.provider}):`, err.message); + return true; + } +} + +async function recordDownload(pluginId, device, clientIp, pkgName) { + try { + if (!device || !clientIp || !pkgName) return; + const deviceCountOnIp = await Download.count([ + [Download.CLIENT_IP, clientIp], + [Download.PLUGIN_ID, pluginId], + ]); + if (deviceCountOnIp < 5) { + const [download] = await Download.get([ + [Download.PLUGIN_ID, pluginId], + [Download.DEVICE_ID, device], + ]); + if (!download) { + await Download.insert( + [Download.PLUGIN_ID, pluginId], + [Download.DEVICE_ID, device], + [Download.CLIENT_IP, clientIp], + [Download.PACKAGE_NAME, pkgName], + ); + await Plugin.increment(Plugin.DOWNLOADS, 1, [Plugin.ID, pluginId]); + } + } + } catch (error) { + console.error('Failed to record download:', error); + } +} + function isVersionGreater(newV, oldV) { const [newMajor, newMinor, newPatch] = newV.split('.').map(Number); const [oldMajor, oldMinor, oldPatch] = oldV.split('.').map(Number); diff --git a/server/apis/razorpay.js b/server/apis/razorpay.js index ab8c6cb..c08406f 100644 --- a/server/apis/razorpay.js +++ b/server/apis/razorpay.js @@ -1,6 +1,5 @@ const crypto = require('node:crypto'); const { Router } = require('express'); -const Razorpay = require('razorpay'); const Plugin = require('../entities/plugin'); const Order = require('../entities/purchaseOrder'); const User = require('../entities/user'); @@ -8,28 +7,10 @@ const AppConfig = require('../entities/appConfig'); const { getLoggedInUser } = require('../lib/helpers'); const sendEmail = require('../lib/sendEmail'); const { REFUND_WINDOW_MS } = require('../../constants.mjs'); +const getRazorpay = require('../lib/razorpay'); const router = Router(); -// Lazy initialization of Razorpay instance -let razorpayInstance = null; -function getRazorpay() { - if (!razorpayInstance) { - const keyId = process.env.PG_KEY_ID; - const keySecret = process.env.PG_KEY_SECRET; - - if (!keyId || !keySecret) { - throw new Error('Razorpay API keys not configured'); - } - - razorpayInstance = new Razorpay({ - key_id: keyId, - key_secret: keySecret, - }); - } - return razorpayInstance; -} - /** * Create a Razorpay order for a plugin purchase * POST /api/razorpay/create-order diff --git a/server/lib/razorpay.js b/server/lib/razorpay.js new file mode 100644 index 0000000..8b26c65 --- /dev/null +++ b/server/lib/razorpay.js @@ -0,0 +1,20 @@ +const Razorpay = require('razorpay'); + +let razorpayInstance = null; + +module.exports = function getRazorpay() { + if (!razorpayInstance) { + const keyId = process.env.PG_KEY_ID; + const keySecret = process.env.PG_KEY_SECRET; + + if (!keyId || !keySecret) { + throw new Error('Razorpay API keys not configured'); + } + + razorpayInstance = new Razorpay({ + key_id: keyId, + key_secret: keySecret, + }); + } + return razorpayInstance; +};