From 488987a5a03f8b83bc8a394a0fefe9a7018e2dda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:25:22 +0000 Subject: [PATCH] Complete proposal/payment workflow: webhook, settings UI, dashboard counters --- client/proposal-view.php | 359 ++++++++++++++++++++++++++++ client/proposal.php | 10 +- client/proposals.php | 106 +++++++-- dashboard.php | 71 +++++- data/payments.json | 3 + includes/portal-helpers.php | 87 ++++++- paypal-webhook.php | 65 ++++++ settings.php | 162 +++++++++---- staff/payment-record.php | 297 +++++++++++++++++++++++ staff/proposal-edit.php | 454 ++++++++++++++++++++++++++++++++++++ staff/proposal-send.php | 141 +++++++++++ staff/proposal-view.php | 198 ++++++++++++++++ staff/proposals.php | 179 ++++++++++++++ 13 files changed, 2061 insertions(+), 71 deletions(-) create mode 100644 client/proposal-view.php create mode 100644 data/payments.json create mode 100644 paypal-webhook.php create mode 100644 staff/payment-record.php create mode 100644 staff/proposal-edit.php create mode 100644 staff/proposal-send.php create mode 100644 staff/proposal-view.php create mode 100644 staff/proposals.php diff --git a/client/proposal-view.php b/client/proposal-view.php new file mode 100644 index 0000000..2195abf --- /dev/null +++ b/client/proposal-view.php @@ -0,0 +1,359 @@ + date('Y-m-d H:i:s'), + 'message' => $message, + 'requested_budget' => trim((string)($_POST['requested_budget'] ?? '')), + 'requested_timeline' => trim((string)($_POST['requested_timeline'] ?? '')), + 'requested_deliverable' => trim((string)($_POST['requested_deliverable'] ?? '')), + 'status' => 'open', + ]; + $now = date('c'); + $proposals = portalLoadProposals(); + foreach ($proposals as &$p) { + if ((string)($p['proposal_id'] ?? '') !== $proposalId) { continue; } + $existing = (isset($p['change_requests']) && is_array($p['change_requests'])) ? $p['change_requests'] : []; + $existing[] = $cr; + $p['change_requests'] = $existing; + $p['proposal_status'] = 'changes_requested'; + $p['change_requested_at'] = $now; + $p['updated_at'] = $now; + $proposal = $p; + break; + } + unset($p); + portalSaveProposals($proposals); + $status = 'changes_requested'; + $notice = 'Your change request has been submitted. We will review and update the proposal.'; + } + } +} + +$settings = portalLoadAdminSettings(); +$paypalMode = (string)($settings['paypal']['mode'] ?? 'manual'); +$paypalLink = ''; +if ($paypalMode === 'live') { + $paypalLink = (string)($settings['paypal']['live']['payment_link'] ?? ''); +} elseif ($paypalMode === 'sandbox') { + $paypalLink = (string)($settings['paypal']['sandbox']['payment_link'] ?? ''); +} + +$milestoneStatuses = [ + 'not_started' => 'Not Started', + 'in_progress' => 'In Progress', + 'ready_for_review' => 'Ready for Review', + 'payment_due' => 'Payment Due', + 'paid' => 'Paid', + 'complete' => 'Complete', +]; + +$current_page = 'client-portal'; +$header_class = 'inner-header'; +?> + + + + + + + Proposal | Client Portal | Runlevel Systems + + + + + + +
+
+ ← My Proposals + +
+
+ + +

Proposal not found or not available.

+ + +
+
+
+

+

Runlevel Systems — Proposal ID:

