diff --git a/data/payments.json b/data/payments.json
new file mode 100644
index 0000000..76d501a
--- /dev/null
+++ b/data/payments.json
@@ -0,0 +1,3 @@
+{
+ "payments": []
+}
diff --git a/includes/portal-helpers.php b/includes/portal-helpers.php
index 474f30e..a5067a3 100644
--- a/includes/portal-helpers.php
+++ b/includes/portal-helpers.php
@@ -225,6 +225,8 @@ function portalRefreshVerificationTokenByEmail($email, &$userOut = null) {
define('PORTAL_PROJECT_REQUESTS_FILE', PORTAL_DATA_DIR . '/project_requests.json');
define('PORTAL_ESTIMATES_FILE', PORTAL_DATA_DIR . '/estimate_requests.json');
define('PORTAL_PROPOSALS_FILE', PORTAL_DATA_DIR . '/proposals.json');
+define('PORTAL_PAYMENTS_FILE', PORTAL_DATA_DIR . '/payments.json');
+define('PORTAL_PAYPAL_WEBHOOK_LOG', PORTAL_DATA_DIR . '/paypal-webhook-log.json');
define('PORTAL_PROJECT_AGREEMENTS_FILE', PORTAL_DATA_DIR . '/project_agreements.json');
define('PORTAL_ADMIN_SETTINGS_FILE', PORTAL_DATA_DIR . '/admin_settings.json');
@@ -280,10 +282,32 @@ function portalSaveJson($file, $data) {
function portalDefaultAdminSettings() {
return [
'paypal' => [
- 'client_id' => '',
- 'secret' => '',
- 'environment' => 'sandbox',
- 'business_email' => '',
+ 'mode' => 'manual',
+ 'sandbox' => [
+ 'client_id' => '',
+ 'secret' => '',
+ 'business_email' => '',
+ 'webhook_id' => '',
+ 'payment_link' => '',
+ ],
+ 'live' => [
+ 'client_id' => '',
+ 'secret' => '',
+ 'business_email' => '',
+ 'webhook_id' => '',
+ 'payment_link' => '',
+ ],
+ 'currency' => 'USD',
+ 'invoice_message' => '',
+ 'payment_instructions' => '',
+ 'require_payment_before_work' => true,
+ 'enable_manual_recording' => true,
+ 'enable_webhook_logging' => false,
+ // Legacy flat fields kept for backward compat
+ 'client_id' => '',
+ 'secret' => '',
+ 'environment' => 'sandbox',
+ 'business_email'=> '',
'invoice_defaults' => '',
],
'email' => [
@@ -312,8 +336,12 @@ function portalLoadAdminSettings() {
$emailStored = isset($stored['email']) && is_array($stored['email']) ? $stored['email'] : [];
$siteStored = isset($stored['site']) && is_array($stored['site']) ? $stored['site'] : [];
$businessStored = isset($stored['business']) && is_array($stored['business']) ? $stored['business'] : [];
+ $paypal = array_merge($defaults['paypal'], $paypalStored);
+ // Ensure nested sandbox/live sub-arrays are merged properly
+ $paypal['sandbox'] = array_merge($defaults['paypal']['sandbox'], isset($paypalStored['sandbox']) && is_array($paypalStored['sandbox']) ? $paypalStored['sandbox'] : []);
+ $paypal['live'] = array_merge($defaults['paypal']['live'], isset($paypalStored['live']) && is_array($paypalStored['live']) ? $paypalStored['live'] : []);
return [
- 'paypal' => array_merge($defaults['paypal'], $paypalStored),
+ 'paypal' => $paypal,
'email' => array_merge($defaults['email'], $emailStored),
'site' => array_merge($defaults['site'], $siteStored),
'business' => array_merge($defaults['business'], $businessStored),
@@ -324,8 +352,12 @@ function portalLoadAdminSettings() {
function portalSaveAdminSettings(array $settings) {
$defaults = portalDefaultAdminSettings();
+ $paypalIn = isset($settings['paypal']) && is_array($settings['paypal']) ? $settings['paypal'] : [];
+ $paypal = array_merge($defaults['paypal'], $paypalIn);
+ $paypal['sandbox'] = array_merge($defaults['paypal']['sandbox'], isset($paypalIn['sandbox']) && is_array($paypalIn['sandbox']) ? $paypalIn['sandbox'] : []);
+ $paypal['live'] = array_merge($defaults['paypal']['live'], isset($paypalIn['live']) && is_array($paypalIn['live']) ? $paypalIn['live'] : []);
$payload = [
- 'paypal' => array_merge($defaults['paypal'], isset($settings['paypal']) && is_array($settings['paypal']) ? $settings['paypal'] : []),
+ 'paypal' => $paypal,
'email' => array_merge($defaults['email'], isset($settings['email']) && is_array($settings['email']) ? $settings['email'] : []),
'site' => array_merge($defaults['site'], isset($settings['site']) && is_array($settings['site']) ? $settings['site'] : []),
'business' => array_merge($defaults['business'], isset($settings['business']) && is_array($settings['business']) ? $settings['business'] : []),
@@ -804,6 +836,49 @@ function portalSaveProposals(array $proposals) {
return portalSaveJson(PORTAL_PROPOSALS_FILE, ['proposals' => array_values($proposals)]);
}
+function portalGenerateUniqueProposalId() {
+ $proposals = portalLoadProposals();
+ $existing = [];
+ foreach ($proposals as $p) {
+ if (!empty($p['proposal_id'])) {
+ $existing[(string)$p['proposal_id']] = true;
+ }
+ }
+ $datePart = date('Ymd');
+ $attempts = 0;
+ do {
+ $id = 'PROP-' . $datePart . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
+ $attempts++;
+ } while (isset($existing[$id]) && $attempts < 10000);
+ return $id;
+}
+
+function portalGenerateUniquePaymentId() {
+ $payments = portalLoadPayments();
+ $existing = [];
+ foreach ($payments as $p) {
+ if (!empty($p['payment_id'])) {
+ $existing[(string)$p['payment_id']] = true;
+ }
+ }
+ $datePart = date('Ymd');
+ $attempts = 0;
+ do {
+ $id = 'PAY-' . $datePart . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
+ $attempts++;
+ } while (isset($existing[$id]) && $attempts < 10000);
+ return $id;
+}
+
+function portalLoadPayments() {
+ $data = portalLoadJson(PORTAL_PAYMENTS_FILE);
+ return isset($data['payments']) && is_array($data['payments']) ? $data['payments'] : [];
+}
+
+function portalSavePayments(array $payments) {
+ return portalSaveJson(PORTAL_PAYMENTS_FILE, ['payments' => array_values($payments)]);
+}
+
function portalLoadProjectAgreements() {
$data = portalLoadJson(PORTAL_PROJECT_AGREEMENTS_FILE);
return isset($data['agreements']) && is_array($data['agreements']) ? $data['agreements'] : [];
diff --git a/paypal-webhook.php b/paypal-webhook.php
new file mode 100644
index 0000000..c59806d
--- /dev/null
+++ b/paypal-webhook.php
@@ -0,0 +1,65 @@
+ $rawBody];
+}
+
+$logEntry = [
+ 'received_at' => date('c'),
+ 'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? '',
+ 'content_type' => $_SERVER['CONTENT_TYPE'] ?? '',
+ 'paypal_event_type' => $payload['event_type'] ?? '',
+ 'paypal_event_id' => $payload['id'] ?? '',
+ 'payload_preview' => array_slice($payload, 0, 20),
+ 'note' => 'Webhook received but not processed. Manual review required.',
+];
+
+if ($loggingEnabled) {
+ // Append to webhook log
+ $logFile = PORTAL_PAYPAL_WEBHOOK_LOG;
+ $log = [];
+ if (file_exists($logFile)) {
+ $existing = @json_decode(file_get_contents($logFile), true);
+ $log = (isset($existing['events']) && is_array($existing['events'])) ? $existing['events'] : [];
+ }
+ // Keep last 500 entries to prevent unbounded growth
+ $log[] = $logEntry;
+ if (count($log) > 500) {
+ $log = array_slice($log, -500);
+ }
+ portalSaveJson($logFile, ['events' => array_values($log)]);
+}
+
+// TODO: Verify PayPal webhook signature before trusting payment events.
+// TODO: Match PayPal invoice/transaction ID to proposal_id.
+// TODO: Update payments.json automatically after verification.
+
+http_response_code(200);
+header('Content-Type: application/json');
+echo json_encode(['status' => 'received', 'note' => 'Webhook logging only. Not processed.']);
diff --git a/settings.php b/settings.php
index 3654855..19fcc3b 100644
--- a/settings.php
+++ b/settings.php
@@ -7,36 +7,65 @@
$current_page = 'dashboard';
$header_class = 'inner-header';
-$user = portalGetUser();
+$user = portalGetUser();
$settings = portalLoadAdminSettings();
-$notice = '';
-$error = '';
+$notice = '';
+$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- $paypal = $settings['paypal'];
- $email = $settings['email'];
- $site = $settings['site'];
+ $paypal = $settings['paypal'];
+ $email = $settings['email'];
+ $site = $settings['site'];
$business = $settings['business'];
- $paypal['client_id'] = trim((string)($_POST['paypal_client_id'] ?? ''));
- $paypal['secret'] = trim((string)($_POST['paypal_secret'] ?? ''));
- $paypal['environment'] = trim((string)($_POST['paypal_environment'] ?? 'sandbox')) === 'production' ? 'production' : 'sandbox';
- $paypal['business_email'] = trim((string)($_POST['paypal_business_email'] ?? ''));
- $paypal['invoice_defaults'] = trim((string)($_POST['paypal_invoice_defaults'] ?? ''));
+ // PayPal general
+ $paypal['mode'] = in_array(trim((string)($_POST['paypal_mode'] ?? 'manual')), ['manual','sandbox','live'], true)
+ ? trim((string)$_POST['paypal_mode']) : 'manual';
+ $paypal['currency'] = strtoupper(substr(trim((string)($_POST['paypal_currency'] ?? 'USD')), 0, 3));
+ $paypal['invoice_message'] = trim((string)($_POST['paypal_invoice_message'] ?? ''));
+ $paypal['payment_instructions'] = trim((string)($_POST['paypal_payment_instructions'] ?? ''));
+ $paypal['require_payment_before_work'] = (bool)($_POST['paypal_require_payment_before_work'] ?? false);
+ $paypal['enable_manual_recording'] = (bool)($_POST['paypal_enable_manual_recording'] ?? false);
+ $paypal['enable_webhook_logging'] = (bool)($_POST['paypal_enable_webhook_logging'] ?? false);
- $email['from_name'] = trim((string)($_POST['email_from_name'] ?? ''));
+ // Sandbox credentials — only overwrite secret if a non-blank value is submitted
+ $paypal['sandbox']['client_id'] = trim((string)($_POST['sandbox_client_id'] ?? ''));
+ $paypal['sandbox']['business_email']= trim((string)($_POST['sandbox_business_email']?? ''));
+ $paypal['sandbox']['webhook_id'] = trim((string)($_POST['sandbox_webhook_id'] ?? ''));
+ $paypal['sandbox']['payment_link'] = trim((string)($_POST['sandbox_payment_link'] ?? ''));
+ $sbSecret = trim((string)($_POST['sandbox_secret'] ?? ''));
+ if ($sbSecret !== '') {
+ $paypal['sandbox']['secret'] = $sbSecret;
+ }
+
+ // Live credentials
+ $paypal['live']['client_id'] = trim((string)($_POST['live_client_id'] ?? ''));
+ $paypal['live']['business_email']= trim((string)($_POST['live_business_email']?? ''));
+ $paypal['live']['webhook_id'] = trim((string)($_POST['live_webhook_id'] ?? ''));
+ $paypal['live']['payment_link'] = trim((string)($_POST['live_payment_link'] ?? ''));
+ $liveSecret = trim((string)($_POST['live_secret'] ?? ''));
+ if ($liveSecret !== '') {
+ $paypal['live']['secret'] = $liveSecret;
+ }
+
+ $email['from_name'] = trim((string)($_POST['email_from_name'] ?? ''));
$email['from_email'] = trim((string)($_POST['email_from_email'] ?? ''));
- $email['reply_to'] = trim((string)($_POST['email_reply_to'] ?? ''));
+ $email['reply_to'] = trim((string)($_POST['email_reply_to'] ?? ''));
- $site['company_name'] = trim((string)($_POST['site_company_name'] ?? 'Runlevel Systems'));
- $site['support_email'] = trim((string)($_POST['site_support_email'] ?? ''));
- $site['support_phone'] = trim((string)($_POST['site_support_phone'] ?? ''));
+ $site['company_name'] = trim((string)($_POST['site_company_name'] ?? 'Runlevel Systems'));
+ $site['support_email'] = trim((string)($_POST['site_support_email'] ?? ''));
+ $site['support_phone'] = trim((string)($_POST['site_support_phone'] ?? ''));
$business['legal_name'] = trim((string)($_POST['business_legal_name'] ?? ''));
- $business['address'] = trim((string)($_POST['business_address'] ?? ''));
+ $business['address'] = trim((string)($_POST['business_address'] ?? ''));
- if ($paypal['business_email'] !== '' && !filter_var($paypal['business_email'], FILTER_VALIDATE_EMAIL)) {
- $error = 'Please enter a valid PayPal business email.';
+ // Email validation
+ $sbEmailVal = $paypal['sandbox']['business_email'];
+ $liveEmailVal = $paypal['live']['business_email'];
+ if ($sbEmailVal !== '' && !filter_var($sbEmailVal, FILTER_VALIDATE_EMAIL)) {
+ $error = 'Please enter a valid sandbox business email.';
+ } elseif ($liveEmailVal !== '' && !filter_var($liveEmailVal, FILTER_VALIDATE_EMAIL)) {
+ $error = 'Please enter a valid live business email.';
} elseif ($email['from_email'] !== '' && !filter_var($email['from_email'], FILTER_VALIDATE_EMAIL)) {
$error = 'Please enter a valid sender email.';
} elseif ($email['reply_to'] !== '' && !filter_var($email['reply_to'], FILTER_VALIDATE_EMAIL)) {
@@ -47,21 +76,31 @@
if ($error === '') {
$settings = [
- 'paypal' => $paypal,
- 'email' => $email,
- 'site' => $site,
- 'business' => $business,
+ 'paypal' => $paypal,
+ 'email' => $email,
+ 'site' => $site,
+ 'business' => $business,
'updated_at' => date('c'),
'updated_by' => (string)($user['username'] ?? 'admin'),
];
if (portalSaveAdminSettings($settings)) {
$notice = 'Settings saved.';
+ $settings = portalLoadAdminSettings();
} else {
$error = 'Unable to save settings.';
}
}
}
+
+// Helper: mask secret for display
+function maskSecret($val) {
+ $val = (string)$val;
+ if ($val === '') { return ''; }
+ if (strlen($val) <= 8) { return str_repeat('•', strlen($val)); }
+ return substr($val, 0, 4) . str_repeat('•', min(24, strlen($val) - 4));
+}
?>
+
@@ -105,28 +144,73 @@