+
+ 'Awaiting Your Review', + 'changes_requested'=> 'Changes Requested', + 'accepted' => 'Accepted', + 'payment_pending' => 'Payment Pending', + 'paid_start' => 'Paid — Work Starting', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', + ]; + $statusColors = [ + 'sent' => '#36f3ff', + 'changes_requested'=> '#ffc600', + 'accepted' => '#86efac', + 'payment_pending' => '#fbbf24', + 'paid_start' => '#34d399', + 'in_progress' => '#60a5fa', + 'completed' => '#4ade80', + ]; + $statusLabel = $statusLabels[$status] ?? ucfirst(str_replace('_', ' ', $status)); + $statusColor = $statusColors[$status] ?? '#7a9ac0'; + ?> + +
+
+ +
+ +
+
+ +
+ +
+ + +
+ + + +
+ +
+ +
+ +
+ +
Timeline
+ + +
Total Project Amount
$
+ + +
Amount Due To Start
$
+ +
+ +
Payment Terms
+ +
+ + + +
+ +

Amount due to start: $ — required before work begins.

+

Future milestone payments are only due when the related milestone is completed or ready for review, depending on the proposal terms.

+ $ms): ?> +
+
Milestone :
+
+
Amount Due Upon Completion: $
+
+
+
+
Deliverables
+
+ +
+ + + +
+ + +
Your Responsibilities
+ + +
Third-Party Costs
+ + +
Revisions
+ +
+ + + +
+ + +

✓ Starting payment received. Work will begin shortly.

+ +

To begin work, the starting payment listed above must be received.

+ +
Amount Due To Start: $
+ + +

Runlevel Systems will send a PayPal invoice or payment link after proposal acceptance.

+ + Pay With PayPal + +

Payment instructions will be sent to you by email.

+ + +
+ + + + +
+

Accept This Proposal

+

By accepting this proposal, you are asking Runlevel Systems to begin work after the required starting payment is received.

+ +
Amount Due To Start: $
+ + +
+ +
+ + + +
+
+ +
+

Request Changes

+

Tell us what you would like changed and we will review and update the proposal.

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + +
+
+ + + + + diff --git a/client/proposal.php b/client/proposal.php index e7ab8e9..0422afd 100644 --- a/client/proposal.php +++ b/client/proposal.php @@ -3,11 +3,11 @@ define('WDS_SYSTEM', true); require_once __DIR__ . '/../includes/portal-helpers.php'; -portalRequireLogin(); -if (portalGetRole() !== 'client') { - header('Location: /dashboard.php'); - exit; -} +// Forward to the new unified proposal view page +$proposalId = isset($_GET['proposal_id']) ? '?proposal_id=' . urlencode((string)$_GET['proposal_id']) : ''; +header('Location: /client/proposal-view.php' . $proposalId); +exit; + $user = portalGetUser(); $username = (string)($user['username'] ?? ''); diff --git a/client/proposals.php b/client/proposals.php index 6ff7a7a..4f6aaba 100644 --- a/client/proposals.php +++ b/client/proposals.php @@ -9,7 +9,7 @@ exit; } -$user = portalGetUser(); +$user = portalGetUser(); $username = (string)($user['username'] ?? ''); $requests = array_values(array_filter(portalLoadProjectRequests(), function ($r) use ($username) { return (string)($r['client_username'] ?? '') === $username; @@ -21,10 +21,39 @@ || (string)($p['client_username'] ?? '') === $username; })); +// Only show proposals that have been sent (not draft) +$proposals = array_values(array_filter($proposals, function ($p) { + $st = (string)($p['proposal_status'] ?? $p['status'] ?? 'draft'); + return !in_array($st, ['draft', 'cancelled'], true); +})); + usort($proposals, function ($a, $b) { - return strcmp((string)($b['updated_at'] ?? $b['created_at'] ?? ''), (string)($a['updated_at'] ?? $a['created_at'] ?? '')); + return strcmp( + (string)($b['updated_at'] ?? $b['created_at'] ?? ''), + (string)($a['updated_at'] ?? $a['created_at'] ?? '') + ); }); +$statusLabels = [ + 'sent' => 'Awaiting Your Review', + 'changes_requested'=> 'Changes Requested', + 'accepted' => 'Accepted', + 'payment_pending' => 'Payment Pending', + 'paid_start' => 'Paid — Work Starting', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', +]; + +$statusColors = [ + 'sent' => '#36f3ff', + 'changes_requested'=> '#ffc600', + 'accepted' => '#86efac', + 'payment_pending' => '#fbbf24', + 'paid_start' => '#34d399', + 'in_progress' => '#60a5fa', + 'completed' => '#4ade80', +]; + $current_page = 'client-portal'; $header_class = 'inner-header'; ?> @@ -32,15 +61,23 @@ - My Proposals | Client Portal | Runlevel Systems @@ -48,21 +85,57 @@
- ← Back to Dashboard + ← Dashboard +
-

My Proposals

+

How This Works

+
    +
  • 1 You tell us what you need.
  • +
  • 2 We create a proposal.
  • +
  • 3 You can accept it or request changes.
  • +
  • 4 Once accepted, you pay the amount due to start.
  • +
  • 5 We begin work.
  • +
  • 6 You review the delivered work.
  • +
  • 7 Additional milestones or final payment are handled according to the proposal.
  • +
+
+ +
+

My Proposals

-

No proposals available yet.

+

No proposals available yet. Once we review your request and create a proposal, it will appear here.

- -
-
-
-
+ +
+
+
+
+
ID:
+
+ +
+ +
+
Total: $
+
Due to Start: $
+
+ + 0): ?>
Updated
+
+ View Proposal + + Accept + Request Changes + + ✓ Accepted — awaiting payment + Payment pending
-
-
Request ID:
- View proposal
@@ -74,3 +147,4 @@ + diff --git a/dashboard.php b/dashboard.php index 53c9db1..aa69d4c 100644 --- a/dashboard.php +++ b/dashboard.php @@ -114,6 +114,41 @@ $awaitingApproval = 0; $awaitingSignature = 0; +// Proposal status counters (new workflow) +$proposalsDraft = 0; +$proposalsSent = 0; +$proposalsChangesRequested = 0; +$proposalsAccepted = 0; +$proposalsAwaitingPayment = 0; +$proposalsPaidStart = 0; +$proposalsInProgress = 0; +$proposalsMyReview = 0; // client-facing: proposals awaiting client action + +// Also count from all proposals directly for staff overview +foreach ($proposals as $prop) { + $ps = (string)($prop['proposal_status'] ?? $prop['status'] ?? 'draft'); + if ($isStaffRole) { + switch ($ps) { + case 'draft': $proposalsDraft++; break; + case 'sent': $proposalsSent++; break; + case 'changes_requested': $proposalsChangesRequested++; break; + case 'accepted': $proposalsAccepted++; break; + case 'payment_pending': $proposalsAwaitingPayment++; break; + case 'paid_start': $proposalsPaidStart++; break; + case 'in_progress': $proposalsInProgress++; break; + } + } elseif ($role === 'client') { + // Only count proposals the client owns + $ownerUsername = (string)($prop['client_username'] ?? ''); + if ($ownerUsername !== $myUsername && $ownerUsername !== '') { + continue; + } + if (in_array($ps, ['sent', 'changes_requested'], true)) { + $proposalsMyReview++; + } + } +} + foreach ($visibleRequests as $request) { $projectId = portalGetRequestDisplayId((array)$request); $requestStatus = (string)($request['status'] ?? 'new'); @@ -163,6 +198,7 @@ ]; } + usort($projectRows, function ($a, $b) { return ((int)$b['last_updated_ts']) <=> ((int)$a['last_updated_ts']); }); @@ -319,21 +355,46 @@
+ +

Proposal Pipeline

+ +

Project Overview

+ +

My Overview

+ + +
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 @@
-
+

PayPal Settings

-

PayPal Configuration (Admin Only).

- - - - - +

Configure PayPal mode and credentials. Secrets are masked after saving — enter a new value to update.

- - + + + - - +
+
+
Sandbox Credentials
+ + + + + + + + + + +
+
+
Live Credentials
+ + + + + + + + + + +
+
- - +
+
General PayPal Settings
+
+
+ + +
+
+ > + +
+
+ > + +
+
+ > + +
+
+ + + + +
-
+
diff --git a/staff/payment-record.php b/staff/payment-record.php new file mode 100644 index 0000000..f2de75c --- /dev/null +++ b/staff/payment-record.php @@ -0,0 +1,297 @@ + 'Start Payment', + 'milestone_payment'=> 'Milestone Payment', + 'final_payment' => 'Final Payment', + 'manual_payment' => 'Manual Payment', + 'refund' => 'Refund', +]; + +$paymentStatuses = [ + 'pending' => 'Pending', + 'received' => 'Received', + 'failed' => 'Failed', + 'refunded' => 'Refunded', + 'disputed' => 'Disputed', +]; + +$milestoneStatuses = [ + 'not_started' => 'Not Started', + 'in_progress' => 'In Progress', + 'ready_for_review' => 'Ready for Review', + 'payment_due' => 'Payment Due', + 'paid' => 'Paid', + 'complete' => 'Complete', +]; + +$notice = ''; +$error = ''; + +$form = [ + 'proposal_id' => $preloadProposalId, + 'payment_type' => 'start_payment', + 'milestone_index' => '', + 'amount' => '', + 'currency' => 'USD', + 'payment_status' => 'received', + 'paypal_transaction_id'=> '', + 'paypal_invoice_id' => '', + 'paypal_payer_email' => '', + 'environment' => 'live', + 'notes' => '', +]; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + foreach (array_keys($form) as $key) { + $form[$key] = trim((string)($_POST[$key] ?? '')); + } + + if ($form['proposal_id'] === '' || $form['amount'] === '' || !is_numeric($form['amount'])) { + $error = 'Proposal and a valid amount are required.'; + } else { + // Find proposal + $linkedProposal = null; + foreach ($proposals as $p) { + if ((string)($p['proposal_id'] ?? '') === $form['proposal_id']) { + $linkedProposal = $p; + break; + } + } + if ($linkedProposal === null) { + $error = 'Proposal not found.'; + } + } + + if ($error === '') { + $paymentId = portalGenerateUniquePaymentId(); + $now = date('c'); + + $payment = [ + 'payment_id' => $paymentId, + 'proposal_id' => $form['proposal_id'], + 'request_id' => (string)($linkedProposal['request_id'] ?? ''), + 'client_id' => (string)($linkedProposal['client_id'] ?? ''), + 'payment_type' => $form['payment_type'], + 'milestone_index' => $form['milestone_index'], + 'amount' => $form['amount'], + 'currency' => $form['currency'] !== '' ? $form['currency'] : 'USD', + 'payment_status' => $form['payment_status'], + 'paypal_transaction_id' => $form['paypal_transaction_id'], + 'paypal_invoice_id' => $form['paypal_invoice_id'], + 'paypal_payer_email' => $form['paypal_payer_email'], + 'environment' => $form['environment'], + 'notes' => $form['notes'], + 'created_at' => $now, + 'recorded_at' => $now, + 'recorded_by' => (string)((portalGetUser())['username'] ?? 'staff'), + ]; + + $payments = portalLoadPayments(); + $payments[] = $payment; + portalSavePayments($payments); + + // Update proposal status + $proposalStatus = (string)($linkedProposal['proposal_status'] ?? $linkedProposal['status'] ?? 'accepted'); + $newStatus = $proposalStatus; + + if ($form['payment_status'] === 'received') { + if ($form['payment_type'] === 'start_payment') { + $newStatus = 'paid_start'; + } elseif ($form['payment_type'] === 'final_payment') { + $newStatus = 'completed'; + } + } + + $proposals = portalLoadProposals(); + foreach ($proposals as &$p) { + if ((string)($p['proposal_id'] ?? '') !== $form['proposal_id']) { continue; } + $p['proposal_status'] = $newStatus; + $p['updated_at'] = $now; + + // Update milestone status if applicable + if ($form['payment_type'] === 'milestone_payment' && $form['milestone_index'] !== '' && $form['payment_status'] === 'received') { + $msIdx = (int)$form['milestone_index']; + if (isset($p['milestones'][$msIdx])) { + $p['milestones'][$msIdx]['milestone_status'] = 'paid'; + } + } + break; + } + unset($p); + portalSaveProposals($proposals); + + $notice = 'Payment recorded. Payment ID: ' . $paymentId; + $form = array_fill_keys(array_keys($form), ''); + $form['proposal_id'] = $preloadProposalId; + $form['payment_type'] = 'start_payment'; + $form['payment_status'] = 'received'; + $form['environment'] = 'live'; + $form['currency'] = 'USD'; + } +} + +// Build proposal options +$proposalOptions = []; +foreach ($proposals as $p) { + $pid = (string)($p['proposal_id'] ?? ''); + if ($pid === '') { continue; } + $proposalOptions[$pid] = $pid . ' — ' . ($p['project_title'] ?? '') . ' (' . ($p['client_name'] ?? '') . ')'; +} + +// Selected proposal for milestone list +$selectedProposal = null; +foreach ($proposals as $p) { + if ((string)($p['proposal_id'] ?? '') === $form['proposal_id']) { + $selectedProposal = $p; + break; + } +} + +$current_page = 'staff-portal'; +$header_class = 'inner-header'; +?> + + + + + + + Record Payment | Staff | Runlevel Systems + + + + + + +
+
+
+

Record Payment

+ ← Proposals +
+ +
+
+ +
+

Manually record a PayPal or other payment for a proposal.

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + Cancel +
+ +
+
+
+ + + + + + diff --git a/staff/proposal-edit.php b/staff/proposal-edit.php new file mode 100644 index 0000000..5fdbf28 --- /dev/null +++ b/staff/proposal-edit.php @@ -0,0 +1,454 @@ + 'Draft', + 'sent' => 'Sent', + 'changes_requested'=> 'Changes Requested', + 'accepted' => 'Accepted', + 'payment_pending' => 'Payment Pending', + 'paid_start' => 'Paid — Ready to Start', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', +]; + +$milestoneStatuses = [ + 'not_started' => 'Not Started', + 'in_progress' => 'In Progress', + 'ready_for_review' => 'Ready for Review', + 'payment_due' => 'Payment Due', + 'paid' => 'Paid', + 'complete' => 'Complete', +]; + +$requestIdQuery = trim((string)($_GET['request_id'] ?? '')); +$proposalIdQuery = trim((string)($_GET['proposal_id'] ?? '')); +$requestRecordId = ''; + +$request = null; +$proposal = null; + +if ($proposalIdQuery !== '') { + foreach ($proposals as $row) { + if ((string)($row['proposal_id'] ?? '') === $proposalIdQuery) { + $proposal = $row; + $requestIdQuery = (string)($row['request_id'] ?? $requestIdQuery); + break; + } + } +} + +if ($requestIdQuery !== '') { + foreach ($requests as $row) { + if (portalGetRequestDisplayId((array)$row) === $requestIdQuery) { + $request = portalNormalizeProjectRequest((array)$row); + $requestRecordId = (string)($row['id'] ?? ''); + break; + } + } +} + +$notice = ''; +$error = ''; + +// Build defaults from existing proposal or request +$defaults = [ + 'proposal_id' => $proposal['proposal_id'] ?? '', + 'request_id' => $requestIdQuery, + 'client_id' => $proposal['client_id'] ?? ($request['client_id'] ?? ''), + 'client_name' => $proposal['client_name'] ?? ($request['name'] ?? ''), + 'client_email' => $proposal['client_email'] ?? ($request['email'] ?? ''), + 'client_username' => $proposal['client_username'] ?? ($request['client_username'] ?? ''), + 'project_title' => $proposal['project_title'] ?? ($request['project_type'] ?? ''), + 'proposal_status' => $proposal['proposal_status'] ?? ($proposal['status'] ?? 'draft'), + 'request_summary' => $proposal['request_summary'] ?? ($request['description'] ?? ''), + 'staff_review_notes' => $proposal['staff_review_notes'] ?? ($proposal['staff_summary'] ?? ''), + 'proposed_work' => $proposal['proposed_work'] ?? '', + 'deliverables' => $proposal['deliverables'] ?? '', + 'out_of_scope' => $proposal['out_of_scope'] ?? '', + 'timeline_estimate' => $proposal['timeline_estimate'] ?? ($proposal['estimated_time'] ?? ($request['estimated_time_range'] ?? '')), + 'total_price' => $proposal['total_price'] ?? ($proposal['estimated_cost'] ?? ($request['estimated_cost_range'] ?? '')), + 'amount_due_to_start' => $proposal['amount_due_to_start'] ?? ($proposal['payment_required_to_begin'] ?? ''), + 'payment_terms' => $proposal['payment_terms'] ?? "Start payment required before work begins.\nRemaining balance due after milestone completion or final delivery.", + 'customer_responsibilities' => $proposal['customer_responsibilities'] ?? "Customer must provide access, files, logins, repo links, screenshots, or other information needed to complete the work.", + 'third_party_costs' => $proposal['third_party_costs'] ?? "Assets, hosting, app store fees, licenses, paid APIs, plugins, or marketplace tools are not included unless specifically listed.", + 'revision_terms' => $proposal['revision_terms'] ?? "Small corrections related to agreed work are included. New requests or scope changes may require a revised proposal.", + 'acceptance_statement' => $proposal['acceptance_statement'] ?? "By accepting this proposal, the customer agrees that Runlevel Systems may begin work after the required starting payment is received.", + 'requires_contract' => $proposal['requires_contract'] ?? '0', + 'contract_link' => $proposal['contract_link'] ?? '', + 'num_milestones' => (string)($proposal['num_milestones'] ?? '0'), + 'created_at' => $proposal['created_at'] ?? '', + 'updated_at' => $proposal['updated_at'] ?? '', + 'sent_at' => $proposal['sent_at'] ?? '', + 'accepted_at' => $proposal['accepted_at'] ?? '', + 'change_requested_at' => $proposal['change_requested_at'] ?? '', +]; + +// Milestones array +$existingMilestones = (isset($proposal['milestones']) && is_array($proposal['milestones'])) ? $proposal['milestones'] : []; + +$form = $defaults; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $fields = array_keys($form); + foreach ($fields as $key) { + $form[$key] = trim((string)($_POST[$key] ?? '')); + } + + if ($form['proposal_id'] === '') { + $form['proposal_id'] = portalGenerateUniqueProposalId(); + } + + if ($form['request_id'] === '' || $form['client_name'] === '' || $form['client_email'] === '') { + $error = 'Request ID, client name, and client email are required.'; + } else { + $action = trim((string)($_POST['action'] ?? 'save_draft')); + + // Status logic + if ($action === 'send_to_customer') { + $form['proposal_status'] = 'sent'; + $form['sent_at'] = date('c'); + } else { + if (!isset($proposalStatuses[$form['proposal_status']])) { + $form['proposal_status'] = 'draft'; + } + } + + // Parse milestones + $numMs = max(0, min(5, (int)$form['num_milestones'])); + $form['num_milestones'] = (string)$numMs; + $milestones = []; + for ($i = 0; $i < $numMs; $i++) { + $milestones[] = [ + 'milestone_name' => trim((string)($_POST['ms_name_' . $i] ?? '')), + 'milestone_description' => trim((string)($_POST['ms_desc_' . $i] ?? '')), + 'milestone_deliverables'=> trim((string)($_POST['ms_deliv_' . $i] ?? '')), + 'milestone_amount' => trim((string)($_POST['ms_amount_' . $i] ?? '')), + 'milestone_trigger' => trim((string)($_POST['ms_trigger_' . $i] ?? '')), + 'milestone_status' => trim((string)($_POST['ms_status_' . $i] ?? 'not_started')), + ]; + } + + $record = $form; + $record['milestones'] = $milestones; + $record['updated_at'] = date('c'); + if (empty($record['created_at'])) { + $record['created_at'] = date('c'); + } + // Remove legacy field aliases + unset($record['status']); + + $proposals = portalLoadProposals(); + $found = false; + foreach ($proposals as &$item) { + if ((string)($item['proposal_id'] ?? '') === $record['proposal_id']) { + $record['created_at'] = (string)($item['created_at'] ?? $record['created_at']); + $item = $record; + $found = true; + break; + } + } + unset($item); + if (!$found) { + $proposals[] = $record; + } + portalSaveProposals($proposals); + + // Update linked request + $requests = portalLoadProjectRequests(); + foreach ($requests as &$req) { + if (portalGetRequestDisplayId((array)$req) !== $record['request_id']) { continue; } + $ids = isset($req['proposal_ids']) && is_array($req['proposal_ids']) ? $req['proposal_ids'] : []; + if (!in_array($record['proposal_id'], $ids, true)) { + $ids[] = $record['proposal_id']; + } + $req['proposal_ids'] = array_values($ids); + if ($action === 'send_to_customer') { + $req['status'] = 'proposal_sent'; + } elseif (in_array($req['status'] ?? '', ['new', 'reviewing'], true)) { + $req['status'] = 'proposal_drafted'; + } + break; + } + unset($req); + portalSaveProjectRequests($requests); + + if ($action === 'send_to_customer') { + $host = $_SERVER['HTTP_HOST'] ?? 'runlevel.systems'; + $clientLink = 'https://' . $host . '/client/proposals.php'; + send_project_proposal_email($record['client_email'], $record['client_name'], $record['request_id'], $clientLink); + $notice = 'Proposal sent to customer.'; + } else { + $notice = 'Proposal saved.'; + } + + $proposal = $record; + $form = $record; + $existingMilestones = $milestones; + } +} + +$numMsForm = max(0, min(5, (int)$form['num_milestones'])); + +$current_page = 'staff-portal'; +$header_class = 'inner-header'; +?> + + + + + + + <?php echo $form['proposal_id'] !== '' ? 'Edit Proposal' : 'New Proposal'; ?> | Staff | Runlevel Systems + + + + + + +
+
+
+ ← All Proposals + +
+ +
+
+ +
+
+

+

Runlevel Systems — Proposal Editor

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +

If 0: single start payment + final delivery. If 1+: milestone payments apply after start payment.

+
+ +
+ +
+
Milestone
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ onchange="toggleContractField(this)"> + +
+ +
+ +
+
+ + + + View Proposal + + All Proposals +
+
+
+
+
+ + + + + + diff --git a/staff/proposal-send.php b/staff/proposal-send.php new file mode 100644 index 0000000..20953ae --- /dev/null +++ b/staff/proposal-send.php @@ -0,0 +1,141 @@ + + + + + + + + Send Proposal | Staff | Runlevel Systems + + + + + + +
+
+ ← All Proposals + +
+
+ + +
+

Send Proposal to Customer

+
Proposal
+
Customer
+
Customer Email
+
Total Price
+
Amount Due To Start
+ +
+ +
+ + Review First + Edit Proposal +
+
+
+ + + +
+

The proposal has been sent. The customer will see it in their client dashboard.

+ +
+ +
+
+ + + + + diff --git a/staff/proposal-view.php b/staff/proposal-view.php new file mode 100644 index 0000000..f28e2f6 --- /dev/null +++ b/staff/proposal-view.php @@ -0,0 +1,198 @@ + 'Draft', + 'sent' => 'Sent', + 'changes_requested'=> 'Changes Requested', + 'accepted' => 'Accepted', + 'payment_pending' => 'Payment Pending', + 'paid_start' => 'Paid — Ready to Start', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', +]; + +$milestoneStatuses = [ + 'not_started' => 'Not Started', + 'in_progress' => 'In Progress', + 'ready_for_review' => 'Ready for Review', + 'payment_due' => 'Payment Due', + 'paid' => 'Paid', + 'complete' => 'Complete', +]; + +$changeRequests = (isset($proposal['change_requests']) && is_array($proposal['change_requests'])) ? $proposal['change_requests'] : []; + +$current_page = 'staff-portal'; +$header_class = 'inner-header'; +?> + + + + + + + View Proposal | Staff | Runlevel Systems + + + + + + +
+
+
+ ← All Proposals + +
+ + +

Proposal not found.

+ + +
+
+
+

+
Proposal ID:
+
+ + +
+ + +
+ +
+ +
+
Request ID
+
Client Name
+
Client Email
+
Total Price
+
Amount Due To Start
+
Timeline
+
Created
+
Last Updated
+
Sent At
+
Accepted At
+
+
+ +
+ +
Request Summary
+
Staff Review Notes PRIVATE
+
Proposed Work
+
Deliverables
+
Out of Scope
+
+ + + +
+ +

Amount Due To Start: (due before work begins)

+ $ms): ?> +
+
Milestone :
+
+
Amount
+
Trigger
+
Status
+
+
Description
+
Deliverables
+
+ +
+ + +
+ +
Payment Terms
+
Customer Responsibilities
+
Third-Party Costs
+
Revision / Change Terms
+
Acceptance Statement
+ +
Contract
+ +
Governing Terms
This proposal is governed by the Runlevel Systems Terms of Service.
+ +
+ + +
+ + +
+
+ Change Request + + +
+
+
Requested budget:
+
Requested timeline:
+
Deliverable change:
+
+ + Review & Update Proposal → +
+ + + +
+
+ + + + + diff --git a/staff/proposals.php b/staff/proposals.php new file mode 100644 index 0000000..36ffc3f --- /dev/null +++ b/staff/proposals.php @@ -0,0 +1,179 @@ + $tb; +}); + +$statusLabels = [ + 'draft' => 'Draft', + 'sent' => 'Sent', + 'changes_requested'=> 'Changes Requested', + 'accepted' => 'Accepted', + 'payment_pending' => 'Payment Pending', + 'paid_start' => 'Paid — Ready', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', +]; + +$statusColors = [ + 'draft' => '#7a9ac0', + 'sent' => '#36f3ff', + 'changes_requested'=> '#ffc600', + 'accepted' => '#86efac', + 'payment_pending' => '#fbbf24', + 'paid_start' => '#34d399', + 'in_progress' => '#60a5fa', + 'completed' => '#4ade80', + 'cancelled' => '#ef4444', +]; + +$notice = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $pid = trim((string)($_POST['proposal_id'] ?? '')); + $action = trim((string)($_POST['action'] ?? '')); + $proposals = portalLoadProposals(); + foreach ($proposals as &$p) { + if ((string)($p['proposal_id'] ?? '') !== $pid) { continue; } + if ($action === 'cancel') { + $p['proposal_status'] = 'cancelled'; + $p['updated_at'] = date('c'); + $notice = 'Proposal cancelled.'; + } elseif ($action === 'mark_accepted') { + $p['proposal_status'] = 'accepted'; + $p['accepted_at'] = date('c'); + $p['updated_at'] = date('c'); + $notice = 'Proposal marked accepted.'; + } + break; + } + unset($p); + portalSaveProposals($proposals); + $proposals = portalLoadProposals(); +} + +$current_page = 'staff-portal'; +$header_class = 'inner-header'; +?> + + + + + + + Proposals | Staff | Runlevel Systems + + + + + + +
+
+
+
+

Proposals

+

All client proposals.

+
+ +
+ +
+ +
+ +

No proposals yet. Create one →

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Proposal IDProject TitleClientStatusTotalDue To StartUpdatedActions
0 ? pe(date('M j, Y', $updatedTs)) : '—'; ?> + View + Edit + + Send + + +
+ + +
+ + + Record Payment + + +
+ + +
+ +
+ +
+
+
+ + + + +