Compare commits

...

12 Commits

Author SHA1 Message Date
myron c6a382a378 Add copyright notice 2026-07-27 04:55:23 +00:00
myron f84a754370 Fix critical unauthenticated order tampering/IDOR in api/orders.php and reused named-param bug in admin order search 2026-07-06 07:51:53 +00:00
Myron Blair 3c8e4d1dbc Add in-admin refund processing via Square RefundPayment API
admin/order.php previously had no real refund action - the "refunded"
status option was purely cosmetic, only setting order_status without
touching payment_status or calling any payment API. Adds a Refund card
(full or partial amount, optional reason) that calls the real Square
Refunds API for orders paid via Square, updates payment_status
(refunded/partially_refunded) and order_status, logs the refund ID as an
order note, and restores any wallet_amount_used back to the customer
wallet on a full refund (mirroring how it was deducted on payment
success in markSquarePaymentResult). Also fixed the Payment sidebar card
to display square_payment_id (it only ever showed the old stripe_payment_intent
field, even for Square orders).

Tested in sandbox: full refund (wallet restore verified), and confirmed
Squares own over-refund rejection surfaces cleanly as a flash error
rather than a crash.
2026-07-05 15:30:15 +00:00
Myron Blair bdd0bd6afa Require admin auth on api/search-customers.php
Was callable anonymously, leaking customer email/phone/wallet balance/reward
points to anyone who could guess a search term. Gated behind AdminAuth,
matching the pattern used elsewhere (401 JSON response, not a redirect,
since this is an API endpoint called via fetch from admin/pos.php).
2026-07-05 15:23:33 +00:00
Myron Blair 9771d53b19 Show wallet/gift card discount line on order confirmation page
Total already reflected the discount correctly, but subtotal + shipping
did not reconcile with it visually since the discount was never shown as
its own line item - matches the same row already present on checkout.php.
2026-07-05 15:19:47 +00:00
myron e02436618c Merge square-migration: Stripe to Square payment processor migration, checkout wallet/gift-card redemption, and related bug fixes 2026-07-05 15:01:30 +00:00
Myron Blair cfbae6e945 Fix duplicate PDO named parameter in api/search-customers.php
Same bug class as the awardPoints() fix - :q was reused three times in one
WHERE clause, which fails under real (non-emulated) prepared statements.
Split into distinct :q1/:q2/:q3 placeholders each bound to the same value.

(The other flagged file, admin/import-export.php, turned out to be a false
positive from the earlier scan - the duplicate ":checked"/"::before" matches
were CSS pseudo-selectors inside a <style> block, not SQL placeholders.)
2026-07-05 15:01:01 +00:00
Myron Blair 30daef5f74 Wire up wallet balance and gift cards at checkout; fix Square gift card option; fix account/rewards.php CSS; fix real PDO/schema bugs found along the way
Feature: checkout.php now lets logged-in customers apply existing wallet
balance or redeem a gift card code (which tops up wallet first, then
applies) toward their order total. Deduction is deferred to payment
success (markSquarePaymentResult in includes/square.php), never at order
creation, so an abandoned checkout never loses real wallet money - mirrors
how loyalty points already work here, unlike stock which is decremented
eagerly. payment.php gains a second Square Gift Card tab (payments.giftCard()
SDK method) alongside the card form, both hitting the same
create-square-payment.php endpoint since Square treats both source types
identically.

New api/apply-wallet-credit.php validates/quotes an amount without writing
anything - actual spend happens only via markSquarePaymentResult(). The
gift-card-to-wallet transaction logic was extracted out of
api/redeem-gift-card.php into a shared loyalty()->redeemGiftCardToWallet()
so the Wallet page and checkout both call the same code.

Also fixed three unrelated pre-existing bugs surfaced while testing this:
- loyalty.php awardPoints() reused the same named PDO parameter (:points)
  twice in one UPDATE - fails under real prepared statements, meaning
  loyalty points (and the email sent right after them) were silently never
  awarded for any order tied to a logged-in customer.
- redeemGiftCardToWallet (formerly inline in redeem-gift-card.php) referenced
  a gift_cards.updated_at column that does not exist in the schema, and used
  invalid enum values (gift_card_transactions.type=redeem,
  wallet_transactions.type=gift_card) that do not match the actual enum
  definitions - gift card redemption has likely never worked at all.
- account/rewards.php was missing the  line that loads
  account.css, unlike every other account/*.php page, so its sidebar/layout
  rendered unstyled.
2026-07-05 14:53:47 +00:00
Myron Blair 11edf3394f Add Square payment processor, gated behind PAYMENT_PROCESSOR flag (default: stripe)
Consolidates payment processing onto the same Square account already used by
tomtomgames.com and parkerslingshotrentals.com. Collapses the two prior parallel
Stripe flows (hosted Checkout + embedded Elements) into a single Square Web
Payments SDK flow, since Payments API is synchronous and removes the original
reason for two paths.

- includes/square.php: squareApi() cURL helper (mirrors the pattern already
  used on parkerslingshotrentals.com), markSquarePaymentResult() as the single
  source of truth for order completion shared by the sync response, webhook,
  and reconciliation poll - fixes a pre-existing bug where loyalty points were
  only ever awarded from the polling endpoint, never from the webhook.
- api/create-square-payment.php replaces api/create-payment-intent.php;
  api/create-checkout-session.php deleted (no Square equivalent - single flow).
- api/webhook.php rewritten for Squares signature scheme and event types.
- api/payment-status.php repurposed to reconciliation-only fallback.
- payment.php branches on PAYMENT_PROCESSOR so Stripe and Square code coexist
  deployed while dormant - flipping one config constant is the cutover/rollback.
- admin/payments.php: added a Square settings card alongside the existing
  (now legacy-labeled) Stripe card.
- db/schema.sql + live DB: added square_payment_id/square_order_id columns,
  stripe_* columns kept for historical orders.

Not yet cut over - PAYMENT_PROCESSOR still defaults to stripe in
config-secrets.php (outside this repo). Sandbox testing still needed before
flipping to square/production.
2026-07-05 05:27:19 +00:00
Myron Blair a0b8bf2a09 security: remove stray !install!! deploy folder, block .py files, move secrets out of webroot
- Delete !install!!/ (schema.sql, migrations, fix_tjj.py) - unreferenced leftover
  deploy artifacts, fix_tjj.py was publicly downloadable (200) since the folder
  name did not match the existing /install/ block pattern
- .htaccess: add .py to blocked extensions, explicitly block the !install!!/ path
- config/config.php: replace with a require shim pointing to config-secrets.php
  outside the web root (Stripe/CyberMail keys no longer sit in a web-servable file)
2026-07-05 05:18:50 +00:00
myron a4ebf26a48 Harden .git/config/includes/db block: RedirectMatch not honored, use mod_rewrite REQUEST_URI matching 2026-07-04 15:57:18 -05:00
myron 186ac0cb6d security: fix unauthenticated file upload/RCE risk, TLS bypass, XSS, and broken gift-card/review columns
- admin/api/upload-splash.php had NO admin-auth check (included the public
  customer header, not admin/includes/header.php) and both upload endpoints
  trusted the client-supplied MIME type and filename extension, so an
  attacker could name a file "shell.php", spoof Content-Type: image/png,
  and get PHP written into a web-reachable uploads/ directory. Added
  AdminAuth check and centralized real-content validation (getimagesize +
  server-side extension mapping) in a new handleImageUpload() helper used
  by both admin/upload-image.php and admin/api/upload-splash.php.
- Removed CURLOPT_SSL_VERIFYPEER => false from the CyberMail email calls
  in includes/email.php and includes/functions.php (MITM risk on the API
  key). Rewrote functions.php's sendEmail() as a thin wrapper around
  Email::send() so there's one implementation instead of two that could
  drift (this is what had the second copy of the TLS bypass).
- Escaped customer-controlled fields (customer_name, tracking info, reset
  URL) before interpolating into outbound HTML emails — name is free text
  from registration/checkout with no length/char restriction, so it was
  stored-HTML-injectable into every transactional email.
- Fixed api/redeem-gift-card.php referencing a nonexistent `balance` column
  on gift_cards (actual column is current_balance) — gift card redemption
  was completely broken, always returning "no remaining balance".
- Fixed api/submit-review.php inserting into nonexistent `content`/`status`
  columns on reviews (actual columns are `comment`/`is_approved`) — review
  submission was crashing on every request.
- Hardened .htaccess: block /db/*, /.git/*, and *.sql. Live site currently
  serves db/schema.sql and the full .git directory (including .git/config,
  which contains a GitHub PAT with push access) over HTTP — the existing
  config/includes RedirectMatch rules are also not being enforced live,
  see report for details; this needs a server-level fix too.
- Cleaned up README's leftover install instructions pointing at a deleted
  create-admin.php with a documented default password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:22:50 -05:00
32 changed files with 1353 additions and 1473 deletions
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
import subprocess, os
BASE = '/home/tomsjavajive.com/public_html'
# ── Fix 1: Remove wholesale and careers from footer ──────────
print("[1] Fixing footer...")
f = BASE + '/includes/footer.php'
with open(f) as fh:
c = fh.read()
before = len(c)
c = c.replace(' <li><a href="/wholesale.php">Wholesale</a></li>\n', '')
c = c.replace(' <li><a href="/careers.php">Careers</a></li>\n', '')
# Also try without leading spaces
c = c.replace('<li><a href="/wholesale.php">Wholesale</a></li>\n', '')
c = c.replace('<li><a href="/careers.php">Careers</a></li>\n', '')
with open(f, 'w') as fh:
fh.write(c)
removed = before - len(c)
print(f" Footer: removed {removed} chars (wholesale + careers)")
# ── Fix 2: Check account pages for errors ───────────────────
print("\n[2] Checking account pages...")
for page in ['rewards', 'wishlist', 'reviews']:
path = f'{BASE}/account/{page}.php'
r = subprocess.run(
['/usr/local/lsws/lsphp85/bin/php', '-d', 'display_errors=1',
'-d', 'error_reporting=32767', path],
capture_output=True, text=True
)
output = r.stdout + r.stderr
errors = [l for l in output.split('\n')
if any(x in l for x in ['Fatal','Error','Undefined','undefined','Call to'])]
if errors:
print(f" {page}.php ERRORS:")
for e in errors[:3]:
print(f" {e[:120]}")
else:
# Check if page has actual HTML content
has_html = '<' in r.stdout and len(r.stdout) > 100
print(f" {page}.php: {'OK - has content' if has_html else 'WARNING - minimal output'}")
# ── Fix 3: Check admin customers Modal ──────────────────────
print("\n[3] Checking admin customers...")
f2 = BASE + '/admin/customers.php'
with open(f2) as fh:
c2 = fh.read()
print(f" Modal.open present: {'Modal.open' in c2}")
print(f" openCustomerModal present: {'openCustomerModal' in c2}")
print(f" admin.js exists: {os.path.exists(BASE+'/admin/assets/admin.js')}")
# Check if admin.js has Modal object
if os.path.exists(BASE+'/admin/assets/admin.js'):
with open(BASE+'/admin/assets/admin.js') as fh:
js = fh.read()
print(f" Modal object in admin.js: {'Modal' in js}")
print(f" admin.js size: {len(js)} chars")
else:
print(" admin.js MISSING - this is why customer modal doesn't work!")
# Create a basic Modal object
modal_js = """
// Modal helper
const Modal = {
open: function(id) {
const el = document.getElementById(id);
if (el) { el.style.display = 'flex'; el.classList.add('active'); }
},
close: function(id) {
const el = document.getElementById(id);
if (el) { el.style.display = 'none'; el.classList.remove('active'); }
}
};
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => {
m.style.display = 'none'; m.classList.remove('active');
});
}
});
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
e.target.style.display = 'none'; e.target.classList.remove('active');
}
});
"""
os.makedirs(BASE+'/admin/assets', exist_ok=True)
with open(BASE+'/admin/assets/admin.js', 'w') as fh:
fh.write(modal_js)
print(" Created admin.js with Modal object")
# ── Fix 4: Check account page includes ─────────────────────
print("\n[4] Checking account page structure...")
for page in ['rewards', 'wishlist', 'reviews']:
path = f'{BASE}/account/{page}.php'
with open(path) as fh:
content = fh.read()
has_auth = 'CustomerAuth::require' in content
has_header = "require_once" in content and 'header' in content
has_footer = 'footer' in content
print(f" {page}.php: auth={has_auth}, header={has_header}, footer={has_footer}, size={len(content)}")
print("\nDone!")
-37
View File
@@ -1,37 +0,0 @@
-- Migration: Add wishlist table and addresses column to customers
-- Run this in phpMyAdmin to add the new features
-- Add addresses column to customers table
ALTER TABLE `customers` ADD COLUMN `addresses` JSON DEFAULT NULL AFTER `billing_address`;
-- Add preferences column to customers table
ALTER TABLE `customers` ADD COLUMN `preferences` JSON DEFAULT NULL AFTER `addresses`;
-- Add is_active column to customers table if not exists
ALTER TABLE `customers` ADD COLUMN `is_active` TINYINT(1) DEFAULT 1 AFTER `preferences`;
-- Create wishlist table
CREATE TABLE IF NOT EXISTS `wishlist` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`customer_id` VARCHAR(50) NOT NULL,
`product_id` VARCHAR(50) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `unique_wishlist` (`customer_id`, `product_id`),
INDEX `idx_customer` (`customer_id`),
INDEX `idx_product` (`product_id`),
FOREIGN KEY (`customer_id`) REFERENCES `customers`(`customer_id`) ON DELETE CASCADE,
FOREIGN KEY (`product_id`) REFERENCES `products`(`product_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add reorder_level column to products if not exists
ALTER TABLE `products` ADD COLUMN `reorder_level` INT DEFAULT 10 AFTER `low_stock_threshold`;
-- Add slug column to products if not exists
ALTER TABLE `products` ADD COLUMN `slug` VARCHAR(255) DEFAULT NULL AFTER `name`;
ALTER TABLE `products` ADD INDEX `idx_slug` (`slug`);
-- Update existing products to have slugs based on name
UPDATE `products` SET `slug` = LOWER(REPLACE(REPLACE(REPLACE(`name`, ' ', '-'), "'", ''), '"', '')) WHERE `slug` IS NULL;
-- Add is_pos_order to orders table
ALTER TABLE `orders` ADD COLUMN `is_pos_order` TINYINT(1) DEFAULT 0 AFTER `notes`;
-96
View File
@@ -1,96 +0,0 @@
-- Migration: Add tables for push notifications, loyalty program, and integration settings
-- Run this in phpMyAdmin
-- Push notification subscriptions
CREATE TABLE IF NOT EXISTS `push_subscriptions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`customer_id` VARCHAR(50) DEFAULT NULL,
`endpoint` TEXT NOT NULL,
`p256dh_key` VARCHAR(255) NOT NULL,
`auth_key` VARCHAR(255) NOT NULL,
`is_active` TINYINT(1) DEFAULT 1,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_customer` (`customer_id`),
INDEX `idx_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Push notifications queue
CREATE TABLE IF NOT EXISTS `push_notifications` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`notification_id` VARCHAR(50) NOT NULL UNIQUE,
`subscription_endpoint` TEXT NOT NULL,
`payload` TEXT NOT NULL,
`status` ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
`error_message` TEXT DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`sent_at` TIMESTAMP NULL DEFAULT NULL,
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Loyalty transactions history
CREATE TABLE IF NOT EXISTS `loyalty_transactions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`transaction_id` VARCHAR(50) NOT NULL UNIQUE,
`customer_id` VARCHAR(50) NOT NULL,
`points` INT NOT NULL,
`type` ENUM('earn', 'redeem', 'tier_upgrade', 'birthday_bonus', 'referral_bonus', 'referral_welcome', 'adjustment', 'expiry') NOT NULL,
`description` VARCHAR(255) DEFAULT NULL,
`reference_amount` DECIMAL(10,2) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_customer` (`customer_id`),
INDEX `idx_type` (`type`),
INDEX `idx_created` (`created_at`),
FOREIGN KEY (`customer_id`) REFERENCES `customers`(`customer_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add lifetime_points and loyalty_tier to customers
ALTER TABLE `customers`
ADD COLUMN IF NOT EXISTS `lifetime_points` INT DEFAULT 0 AFTER `reward_points`,
ADD COLUMN IF NOT EXISTS `loyalty_tier` ENUM('bronze', 'silver', 'gold', 'platinum') DEFAULT 'bronze' AFTER `lifetime_points`,
ADD COLUMN IF NOT EXISTS `birthday` DATE DEFAULT NULL AFTER `loyalty_tier`,
ADD COLUMN IF NOT EXISTS `referral_code` VARCHAR(20) DEFAULT NULL AFTER `birthday`,
ADD COLUMN IF NOT EXISTS `referred_by` VARCHAR(50) DEFAULT NULL AFTER `referral_code`;
-- Add unique index on referral_code
ALTER TABLE `customers` ADD UNIQUE INDEX IF NOT EXISTS `idx_referral_code` (`referral_code`);
-- Update settings table with integration keys (INSERT IGNORE to not overwrite existing)
INSERT IGNORE INTO `settings` (`setting_key`, `setting_value`, `updated_at`) VALUES
('sendgrid_api_key', '', NOW()),
('sendgrid_from_email', 'noreply@tomsjavajive.com', NOW()),
('sendgrid_from_name', 'Tom''s Java Jive', NOW()),
('twilio_account_sid', '', NOW()),
('twilio_auth_token', '', NOW()),
('twilio_phone_number', '', NOW()),
('vapid_public_key', '', NOW()),
('vapid_private_key', '', NOW()),
('loyalty_enabled', '1', NOW()),
('email_notifications_enabled', '1', NOW()),
('sms_notifications_enabled', '0', NOW()),
('push_notifications_enabled', '1', NOW());
-- Add Stripe checkout session column to orders
ALTER TABLE `orders` ADD COLUMN IF NOT EXISTS `stripe_checkout_session` VARCHAR(255) DEFAULT NULL AFTER `stripe_payment_intent`;
-- Payment transactions table for tracking payment attempts
CREATE TABLE IF NOT EXISTS `payment_transactions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`transaction_id` VARCHAR(50) NOT NULL UNIQUE,
`order_id` VARCHAR(50) NOT NULL,
`customer_id` VARCHAR(50) DEFAULT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`currency` VARCHAR(3) DEFAULT 'USD',
`payment_method` VARCHAR(50) DEFAULT 'stripe',
`stripe_session_id` VARCHAR(255) DEFAULT NULL,
`stripe_payment_intent` VARCHAR(255) DEFAULT NULL,
`status` ENUM('initiated', 'pending', 'processing', 'succeeded', 'failed', 'cancelled', 'refunded') DEFAULT 'initiated',
`metadata` JSON DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_order` (`order_id`),
INDEX `idx_customer` (`customer_id`),
INDEX `idx_status` (`status`),
INDEX `idx_stripe_session` (`stripe_session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-402
View File
@@ -1,402 +0,0 @@
-- Tom's Java Jive - MySQL Database Schema
-- Version: 1.0
-- Compatible with MySQL 8.0+
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET AUTOCOMMIT = 0;
START TRANSACTION;
SET time_zone = "+00:00";
-- --------------------------------------------------------
-- Database Schema for Tom's Java Jive
-- NOTE: Database must already exist in cPanel
-- Select your database in phpMyAdmin before importing
-- --------------------------------------------------------
-- --------------------------------------------------------
-- Table: settings
-- --------------------------------------------------------
CREATE TABLE `settings` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`setting_key` VARCHAR(100) NOT NULL UNIQUE,
`setting_value` JSON,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: admin_users
-- --------------------------------------------------------
CREATE TABLE `admin_users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` VARCHAR(50) NOT NULL UNIQUE,
`email` VARCHAR(255) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) DEFAULT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`picture` VARCHAR(500) DEFAULT NULL,
`is_admin` TINYINT(1) DEFAULT 1,
`is_master` TINYINT(1) DEFAULT 0,
`permissions` JSON DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`last_login` TIMESTAMP NULL,
INDEX `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: customers
-- --------------------------------------------------------
CREATE TABLE `customers` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`customer_id` VARCHAR(50) NOT NULL UNIQUE,
`email` VARCHAR(255) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) DEFAULT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`phone` VARCHAR(50) DEFAULT NULL,
`shipping_address` JSON DEFAULT NULL,
`billing_address` JSON DEFAULT NULL,
`wallet_balance` DECIMAL(10,2) DEFAULT 0.00,
`reward_points` INT DEFAULT 0,
`is_guest` TINYINT(1) DEFAULT 0,
`created_via` VARCHAR(50) DEFAULT 'web',
`email_verified` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_email` (`email`),
INDEX `idx_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: products
-- --------------------------------------------------------
CREATE TABLE `products` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`product_id` VARCHAR(50) NOT NULL UNIQUE,
`name` VARCHAR(255) NOT NULL,
`description` TEXT,
`price` DECIMAL(10,2) NOT NULL,
`sale_price` DECIMAL(10,2) DEFAULT NULL,
`cost_price` DECIMAL(10,2) DEFAULT NULL,
`sku` VARCHAR(100) DEFAULT NULL,
`barcode` VARCHAR(100) DEFAULT NULL,
`category` VARCHAR(100) DEFAULT NULL,
`tags` JSON DEFAULT NULL,
`images` JSON DEFAULT NULL,
`stock` INT DEFAULT 0,
`low_stock_threshold` INT DEFAULT 10,
`weight` DECIMAL(10,2) DEFAULT NULL,
`dimensions` JSON DEFAULT NULL,
`is_active` TINYINT(1) DEFAULT 1,
`is_featured` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_product_id` (`product_id`),
INDEX `idx_category` (`category`),
INDEX `idx_is_active` (`is_active`),
FULLTEXT INDEX `idx_search` (`name`, `description`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: orders
-- --------------------------------------------------------
CREATE TABLE `orders` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`order_id` VARCHAR(50) NOT NULL UNIQUE,
`order_number` VARCHAR(20) NOT NULL UNIQUE,
`customer_id` VARCHAR(50) DEFAULT NULL,
`customer_email` VARCHAR(255) NOT NULL,
`customer_name` VARCHAR(255) DEFAULT NULL,
`customer_phone` VARCHAR(50) DEFAULT NULL,
`items` JSON NOT NULL,
`subtotal` DECIMAL(10,2) NOT NULL,
`shipping_cost` DECIMAL(10,2) DEFAULT 0.00,
`tax` DECIMAL(10,2) DEFAULT 0.00,
`discount` DECIMAL(10,2) DEFAULT 0.00,
`gift_card_discount` DECIMAL(10,2) DEFAULT 0.00,
`wallet_amount_used` DECIMAL(10,2) DEFAULT 0.00,
`total` DECIMAL(10,2) NOT NULL,
`shipping_address` JSON DEFAULT NULL,
`billing_address` JSON DEFAULT NULL,
`shipping_method` VARCHAR(50) DEFAULT NULL,
`payment_method` VARCHAR(50) DEFAULT NULL,
`payment_status` ENUM('pending', 'paid', 'failed', 'refunded', 'partially_refunded') DEFAULT 'pending',
`order_status` ENUM('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded') DEFAULT 'pending',
`stripe_session_id` VARCHAR(255) DEFAULT NULL,
`stripe_payment_intent` VARCHAR(255) DEFAULT NULL,
`tracking_number` VARCHAR(100) DEFAULT NULL,
`tracking_url` VARCHAR(500) DEFAULT NULL,
`notes` TEXT DEFAULT NULL,
`is_pos_order` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_order_id` (`order_id`),
INDEX `idx_customer_id` (`customer_id`),
INDEX `idx_customer_email` (`customer_email`),
INDEX `idx_order_status` (`order_status`),
INDEX `idx_payment_status` (`payment_status`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: order_items (normalized for reporting)
-- --------------------------------------------------------
CREATE TABLE `order_items` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`order_id` VARCHAR(50) NOT NULL,
`product_id` VARCHAR(50) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`price` DECIMAL(10,2) NOT NULL,
`quantity` INT NOT NULL,
`total` DECIMAL(10,2) NOT NULL,
INDEX `idx_order_id` (`order_id`),
INDEX `idx_product_id` (`product_id`),
FOREIGN KEY (`order_id`) REFERENCES `orders`(`order_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: gift_cards
-- --------------------------------------------------------
CREATE TABLE `gift_cards` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`gift_card_id` VARCHAR(50) NOT NULL UNIQUE,
`code` VARCHAR(20) NOT NULL UNIQUE,
`initial_balance` DECIMAL(10,2) NOT NULL,
`current_balance` DECIMAL(10,2) NOT NULL,
`purchaser_email` VARCHAR(255) DEFAULT NULL,
`recipient_email` VARCHAR(255) DEFAULT NULL,
`recipient_name` VARCHAR(255) DEFAULT NULL,
`message` TEXT DEFAULT NULL,
`is_active` TINYINT(1) DEFAULT 1,
`expires_at` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_code` (`code`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: gift_card_transactions
-- --------------------------------------------------------
CREATE TABLE `gift_card_transactions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`gift_card_id` VARCHAR(50) NOT NULL,
`order_id` VARCHAR(50) DEFAULT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`balance_after` DECIMAL(10,2) NOT NULL,
`type` ENUM('purchase', 'redemption', 'refund') NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_gift_card_id` (`gift_card_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: wallet_transactions
-- --------------------------------------------------------
CREATE TABLE `wallet_transactions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`transaction_id` VARCHAR(50) NOT NULL UNIQUE,
`customer_id` VARCHAR(50) NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`balance_after` DECIMAL(10,2) NOT NULL,
`type` ENUM('deposit', 'withdrawal', 'purchase', 'refund', 'reward') NOT NULL,
`description` VARCHAR(255) DEFAULT NULL,
`order_id` VARCHAR(50) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_customer_id` (`customer_id`),
INDEX `idx_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: reviews
-- --------------------------------------------------------
CREATE TABLE `reviews` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`review_id` VARCHAR(50) NOT NULL UNIQUE,
`product_id` VARCHAR(50) NOT NULL,
`customer_id` VARCHAR(50) DEFAULT NULL,
`customer_name` VARCHAR(255) NOT NULL,
`customer_email` VARCHAR(255) NOT NULL,
`rating` INT NOT NULL CHECK (rating >= 1 AND rating <= 5),
`title` VARCHAR(255) DEFAULT NULL,
`comment` TEXT,
`is_verified_purchase` TINYINT(1) DEFAULT 0,
`is_approved` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_product_id` (`product_id`),
INDEX `idx_is_approved` (`is_approved`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: email_campaigns
-- --------------------------------------------------------
CREATE TABLE `email_campaigns` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`campaign_id` VARCHAR(50) NOT NULL UNIQUE,
`name` VARCHAR(255) NOT NULL,
`subject` VARCHAR(255) NOT NULL,
`content` TEXT NOT NULL,
`recipient_type` ENUM('all', 'customers_only', 'subscribers_only') DEFAULT 'all',
`status` ENUM('draft', 'scheduled', 'sent', 'cancelled') DEFAULT 'draft',
`scheduled_at` TIMESTAMP NULL,
`sent_at` TIMESTAMP NULL,
`recipients_count` INT DEFAULT 0,
`opened_count` INT DEFAULT 0,
`clicked_count` INT DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: email_subscribers
-- --------------------------------------------------------
CREATE TABLE `email_subscribers` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL UNIQUE,
`name` VARCHAR(255) DEFAULT NULL,
`is_active` TINYINT(1) DEFAULT 1,
`source` VARCHAR(50) DEFAULT 'website',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: abandoned_carts
-- --------------------------------------------------------
CREATE TABLE `abandoned_carts` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`cart_id` VARCHAR(50) NOT NULL UNIQUE,
`customer_id` VARCHAR(50) DEFAULT NULL,
`customer_email` VARCHAR(255) DEFAULT NULL,
`items` JSON NOT NULL,
`subtotal` DECIMAL(10,2) NOT NULL,
`recovery_email_sent` TINYINT(1) DEFAULT 0,
`recovered` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_customer_email` (`customer_email`),
INDEX `idx_recovered` (`recovered`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: referrals
-- --------------------------------------------------------
CREATE TABLE `referrals` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`referral_id` VARCHAR(50) NOT NULL UNIQUE,
`referrer_customer_id` VARCHAR(50) NOT NULL,
`referral_code` VARCHAR(20) NOT NULL UNIQUE,
`referred_customer_id` VARCHAR(50) DEFAULT NULL,
`referred_email` VARCHAR(255) DEFAULT NULL,
`status` ENUM('pending', 'completed', 'expired') DEFAULT 'pending',
`reward_amount` DECIMAL(10,2) DEFAULT 5.00,
`reward_given` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_referral_code` (`referral_code`),
INDEX `idx_referrer` (`referrer_customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: visitor_sessions
-- --------------------------------------------------------
CREATE TABLE `visitor_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`session_id` VARCHAR(100) NOT NULL UNIQUE,
`visitor_id` VARCHAR(50) NOT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`user_agent` TEXT DEFAULT NULL,
`current_page` VARCHAR(500) DEFAULT NULL,
`referrer` VARCHAR(500) DEFAULT NULL,
`country` VARCHAR(100) DEFAULT NULL,
`city` VARCHAR(100) DEFAULT NULL,
`is_active` TINYINT(1) DEFAULT 1,
`page_views` INT DEFAULT 1,
`started_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`last_activity` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_is_active` (`is_active`),
INDEX `idx_last_activity` (`last_activity`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: categories
-- --------------------------------------------------------
CREATE TABLE `categories` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`category_id` VARCHAR(50) NOT NULL UNIQUE,
`name` VARCHAR(255) NOT NULL,
`slug` VARCHAR(255) NOT NULL UNIQUE,
`description` TEXT DEFAULT NULL,
`image` VARCHAR(500) DEFAULT NULL,
`parent_id` VARCHAR(50) DEFAULT NULL,
`sort_order` INT DEFAULT 0,
`is_active` TINYINT(1) DEFAULT 1,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_slug` (`slug`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: coupons
-- --------------------------------------------------------
CREATE TABLE `coupons` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`coupon_id` VARCHAR(50) NOT NULL UNIQUE,
`code` VARCHAR(50) NOT NULL UNIQUE,
`discount_type` ENUM('percentage', 'fixed') NOT NULL DEFAULT 'percentage',
`discount_value` DECIMAL(10,2) NOT NULL,
`min_order_amount` DECIMAL(10,2) DEFAULT NULL,
`max_uses` INT DEFAULT NULL,
`times_used` INT DEFAULT 0,
`is_active` TINYINT(1) DEFAULT 1,
`starts_at` TIMESTAMP NULL,
`expires_at` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_code` (`code`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: password_reset_tokens
-- --------------------------------------------------------
CREATE TABLE `password_reset_tokens` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`token` VARCHAR(255) NOT NULL,
`user_type` ENUM('admin', 'customer') NOT NULL,
`expires_at` TIMESTAMP NOT NULL,
`used` TINYINT(1) DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_token` (`token`),
INDEX `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Table: sessions
-- --------------------------------------------------------
CREATE TABLE `sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`session_id` VARCHAR(128) NOT NULL UNIQUE,
`user_id` VARCHAR(50) DEFAULT NULL,
`user_type` ENUM('admin', 'customer') DEFAULT NULL,
`data` TEXT,
`ip_address` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`expires_at` TIMESTAMP NOT NULL,
INDEX `idx_session_id` (`session_id`),
INDEX `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Insert default settings
-- --------------------------------------------------------
INSERT INTO `settings` (`setting_key`, `setting_value`) VALUES
('store_name', '"Tom\'s Java Jive"'),
('store_email', '"support@tomsjavajive.com"'),
('store_phone', '""'),
('store_address', '""'),
('currency', '"USD"'),
('currency_symbol', '"$"'),
('tax_rate', '0'),
('shipping', '{"flat_rate_enabled": true, "flat_rate_amount": 5.99, "free_shipping_threshold": 50, "weight_based_enabled": false}'),
('payment', '{"stripe_enabled": true, "paypal_enabled": false, "cod_enabled": false}'),
('email', '{"sendgrid_api_key": "", "sender_email": "noreply@tomsjavajive.com", "sender_name": "Tom\'s Java Jive"}');
COMMIT;
+35
View File
@@ -3,6 +3,17 @@
# Enable URL rewriting # Enable URL rewriting
RewriteEngine On RewriteEngine On
# Block sensitive paths outright. RedirectMatch/FilesMatch/Require/Order
# directives were confirmed NOT to be honored on this LiteSpeed setup (config/,
# includes/*.php, and .git/ were all still reachable live despite the
# RedirectMatch rules further down) — only mod_rewrite matching on REQUEST_URI
# is confirmed to actually work here. This must come before other rewrites.
RewriteCond %{REQUEST_URI} ^/(\.git|config/|includes/.*\.php|install/|!install!!/|db/)
RewriteRule .* - [F,L]
RewriteCond %{REQUEST_URI} \.sql$ [OR]
RewriteCond %{REQUEST_URI} \.py$
RewriteRule .* - [F,L]
# Force HTTPS (uncomment in production) # Force HTTPS (uncomment in production)
RewriteCond %{HTTPS} off RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
@@ -16,6 +27,30 @@ RewriteRule ^ %1 [L,R=301]
RedirectMatch 403 /config/.*$ RedirectMatch 403 /config/.*$
RedirectMatch 403 /includes/.*\.php$ RedirectMatch 403 /includes/.*\.php$
RedirectMatch 403 /install/.*$ RedirectMatch 403 /install/.*$
RedirectMatch 403 /db/.*$
# Block .git (full source + commit history, including any credentials ever
# committed to it) from ever being served. NOTE: on the live server this
# repo's .git directory lives inside the docroot itself (deployed via
# `git clone` directly into public_html) and was confirmed reachable over
# HTTP (.git/config, .git/logs/HEAD, etc. all returned real content) even
# though the RedirectMatch rules above were also confirmed NOT blocking
# /config/*.php or /includes/*.php live — meaning .htaccess directives are
# not being fully honored by the current web server. This needs a server
# config fix (vhost-level deny, or moving .git outside the docroot) in
# addition to this file — see review notes.
RedirectMatch 403 /\.git/.*$
RedirectMatch 403 /\.git$
<FilesMatch "\.sql$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# Set default charset # Set default charset
AddDefaultCharset UTF-8 AddDefaultCharset UTF-8
+1
View File
@@ -0,0 +1 @@
Property of TomTom Enterprises.
+4 -4
View File
@@ -75,10 +75,10 @@ A complete e-commerce platform built with **PHP 8.4** and **MySQL 8.0** for cPan
``` ```
6. **Create Admin User** 6. **Create Admin User**
- Visit: `https://yoursite.com/create-admin.php` - `create-admin.php` and the seeded default admin account have been removed —
- Or import the default admin from schema.sql: insert your own row into `admin_users` directly (via phpMyAdmin/CLI) with a
- Email: `admin@tomsjavajive.com` bcrypt hash from `password_hash($password, PASSWORD_BCRYPT, ['cost' => 12])`.
- Password: `admin123!` Never leave a well-known default password (e.g. `admin123!`) in place.
7. **Configure Site URL** 7. **Configure Site URL**
- Edit `config/config.php`: - Edit `config/config.php`:
+1
View File
@@ -37,6 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['redeem_points'])) {
redirect('/account/rewards.php'); redirect('/account/rewards.php');
} }
$extraHead = '<link rel="stylesheet" href="/assets/css/account.css?v='. filemtime(__DIR__ . '/../assets/css/account.css') .'">';
require_once __DIR__ . '/../includes/header.php'; require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php'; require_once __DIR__ . '/includes/sidebar.php';
?> ?>
+12 -20
View File
@@ -1,29 +1,21 @@
<?php <?php
require_once __DIR__ . '/../includes/header.php'; require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['image'])) { if (!AdminAuth::getUser()) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'No file received']); exit; echo json_encode(['error' => 'No file received']); exit;
} }
$file = $_FILES['image']; $result = handleImageUpload('image', __DIR__ . '/../../uploads/splashes/', 'splash_');
$allowed = ['image/jpeg','image/png','image/gif','image/webp'];
if (!in_array($file['type'], $allowed)) {
echo json_encode(['error' => 'Invalid type. Use JPG, PNG, WebP or GIF.']); exit;
}
if ($file['size'] > 5 * 1024 * 1024) {
echo json_encode(['error' => 'File too large (max 5 MB).']); exit;
}
$dir = __DIR__ . '/../../uploads/splashes/'; if (isset($result['success'])) {
if (!is_dir($dir)) mkdir($dir, 0755, true); echo json_encode(['success' => true, 'url' => '/uploads/splashes/' . $result['filename']]);
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$name = 'splash_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$path = $dir . $name;
if (move_uploaded_file($file['tmp_name'], $path)) {
echo json_encode(['success' => true, 'url' => '/uploads/splashes/' . $name]);
} else { } else {
echo json_encode(['error' => 'Could not save file.']); echo json_encode(['error' => $result['error']]);
} }
+119 -3
View File
@@ -5,6 +5,7 @@
ob_start(); ob_start();
$pageTitle = 'Order Details'; $pageTitle = 'Order Details';
require_once __DIR__ . '/includes/header.php'; require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/../includes/square.php';
$orderId = $_GET['id'] ?? ''; $orderId = $_GET['id'] ?? '';
@@ -53,12 +54,87 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit; exit;
} }
} }
if ($action === 'process_refund') {
$refundAmount = (float) ($_POST['refund_amount'] ?? 0);
$reason = trim($_POST['refund_reason'] ?? '');
if ($order['payment_method'] !== 'square' || empty($order['square_payment_id'])) {
setFlash('error', 'This order has no Square payment to refund.');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
if ($order['payment_status'] !== 'paid' && $order['payment_status'] !== 'partially_refunded') {
setFlash('error', 'Only paid orders can be refunded.');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
if ($refundAmount <= 0 || $refundAmount > (float) $order['total']) {
setFlash('error', 'Invalid refund amount.');
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
try {
$result = squareRefundPayment($order['square_payment_id'], $refundAmount, $reason ?: ('Order #' . $order['order_number']));
$refund = $result['refund'] ?? [];
$refundStatus = $refund['status'] ?? '';
if (in_array($refundStatus, ['PENDING', 'COMPLETED'], true)) {
$isFullRefund = $refundAmount >= (float) $order['total'];
$newPaymentStatus = $isFullRefund ? 'refunded' : 'partially_refunded';
db()->update('orders',
['payment_status' => $newPaymentStatus, 'order_status' => $isFullRefund ? 'refunded' : $order['order_status']],
'order_id = :id',
['id' => $orderId]
);
// Restore any wallet credit that was applied, proportionally on a full refund
if ($isFullRefund && !empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0 && !empty($order['customer_id'])) {
$walletAmount = (float) $order['wallet_amount_used'];
$cust = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $order['customer_id']]);
if ($cust) {
$newBalance = (float) $cust['wallet_balance'] + $walletAmount;
db()->update('customers', ['wallet_balance' => $newBalance], 'customer_id = :id', ['id' => $order['customer_id']]);
db()->insert('wallet_transactions', [
'transaction_id' => generateId('wt_'),
'customer_id' => $order['customer_id'],
'amount' => $walletAmount,
'balance_after' => $newBalance,
'type' => 'refund',
'description' => 'Refund for Order #' . $order['order_number'],
]);
}
}
$existingNotes = $order['notes'] ?? '';
$newNote = '[' . date('M j, Y g:i A') . '] Refunded ' . formatCurrency($refundAmount)
. ' via Square (refund ID: ' . ($refund['id'] ?? 'unknown') . ')'
. ($reason ? ' — ' . $reason : '');
$allNotes = $existingNotes ? $existingNotes . "\n" . $newNote : $newNote;
db()->update('orders', ['notes' => $allNotes], 'order_id = :id', ['id' => $orderId]);
setFlash('success', formatCurrency($refundAmount) . ' refunded successfully.');
} else {
setFlash('error', 'Refund did not complete (status: ' . $refundStatus . ').');
}
} catch (Exception $e) {
setFlash('error', 'Refund failed: ' . $e->getMessage());
}
header('Location: /admin/order.php?id=' . $orderId);
exit;
}
} }
$items = json_decode($order['items'], true) ?? []; $items = json_decode($order['items'], true) ?? [];
$shippingAddress = json_decode($order['shipping_address'], true) ?? []; $shippingAddress = json_decode($order['shipping_address'], true) ?? [];
$statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; $statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
$canRefund = $order['payment_method'] === 'square'
&& !empty($order['square_payment_id'])
&& in_array($order['payment_status'], ['paid', 'partially_refunded'], true);
?> ?>
<div class="page-header"> <div class="page-header">
@@ -81,6 +157,9 @@ $statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'canc
<?php if (hasFlash('success')): ?> <?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div> <div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;"> <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<!-- Main Column --> <!-- Main Column -->
@@ -141,6 +220,12 @@ $statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'canc
<td style="text-align: right; color: var(--admin-success);">-<?= formatCurrency($order['discount']) ?></td> <td style="text-align: right; color: var(--admin-success);">-<?= formatCurrency($order['discount']) ?></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php if (($order['wallet_amount_used'] ?? 0) > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Wallet / Gift Card</td>
<td style="text-align: right; color: var(--admin-success);">-<?= formatCurrency($order['wallet_amount_used']) ?></td>
</tr>
<?php endif; ?>
<tr style="font-size: 1.125rem; font-weight: 600;"> <tr style="font-size: 1.125rem; font-weight: 600;">
<td colspan="3" style="text-align: right;">Total</td> <td colspan="3" style="text-align: right;">Total</td>
<td style="text-align: right;"><?= formatCurrency($order['total']) ?></td> <td style="text-align: right;"><?= formatCurrency($order['total']) ?></td>
@@ -173,6 +258,32 @@ $statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'canc
</form> </form>
</div> </div>
</div> </div>
<?php if ($canRefund): ?>
<!-- Refund -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Process Refund</h3>
</div>
<div class="admin-card-body">
<form method="POST" onsubmit="return confirm('Refund ' + document.getElementById('refund_amount').value + ' via Square? This cannot be undone.');">
<input type="hidden" name="action" value="process_refund">
<div class="form-group">
<label class="form-label">Amount</label>
<input type="number" step="0.01" min="0.01" max="<?= $order['total'] ?>" name="refund_amount" id="refund_amount" class="form-input" value="<?= $order['total'] ?>" required>
<small class="text-muted">Up to <?= formatCurrency($order['total']) ?> (full order total). Enter a lower amount for a partial refund.</small>
</div>
<div class="form-group">
<label class="form-label">Reason (optional)</label>
<input type="text" name="refund_reason" class="form-input" placeholder="e.g. damaged item, customer request">
</div>
<button type="submit" class="btn btn-secondary btn-block">
<i class="fas fa-undo"></i> Refund via Square
</button>
</form>
</div>
</div>
<?php endif; ?>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
@@ -260,13 +371,18 @@ $statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'canc
$paymentClass = match($order['payment_status']) { $paymentClass = match($order['payment_status']) {
'paid' => 'success', 'paid' => 'success',
'failed' => 'error', 'failed' => 'error',
'refunded' => 'warning', 'refunded', 'partially_refunded' => 'warning',
default => 'primary' default => 'primary'
}; };
?> ?>
<span class="badge badge-<?= $paymentClass ?>"><?= ucfirst($order['payment_status']) ?></span> <span class="badge badge-<?= $paymentClass ?>"><?= ucfirst(str_replace('_', ' ', $order['payment_status'])) ?></span>
</div> </div>
<?php if ($order['stripe_payment_intent']): ?> <?php if (!empty($order['square_payment_id'])): ?>
<div style="display: flex; justify-content: space-between;">
<span>Square Payment ID</span>
<span class="text-muted" style="font-size: 0.8rem;"><?= htmlspecialchars(substr($order['square_payment_id'], 0, 20)) ?></span>
</div>
<?php elseif ($order['stripe_payment_intent']): ?>
<div style="display: flex; justify-content: space-between;"> <div style="display: flex; justify-content: space-between;">
<span>Stripe ID</span> <span>Stripe ID</span>
<span class="text-muted" style="font-size: 0.8rem;"><?= htmlspecialchars(substr($order['stripe_payment_intent'], 0, 20)) ?>...</span> <span class="text-muted" style="font-size: 0.8rem;"><?= htmlspecialchars(substr($order['stripe_payment_intent'], 0, 20)) ?>...</span>
+4 -2
View File
@@ -45,8 +45,10 @@ if ($status) {
} }
if ($search) { if ($search) {
$where[] = '(order_number LIKE :search OR customer_name LIKE :search OR customer_email LIKE :search)'; $where[] = '(order_number LIKE :search1 OR customer_name LIKE :search2 OR customer_email LIKE :search3)';
$params['search'] = '%' . $search . '%'; $params['search1'] = '%' . $search . '%';
$params['search2'] = '%' . $search . '%';
$params['search3'] = '%' . $search . '%';
} }
if ($dateFrom) { if ($dateFrom) {
+79 -2
View File
@@ -21,6 +21,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
setFlash('success', 'Stripe settings updated'); setFlash('success', 'Stripe settings updated');
} }
if ($section === 'square') {
setSetting('payment_square', [
'enabled' => isset($_POST['square_enabled']),
'sandbox' => isset($_POST['square_sandbox']),
'app_id' => trim($_POST['square_app_id'] ?? ''),
'location_id' => trim($_POST['square_location_id'] ?? ''),
'access_token' => trim($_POST['square_access_token'] ?? ''),
'webhook_signature_key' => trim($_POST['square_webhook_signature_key'] ?? '')
]);
setFlash('success', 'Square settings updated');
}
if ($section === 'methods') { if ($section === 'methods') {
setSetting('payment_methods', [ setSetting('payment_methods', [
'card' => isset($_POST['method_card']), 'card' => isset($_POST['method_card']),
@@ -43,6 +55,15 @@ $stripe = getSetting('payment_stripe', [
'webhook_secret' => '' 'webhook_secret' => ''
]); ]);
$square = getSetting('payment_square', [
'enabled' => defined('PAYMENT_PROCESSOR') && PAYMENT_PROCESSOR === 'square',
'sandbox' => defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox',
'app_id' => defined('SQUARE_APP_ID') ? SQUARE_APP_ID : '',
'location_id' => defined('SQUARE_LOCATION_ID') ? SQUARE_LOCATION_ID : '',
'access_token' => defined('SQUARE_ACCESS_TOKEN') ? SQUARE_ACCESS_TOKEN : '',
'webhook_signature_key' => defined('SQUARE_WEBHOOK_SIGNATURE_KEY') ? SQUARE_WEBHOOK_SIGNATURE_KEY : ''
]);
$methods = getSetting('payment_methods', [ $methods = getSetting('payment_methods', [
'card' => true, 'card' => true,
'cash' => true, 'cash' => true,
@@ -72,12 +93,68 @@ $methods = getSetting('payment_methods', [
</div> </div>
<div> <div>
<!-- Stripe Settings --> <!-- Square Settings -->
<form method="POST">
<input type="hidden" name="section" value="square">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title"><i class="fas fa-square" style="color: #006AFF;"></i> Square</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="square_enabled" <?= $square['enabled'] ? 'checked' : '' ?>>
Enable Square payments
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="square_sandbox" <?= $square['sandbox'] ? 'checked' : '' ?>>
Sandbox mode (use sandbox app/location/token)
</label>
</div>
<div class="form-group">
<label class="form-label">Application ID</label>
<input type="text" name="square_app_id" class="form-input"
value="<?= htmlspecialchars($square['app_id']) ?>"
placeholder="sq0idp-...">
</div>
<div class="form-group">
<label class="form-label">Location ID</label>
<input type="text" name="square_location_id" class="form-input"
value="<?= htmlspecialchars($square['location_id']) ?>"
placeholder="L...">
</div>
<div class="form-group">
<label class="form-label">Access Token</label>
<input type="password" name="square_access_token" class="form-input"
value="<?= htmlspecialchars($square['access_token']) ?>"
placeholder="EAAA...">
</div>
<div class="form-group mb-0">
<label class="form-label">Webhook Signature Key</label>
<input type="password" name="square_webhook_signature_key" class="form-input"
value="<?= htmlspecialchars($square['webhook_signature_key']) ?>"
placeholder="...">
<small class="text-muted">From the Square Developer Dashboard's webhook subscription for this site</small>
</div>
<button type="submit" class="btn btn-primary mt-2">Save Square Settings</button>
</div>
</div>
</form>
<!-- Stripe Settings (legacy) -->
<form method="POST"> <form method="POST">
<input type="hidden" name="section" value="stripe"> <input type="hidden" name="section" value="stripe">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"> <div class="admin-card-header">
<h3 class="admin-card-title"><i class="fab fa-stripe" style="color: #635BFF;"></i> Stripe</h3> <h3 class="admin-card-title"><i class="fab fa-stripe" style="color: #635BFF;"></i> Stripe <small class="text-muted">(legacy)</small></h3>
</div> </div>
<div class="admin-card-body"> <div class="admin-card-body">
<div class="form-group"> <div class="form-group">
+5 -26
View File
@@ -13,36 +13,15 @@ if (!AdminAuth::getUser()) {
exit; exit;
} }
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['image'])) { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'No file received']); echo json_encode(['error' => 'No file received']);
exit; exit;
} }
$file = $_FILES['image']; $result = handleImageUpload('image', __DIR__ . '/../uploads/products/', 'product_');
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$maxSize = 5 * 1024 * 1024; // 5MB
if (!in_array($file['type'], $allowedTypes)) { if (isset($result['success'])) {
echo json_encode(['error' => 'Invalid file type. Use JPG, PNG, WebP, or GIF.']); echo json_encode(['success' => true, 'url' => '/uploads/products/' . $result['filename']]);
exit;
}
if ($file['size'] > $maxSize) {
echo json_encode(['error' => 'File too large. Maximum 5MB.']);
exit;
}
$uploadDir = __DIR__ . '/../uploads/products/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filename = 'product_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$filepath = $uploadDir . $filename;
if (move_uploaded_file($file['tmp_name'], $filepath)) {
echo json_encode(['success' => true, 'url' => '/uploads/products/' . $filename]);
} else { } else {
echo json_encode(['error' => 'Failed to save file. Check directory permissions.']); echo json_encode(['error' => $result['error']]);
} }
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Tom's Java Jive - Apply Wallet Credit / Gift Card at Checkout
*
* Validates and quotes an amount to apply toward the current order total.
* Does NOT deduct wallet balance here - that only happens once the Square
* payment actually succeeds (see markSquarePaymentResult() in
* includes/square.php), so an abandoned checkout never loses a customer's
* real wallet money. A gift card code, if given, is redeemed into wallet
* balance immediately (same as the Wallet page does) since that step is
* safe/reversible-in-spirit - only the *spending* of wallet balance at
* checkout is deferred to payment success.
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/loyalty.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
if (!CustomerAuth::isLoggedIn()) {
jsonResponse(['error' => 'Please log in to use wallet balance or a gift card'], 401);
}
$customer = CustomerAuth::getFullUser();
$input = json_decode(file_get_contents('php://input'), true);
$orderTotal = (float) ($input['order_total'] ?? 0);
if ($orderTotal <= 0) {
jsonResponse(['error' => 'Invalid order total'], 400);
}
$giftCardCode = trim($input['gift_card_code'] ?? '');
if (!empty($giftCardCode)) {
$redeemResult = loyalty()->redeemGiftCardToWallet($customer['customer_id'], $giftCardCode);
if (!$redeemResult['success']) {
jsonResponse(['error' => $redeemResult['error']], 400);
}
$walletBalance = $redeemResult['new_balance'];
} else {
$fresh = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]);
$walletBalance = (float) ($fresh['wallet_balance'] ?? 0);
}
$requested = isset($input['amount']) ? (float) $input['amount'] : $walletBalance;
$applyAmount = max(0, round(min($requested, $walletBalance, $orderTotal), 2));
jsonResponse([
'success' => true,
'apply_amount' => $applyAmount,
'wallet_balance' => $walletBalance,
'new_total' => round($orderTotal - $applyAmount, 2)
]);
-129
View File
@@ -1,129 +0,0 @@
<?php
/**
* Tom's Java Jive - Create Stripe Checkout Session API
* Uses hosted checkout page (redirects to Stripe)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
require_once __DIR__ . '/../includes/loyalty.php';
header('Content-Type: application/json');
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
$orderId = $input['order_id'] ?? '';
$originUrl = $input['origin_url'] ?? '';
if (empty($orderId)) {
jsonResponse(['error' => 'Order ID required'], 400);
}
if (empty($originUrl)) {
$originUrl = SITE_URL;
}
// Get order
$order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId]
);
if (!$order) {
jsonResponse(['error' => 'Order not found'], 404);
}
if ($order['payment_status'] === 'paid') {
jsonResponse(['error' => 'Order already paid'], 400);
}
// Check if Stripe is configured
if (!isStripeConfigured()) {
// Demo mode - simulate successful payment
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed',
'stripe_payment_intent' => 'demo_' . bin2hex(random_bytes(8))
],
'order_id = :id',
['id' => $orderId]
);
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$orderId
);
}
jsonResponse([
'demo_mode' => true,
'message' => 'Payment simulated (Stripe not configured)',
'redirect' => '/order-confirmation.php?order=' . $orderId
]);
}
// Build line items from order
$items = json_decode($order['items'], true) ?? [];
$lineItems = [];
foreach ($items as $item) {
$lineItems[] = [
'name' => $item['name'],
'price' => floatval($item['price']),
'quantity' => intval($item['quantity']),
'currency' => 'usd'
];
}
// Add shipping if applicable
if ($order['shipping_cost'] > 0) {
$lineItems[] = [
'name' => 'Shipping',
'price' => floatval($order['shipping_cost']),
'quantity' => 1,
'currency' => 'usd'
];
}
// Build success/cancel URLs
$successUrl = rtrim($originUrl, '/') . '/order-confirmation.php?order=' . $orderId . '&session_id={CHECKOUT_SESSION_ID}';
$cancelUrl = rtrim($originUrl, '/') . '/payment.php?order=' . $orderId . '&cancelled=1';
try {
$session = stripe()->createCheckoutSession(
$lineItems,
$successUrl,
$cancelUrl,
[
'customer_email' => $order['customer_email'],
'metadata' => [
'order_id' => $orderId,
'order_number' => $order['order_number']
]
]
);
// Store checkout session ID
db()->update('orders',
['stripe_session_id' => $session['id']],
'order_id = :id',
['id' => $orderId]
);
jsonResponse([
'url' => $session['url'],
'session_id' => $session['id']
]);
} catch (Exception $e) {
error_log('Stripe Checkout error: ' . $e->getMessage());
jsonResponse(['error' => 'Failed to create checkout session: ' . $e->getMessage()], 500);
}
-87
View File
@@ -1,87 +0,0 @@
<?php
/**
* Tom's Java Jive - Create Stripe Payment Intent API
* Uses cURL-based Stripe integration (no Composer required)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
header('Content-Type: application/json');
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
$orderId = $input['order_id'] ?? '';
if (empty($orderId)) {
jsonResponse(['error' => 'Order ID required'], 400);
}
// Get order
$order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId]
);
if (!$order) {
jsonResponse(['error' => 'Order not found'], 404);
}
if ($order['payment_status'] === 'paid') {
jsonResponse(['error' => 'Order already paid'], 400);
}
// Check if Stripe is configured
if (!isStripeConfigured()) {
// Demo mode - simulate successful payment
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed',
'stripe_payment_intent' => 'demo_' . bin2hex(random_bytes(8))
],
'order_id = :id',
['id' => $orderId]
);
jsonResponse([
'demo_mode' => true,
'message' => 'Payment simulated (Stripe not configured)',
'redirect' => '/order-confirmation.php?order=' . $orderId
]);
}
// Create Stripe Payment Intent using cURL-based API
try {
$paymentIntent = stripe()->createPaymentIntent(
$order['total'],
'usd',
[
'metadata' => [
'order_id' => $orderId,
'order_number' => $order['order_number']
],
'receipt_email' => $order['customer_email'],
'description' => 'Order #' . $order['order_number']
]
);
// Store payment intent ID
db()->update('orders',
['stripe_payment_intent' => $paymentIntent['id']],
'order_id = :id',
['id' => $orderId]
);
jsonResponse([
'client_secret' => $paymentIntent['client_secret']
]);
} catch (Exception $e) {
error_log('Stripe error: ' . $e->getMessage());
jsonResponse(['error' => 'Payment initialization failed: ' . $e->getMessage()], 500);
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* Tom's Java Jive - Create Square Payment API
* Accepts a Web Payments SDK card nonce (source_id) and charges it synchronously.
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/square.php';
require_once __DIR__ . '/../includes/loyalty.php';
require_once __DIR__ . '/../includes/email.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
$orderId = $input['order_id'] ?? '';
$sourceId = $input['source_id'] ?? '';
if (empty($orderId)) {
jsonResponse(['error' => 'Order ID required'], 400);
}
$order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId]
);
if (!$order) {
jsonResponse(['error' => 'Order not found'], 404);
}
if ($order['payment_status'] === 'paid') {
jsonResponse(['error' => 'Order already paid'], 400);
}
// Demo mode - Square not configured
if (!isSquareConfigured()) {
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed',
'square_payment_id' => 'demo_' . bin2hex(random_bytes(8)),
'payment_method' => 'square',
],
'order_id = :id',
['id' => $orderId]
);
jsonResponse([
'demo_mode' => true,
'message' => 'Payment simulated (Square not configured)',
'redirect' => '/order-confirmation.php?order=' . $orderId
]);
}
if (empty($sourceId)) {
jsonResponse(['error' => 'Card details required'], 400);
}
try {
$result = squareCreatePayment(
$sourceId,
(float) $order['total'],
$orderId,
['note' => 'Order #' . $order['order_number']]
);
$payment = $result['payment'] ?? [];
$status = $payment['status'] ?? '';
if ($status === 'COMPLETED') {
markSquarePaymentResult($payment['id'], $orderId, 'COMPLETED');
jsonResponse([
'success' => true,
'redirect' => '/order-confirmation.php?order=' . $orderId
]);
}
// Store the payment id even if not yet completed, so the reconciliation
// poll and webhook can find this order by square_payment_id.
db()->update('orders',
['square_payment_id' => $payment['id'] ?? null],
'order_id = :id',
['id' => $orderId]
);
jsonResponse(['error' => 'Payment was not completed (status: ' . $status . ')'], 400);
} catch (Exception $e) {
error_log('Square payment error: ' . $e->getMessage());
jsonResponse(['error' => 'Payment failed: ' . $e->getMessage()], 500);
}
+10 -1
View File
@@ -28,6 +28,13 @@ switch ($method) {
jsonResponse(['error' => 'Order not found'], 404); jsonResponse(['error' => 'Order not found'], 404);
} }
// Only the admin or the customer who placed this order may view it by raw id.
$customer = CustomerAuth::getUser();
$isOwner = $customer && (string)$customer['customer_id'] === (string)$order['customer_id'];
if (!AdminAuth::isLoggedIn() && !$isOwner) {
jsonResponse(['error' => 'Unauthorized'], 401);
}
$order['items'] = json_decode($order['items'], true); $order['items'] = json_decode($order['items'], true);
$order['shipping_address'] = json_decode($order['shipping_address'], true); $order['shipping_address'] = json_decode($order['shipping_address'], true);
unset($order['id']); unset($order['id']);
@@ -71,7 +78,9 @@ switch ($method) {
case 'POST': case 'POST':
// Update order status (admin) // Update order status (admin)
if ($action === 'update_status') { if ($action === 'update_status') {
// Admin check would go here if (!AdminAuth::isLoggedIn()) {
jsonResponse(['error' => 'Unauthorized'], 401);
}
$orderId = $input['order_id'] ?? ''; $orderId = $input['order_id'] ?? '';
$status = $input['status'] ?? ''; $status = $input['status'] ?? '';
$trackingNumber = $input['tracking_number'] ?? null; $trackingNumber = $input['tracking_number'] ?? null;
+38 -102
View File
@@ -1,45 +1,39 @@
<?php <?php
/** /**
* Tom's Java Jive - Check Payment Status API * Tom's Java Jive - Check Payment Status API
* Polls Stripe for payment/checkout session status * Reconciliation-only fallback: Square payments complete synchronously in
* create-square-payment.php, so this endpoint only matters if the browser
* lost that response after Square had already accepted the charge. It shares
* markSquarePaymentResult() with the webhook and the synchronous path so
* loyalty points/emails are never duplicated no matter which path resolves first.
*/ */
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php'; require_once __DIR__ . '/../includes/square.php';
require_once __DIR__ . '/../includes/loyalty.php'; require_once __DIR__ . '/../includes/loyalty.php';
require_once __DIR__ . '/../includes/email.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
// Only accept GET
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
$orderId = $_GET['order_id'] ?? ''; $orderId = $_GET['order_id'] ?? '';
$sessionId = $_GET['session_id'] ?? '';
if (empty($orderId) && empty($sessionId)) { if (empty($orderId)) {
jsonResponse(['error' => 'Order ID or Session ID required'], 400); jsonResponse(['error' => 'Order ID required'], 400);
} }
// Get order by ID or session
if (!empty($orderId)) {
$order = db()->fetch( $order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id", "SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId] ['id' => $orderId]
); );
} else {
$order = db()->fetch(
"SELECT * FROM orders WHERE stripe_session_id = :session OR stripe_payment_intent = :session",
['session' => $sessionId]
);
}
if (!$order) { if (!$order) {
jsonResponse(['error' => 'Order not found'], 404); jsonResponse(['error' => 'Order not found'], 404);
} }
// If already marked as paid, return success
if ($order['payment_status'] === 'paid') { if ($order['payment_status'] === 'paid') {
jsonResponse([ jsonResponse([
'status' => 'complete', 'status' => 'complete',
@@ -50,105 +44,47 @@ if ($order['payment_status'] === 'paid') {
]); ]);
} }
// Check if Stripe is configured if (!isSquareConfigured()) {
if (!isStripeConfigured()) {
jsonResponse([ jsonResponse([
'status' => 'demo_mode', 'status' => 'demo_mode',
'payment_status' => $order['payment_status'], 'payment_status' => $order['payment_status'],
'message' => 'Stripe not configured - running in demo mode' 'message' => 'Square not configured - running in demo mode'
]); ]);
} }
try { if (empty($order['square_payment_id'])) {
// Check with Stripe
if (!empty($order['stripe_session_id'])) {
// Check checkout session status
$session = stripe()->getCheckoutSession($order['stripe_session_id']);
if ($session['payment_status'] === 'paid') {
// Update order
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed',
'stripe_payment_intent' => $session['payment_intent'] ?? null
],
'order_id = :id',
['id' => $order['order_id']]
);
// Award loyalty points
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$order['order_id']
);
}
jsonResponse([
'status' => 'complete',
'payment_status' => 'paid',
'order_id' => $order['order_id'],
'order_number' => $order['order_number'],
'redirect' => '/order-confirmation.php?order=' . $order['order_id']
]);
}
jsonResponse([
'status' => $session['status'],
'payment_status' => $session['payment_status']
]);
} elseif (!empty($order['stripe_payment_intent'])) {
// Check payment intent status
$paymentIntent = stripe()->getPaymentIntent($order['stripe_payment_intent']);
if ($paymentIntent['status'] === 'succeeded') {
// Update order
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed'
],
'order_id = :id',
['id' => $order['order_id']]
);
// Award loyalty points
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$order['order_id']
);
}
jsonResponse([
'status' => 'complete',
'payment_status' => 'paid',
'order_id' => $order['order_id'],
'order_number' => $order['order_number'],
'redirect' => '/order-confirmation.php?order=' . $order['order_id']
]);
}
jsonResponse([
'status' => $paymentIntent['status'],
'payment_status' => 'pending'
]);
}
// No Stripe reference found
jsonResponse([ jsonResponse([
'status' => 'pending', 'status' => 'pending',
'payment_status' => $order['payment_status'] 'payment_status' => $order['payment_status']
]); ]);
}
try {
$result = squareGetPayment($order['square_payment_id']);
$payment = $result['payment'] ?? [];
$status = $payment['status'] ?? '';
markSquarePaymentResult($order['square_payment_id'], $order['order_id'], $status);
$updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]);
if ($updated['payment_status'] === 'paid') {
jsonResponse([
'status' => 'complete',
'payment_status' => 'paid',
'order_id' => $updated['order_id'],
'order_number' => $updated['order_number'],
'redirect' => '/order-confirmation.php?order=' . $updated['order_id']
]);
}
jsonResponse([
'status' => $status,
'payment_status' => $updated['payment_status']
]);
} catch (Exception $e) { } catch (Exception $e) {
error_log('Payment status check error: ' . $e->getMessage()); error_log('Square payment status check error: ' . $e->getMessage());
jsonResponse([ jsonResponse([
'status' => 'error', 'status' => 'error',
'payment_status' => $order['payment_status'], 'payment_status' => $order['payment_status'],
+7 -70
View File
@@ -7,6 +7,7 @@ header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/loyalty.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
@@ -19,79 +20,15 @@ if (!CustomerAuth::isLoggedIn()) {
$customer = CustomerAuth::getFullUser(); $customer = CustomerAuth::getFullUser();
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$code = strtoupper(str_replace(['-', ' '], '', trim($input['code'] ?? ''))); $result = loyalty()->redeemGiftCardToWallet($customer['customer_id'], $input['code'] ?? '');
if (empty($code) || strlen($code) < 8) { if (!$result['success']) {
jsonResponse(['error' => 'Invalid gift card code'], 400); jsonResponse(['error' => $result['error']], 400);
} }
// Find gift card
$giftCard = db()->fetch(
"SELECT * FROM gift_cards WHERE code = :code AND is_active = 1",
['code' => $code]
);
if (!$giftCard) {
jsonResponse(['error' => 'Gift card not found or already used'], 404);
}
if ($giftCard['balance'] <= 0) {
jsonResponse(['error' => 'This gift card has no remaining balance'], 400);
}
if ($giftCard['expires_at'] && strtotime($giftCard['expires_at']) < time()) {
jsonResponse(['error' => 'This gift card has expired'], 400);
}
$amount = $giftCard['balance'];
try {
// Start transaction
db()->query("START TRANSACTION");
// Update gift card balance to 0
db()->query(
"UPDATE gift_cards SET balance = 0, is_active = 0, updated_at = NOW() WHERE gift_card_id = :id",
['id' => $giftCard['gift_card_id']]
);
// Log gift card transaction
db()->insert('gift_card_transactions', [
'gift_card_id' => $giftCard['gift_card_id'],
'amount' => -$amount,
'balance_after' => 0,
'type' => 'redeem',
'description' => 'Redeemed by customer: ' . $customer['email']
]);
// Add to customer wallet
$newWalletBalance = ($customer['wallet_balance'] ?? 0) + $amount;
db()->query(
"UPDATE customers SET wallet_balance = :balance, updated_at = NOW() WHERE customer_id = :id",
['balance' => $newWalletBalance, 'id' => $customer['customer_id']]
);
// Log wallet transaction
db()->insert('wallet_transactions', [
'transaction_id' => generateId('wt_'),
'customer_id' => $customer['customer_id'],
'amount' => $amount,
'balance_after' => $newWalletBalance,
'type' => 'gift_card',
'description' => 'Gift card redeemed: ' . $code
]);
db()->query("COMMIT");
jsonResponse([ jsonResponse([
'success' => true, 'success' => true,
'amount' => $amount, 'amount' => $result['amount'],
'new_balance' => $newWalletBalance, 'new_balance' => $result['new_balance'],
'message' => formatCurrency($amount) . ' has been added to your wallet!' 'message' => formatCurrency($result['amount']) . ' has been added to your wallet!'
]); ]);
} catch (Exception $e) {
db()->query("ROLLBACK");
jsonResponse(['error' => 'Failed to redeem gift card. Please try again.'], 500);
}
+7 -2
View File
@@ -6,6 +6,11 @@
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
if (!AdminAuth::isLoggedIn()) {
jsonResponse(['error' => 'Unauthorized'], 401);
}
$query = $_GET['q'] ?? ''; $query = $_GET['q'] ?? '';
@@ -16,10 +21,10 @@ if (strlen($query) < 2) {
$customers = db()->fetchAll( $customers = db()->fetchAll(
"SELECT customer_id, email, name, phone, wallet_balance, reward_points "SELECT customer_id, email, name, phone, wallet_balance, reward_points
FROM customers FROM customers
WHERE (email LIKE :q OR name LIKE :q OR phone LIKE :q) AND is_active = 1 WHERE (email LIKE :q1 OR name LIKE :q2 OR phone LIKE :q3) AND is_active = 1
ORDER BY name ASC ORDER BY name ASC
LIMIT 20", LIMIT 20",
['q' => '%' . $query . '%'] ['q1' => '%' . $query . '%', 'q2' => '%' . $query . '%', 'q3' => '%' . $query . '%']
); );
jsonResponse($customers); jsonResponse($customers);
+2 -2
View File
@@ -55,8 +55,8 @@ db()->insert('reviews', [
'customer_email' => $customer['email'], 'customer_email' => $customer['email'],
'rating' => $rating, 'rating' => $rating,
'title' => $title, 'title' => $title,
'content' => $content, 'comment' => $content,
'status' => 'pending' // Reviews require admin approval 'is_approved' => 0 // Reviews require admin approval
]); ]);
jsonResponse([ jsonResponse([
+30 -73
View File
@@ -1,107 +1,65 @@
<?php <?php
/** /**
* Tom's Java Jive - Stripe Webhook Handler * Tom's Java Jive - Square Webhook Handler
* Uses cURL-based Stripe integration (no Composer required)
*/ */
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php'; require_once __DIR__ . '/../includes/square.php';
require_once __DIR__ . '/../includes/loyalty.php';
require_once __DIR__ . '/../includes/email.php'; require_once __DIR__ . '/../includes/email.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
$payload = file_get_contents('php://input'); $payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; $sigHeader = $_SERVER['HTTP_X_SQUARE_HMACSHA256_SIGNATURE'] ?? '';
$notificationUrl = SITE_URL . '/api/webhook.php';
// Verify webhook signature (if secret is configured) if (empty(SQUARE_WEBHOOK_SIGNATURE_KEY) || SQUARE_WEBHOOK_SIGNATURE_KEY === 'REPLACE_ME') {
if (!empty(STRIPE_WEBHOOK_SECRET) && STRIPE_WEBHOOK_SECRET !== 'whsec_your_webhook_secret') { error_log('Square webhook signature key not configured - rejecting webhook');
try {
stripe()->verifyWebhookSignature($payload, $sigHeader, STRIPE_WEBHOOK_SECRET);
$event = json_decode($payload, true);
} catch (Exception $e) {
error_log('Stripe webhook signature verification failed: ' . $e->getMessage());
http_response_code(400); http_response_code(400);
exit(); exit();
} }
} else {
if (!verifySquareWebhookSignature($payload, $sigHeader, $notificationUrl, SQUARE_WEBHOOK_SIGNATURE_KEY)) {
error_log('Square webhook signature verification failed');
http_response_code(400);
exit();
}
$event = json_decode($payload, true); $event = json_decode($payload, true);
if (!$event) { if (!$event) {
http_response_code(400); http_response_code(400);
exit(); exit();
} }
}
$eventType = $event['type'] ?? ''; $eventType = $event['type'] ?? '';
$data = $event['data']['object'] ?? [];
switch ($eventType) { switch ($eventType) {
case 'checkout.session.completed': case 'payment.updated':
// Stripe Checkout (hosted page) — metadata is on the session case 'payment.created':
$orderId = $data['metadata']['order_id'] ?? ''; $payment = $event['data']['object']['payment'] ?? [];
$paymentIntentId = $data['payment_intent'] ?? ''; $paymentId = $payment['id'] ?? '';
if ($orderId && ($data['payment_status'] ?? '') === 'paid') { $status = $payment['status'] ?? '';
db()->update('orders', $orderId = $payment['reference_id'] ?? null;
[ if ($paymentId && $status) {
'payment_status' => 'paid', markSquarePaymentResult($paymentId, $orderId, $status);
'order_status' => 'confirmed',
'stripe_payment_intent' => $paymentIntentId,
],
'order_id = :id',
['id' => $orderId]
);
$order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]);
if ($order) {
emailService()->sendOrderConfirmation($order);
}
} }
break; break;
case 'payment_intent.succeeded': case 'refund.updated':
// Payment Intent flow (embedded/direct) - skip if already confirmed by checkout.session.completed case 'refund.created':
$paymentIntentId = $data['id'] ?? ''; $refund = $event['data']['object']['refund'] ?? [];
$orderId = $data['metadata']['order_id'] ?? ''; $paymentId = $refund['payment_id'] ?? '';
if ($orderId && ($data['status'] ?? '') === 'succeeded') { $status = $refund['status'] ?? '';
$order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]); if ($status === 'COMPLETED' && $paymentId) {
if ($order && $order['order_status'] !== 'confirmed') {
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed',
'stripe_payment_intent' => $paymentIntentId,
],
'order_id = :id',
['id' => $orderId]
);
$order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]);
if ($order) {
emailService()->sendOrderConfirmation($order);
}
}
}
break;
case 'payment_intent.payment_failed':
$orderId = $data['metadata']['order_id'] ?? '';
if ($orderId) {
db()->update('orders',
['payment_status' => 'failed'],
'order_id = :id',
['id' => $orderId]
);
}
break;
case 'charge.refunded':
$paymentIntentId = $data['payment_intent'] ?? '';
if ($paymentIntentId) {
db()->update('orders', db()->update('orders',
[ [
'payment_status' => 'refunded', 'payment_status' => 'refunded',
'order_status' => 'refunded', 'order_status' => 'refunded',
], ],
'stripe_payment_intent = :pi', 'square_payment_id = :pid',
['pi' => $paymentIntentId] ['pid' => $paymentId]
); );
} }
break; break;
@@ -109,4 +67,3 @@ switch ($eventType) {
http_response_code(200); http_response_code(200);
echo json_encode(['received' => true]); echo json_encode(['received' => true]);
+132 -7
View File
@@ -6,6 +6,7 @@
$pageTitle = "Checkout - Tom's Java Jive"; $pageTitle = "Checkout - Tom's Java Jive";
require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/loyalty.php';
$cart = getCart(); $cart = getCart();
if (empty($cart)) { if (empty($cart)) {
@@ -50,10 +51,8 @@ if ($shippingSettings['flat_rate_enabled'] ?? true) {
} }
} }
$total = $subtotal + $shippingCost; $preDiscountTotal = $subtotal + $shippingCost;
$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe';
// Get Stripe publishable key
$stripeKey = STRIPE_PUBLISHABLE_KEY;
$errors = []; $errors = [];
@@ -75,6 +74,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($state)) $errors['state'] = 'State is required'; if (empty($state)) $errors['state'] = 'State is required';
if (empty($zip)) $errors['zip'] = 'ZIP code is required'; if (empty($zip)) $errors['zip'] = 'ZIP code is required';
// Re-validate any requested wallet credit server-side against the
// customer's current balance - never trust the client-submitted amount.
$walletAmountUsed = 0;
if ($customer && !empty($_POST['wallet_amount_used'])) {
$requestedWallet = (float) $_POST['wallet_amount_used'];
$freshCustomer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]);
$currentBalance = (float) ($freshCustomer['wallet_balance'] ?? 0);
$walletAmountUsed = max(0, round(min($requestedWallet, $currentBalance, $preDiscountTotal), 2));
}
$total = round($preDiscountTotal - $walletAmountUsed, 2);
if (empty($errors)) { if (empty($errors)) {
// Create order // Create order
$orderId = generateId('ord_'); $orderId = generateId('ord_');
@@ -111,6 +122,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'items' => json_encode($orderItems), 'items' => json_encode($orderItems),
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'shipping_cost' => $shippingCost, 'shipping_cost' => $shippingCost,
'wallet_amount_used' => $walletAmountUsed,
'total' => $total, 'total' => $total,
'shipping_address' => json_encode([ 'shipping_address' => json_encode([
'address' => $address, 'address' => $address,
@@ -120,7 +132,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'country' => 'USA' 'country' => 'USA'
]), ]),
'shipping_method' => 'standard', 'shipping_method' => 'standard',
'payment_method' => 'stripe', 'payment_method' => $processor,
'payment_status' => 'pending', 'payment_status' => 'pending',
'order_status' => 'pending' 'order_status' => 'pending'
]); ]);
@@ -167,6 +179,7 @@ require_once __DIR__ . '/includes/header.php';
<h1 style="margin-bottom: 2rem;">Checkout</h1> <h1 style="margin-bottom: 2rem;">Checkout</h1>
<form method="POST" action="" id="checkout-form"> <form method="POST" action="" id="checkout-form">
<input type="hidden" name="wallet_amount_used" id="wallet_amount_used" value="0">
<div class="checkout-layout"> <div class="checkout-layout">
<!-- Customer & Shipping Info --> <!-- Customer & Shipping Info -->
@@ -263,6 +276,38 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</div> </div>
<?php if ($customer): ?>
<!-- Wallet / Gift Card -->
<div class="card mb-2">
<div class="card-header">
<h3 style="margin: 0;">Wallet &amp; Gift Cards</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
Wallet balance: <strong id="wallet-balance-display"><?= formatCurrency($customer['wallet_balance'] ?? 0) ?></strong>
</p>
<?php if (($customer['wallet_balance'] ?? 0) > 0): ?>
<button type="button" id="apply-wallet-btn" class="btn btn-secondary mb-1">
<i class="fas fa-wallet"></i> Apply Wallet Balance
</button>
<?php endif; ?>
<div class="form-group" style="display: flex; gap: 0.5rem; align-items: flex-end;">
<div style="flex: 1;">
<label class="form-label">Gift Card Code</label>
<input type="text" id="gift-card-code" class="form-input"
placeholder="XXXX-XXXX-XXXX" style="text-transform: uppercase;">
</div>
<button type="button" id="apply-gift-card-btn" class="btn btn-secondary">Apply</button>
</div>
<div id="wallet-message" style="margin-top: 0.5rem;"></div>
<div id="wallet-applied-row" style="display: none; margin-top: 0.5rem; font-weight: 600;">
Applied: <span id="wallet-applied-amount"></span>
<button type="button" id="remove-wallet-btn" class="btn btn-secondary" style="margin-left: 0.5rem; padding: 0.15rem 0.6rem; font-size: 0.8rem;">Remove</button>
</div>
</div>
</div>
<?php endif; ?>
<!-- Order Notes --> <!-- Order Notes -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -313,12 +358,16 @@ require_once __DIR__ . '/includes/header.php';
<?php endif; ?> <?php endif; ?>
</span> </span>
</div> </div>
<div class="checkout-summary-row" id="wallet-discount-row" style="display: none;">
<span>Wallet / Gift Card</span>
<span id="wallet-discount-amount" class="text-success"></span>
</div>
<hr style="margin: 1rem 0;"> <hr style="margin: 1rem 0;">
<div class="checkout-summary-total"> <div class="checkout-summary-total">
<span>Total</span> <span>Total</span>
<span><?= formatCurrency($total) ?></span> <span id="checkout-total-display"><?= formatCurrency($preDiscountTotal) ?></span>
</div> </div>
<button type="submit" class="btn btn-primary btn-lg btn-block mt-2"> <button type="submit" class="btn btn-primary btn-lg btn-block mt-2">
@@ -330,7 +379,7 @@ require_once __DIR__ . '/includes/header.php';
</a> </a>
<p class="secure-badge"> <p class="secure-badge">
<i class="fas fa-lock"></i> Secure checkout powered by Stripe <i class="fas fa-lock"></i> Secure checkout powered by <?= $processor === 'square' ? 'Square' : 'Stripe' ?>
</p> </p>
</div> </div>
</div> </div>
@@ -339,4 +388,80 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</section> </section>
<?php if ($customer): ?>
<script>
const ORDER_TOTAL_PRE_DISCOUNT = <?= json_encode($preDiscountTotal) ?>;
const CURRENCY_SYMBOL = <?= json_encode(defined('TJJ_CURRENCY_SYMBOL') ? TJJ_CURRENCY_SYMBOL : '$') ?>;
function fmt(amount) {
return CURRENCY_SYMBOL + Number(amount).toFixed(2);
}
function showWalletMessage(msg, isError) {
const el = document.getElementById('wallet-message');
el.textContent = msg;
el.style.color = isError ? 'var(--color-error)' : 'var(--color-success)';
}
function applyWalletResult(data) {
document.getElementById('wallet_amount_used').value = data.apply_amount;
document.getElementById('checkout-total-display').textContent = fmt(data.new_total);
document.getElementById('wallet-balance-display').textContent = fmt(data.wallet_balance);
if (data.apply_amount > 0) {
document.getElementById('wallet-discount-row').style.display = '';
document.getElementById('wallet-discount-amount').textContent = '-' + fmt(data.apply_amount);
document.getElementById('wallet-applied-row').style.display = '';
document.getElementById('wallet-applied-amount').textContent = fmt(data.apply_amount);
} else {
document.getElementById('wallet-discount-row').style.display = 'none';
document.getElementById('wallet-applied-row').style.display = 'none';
}
}
async function applyCredit(body) {
try {
const response = await fetch('/api/apply-wallet-credit.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ order_total: ORDER_TOTAL_PRE_DISCOUNT }, body))
});
const data = await response.json();
if (data.error) {
showWalletMessage(data.error, true);
return;
}
showWalletMessage('Applied ' + fmt(data.apply_amount) + ' to your order.', false);
applyWalletResult(data);
} catch (err) {
showWalletMessage('Failed to apply credit. Please try again.', true);
}
}
const applyWalletBtn = document.getElementById('apply-wallet-btn');
if (applyWalletBtn) {
applyWalletBtn.addEventListener('click', function() {
applyCredit({});
});
}
document.getElementById('apply-gift-card-btn').addEventListener('click', function() {
const code = document.getElementById('gift-card-code').value.trim();
if (!code) {
showWalletMessage('Enter a gift card code first.', true);
return;
}
applyCredit({ gift_card_code: code });
});
document.getElementById('remove-wallet-btn').addEventListener('click', function() {
document.getElementById('wallet_amount_used').value = 0;
document.getElementById('checkout-total-display').textContent = fmt(ORDER_TOTAL_PRE_DISCOUNT);
document.getElementById('wallet-discount-row').style.display = 'none';
document.getElementById('wallet-applied-row').style.display = 'none';
showWalletMessage('', false);
});
</script>
<?php endif; ?>
<?php require_once __DIR__ . '/includes/footer.php'; ?> <?php require_once __DIR__ . '/includes/footer.php'; ?>
+1 -65
View File
@@ -1,66 +1,2 @@
<?php <?php
/** require_once dirname(__DIR__, 2) . "/config-secrets.php";
* Tom's Java Jive - Main Configuration
*/
// Site Configuration
if (!defined('SITE_NAME')) { define('SITE_NAME', "Tom's Java Jive"); }
if (!defined('SITE_URL')) { define('SITE_URL', 'https://tomsjavajive.com'); }
if (!defined('SITE_EMAIL')) { define('SITE_EMAIL', 'support@tomsjavajive.com'); }
// Environment
if (!defined('ENVIRONMENT')) { define('ENVIRONMENT', 'production'); }
if (!defined('DEBUG_MODE')) { define('DEBUG_MODE', false); }
// Session Configuration
if (!defined('SESSION_NAME')) { define('SESSION_NAME', 'tjj_session'); }
if (!defined('SESSION_LIFETIME')) { define('SESSION_LIFETIME', 86400 * 7); }
// Security
if (!defined('HASH_COST')) { define('HASH_COST', 12); }
if (!defined('CSRF_TOKEN_NAME')) { define('CSRF_TOKEN_NAME', 'csrf_token'); }
// Stripe
if (!defined('STRIPE_SECRET_KEY')) { define('STRIPE_SECRET_KEY', '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'); }
if (!defined('STRIPE_PUBLISHABLE_KEY')) { define('STRIPE_PUBLISHABLE_KEY', 'pk_live_51TcSte42hN9FJ8ht6QMzoTJwji8q1me1cFvEX5Y3mES1cJnA6LQ0ROvqkoiiPBMGf73jszIHZPv6Q37qUgLYdCeG00W4uehVba'); }
if (!defined('STRIPE_WEBHOOK_SECRET')) { define('STRIPE_WEBHOOK_SECRET', 'whsec_esEudSGg3zch1zoHaYD3b9Bj2CfZerO9'); }
// CyberMail SMTP
if (!defined('SMTP_HOST')) { define('SMTP_HOST', 'mail.cyberpersons.com'); }
if (!defined('SMTP_PORT')) { define('SMTP_PORT', 587); }
if (!defined('SMTP_USER')) { define('SMTP_USER', 'smtp_49a1fa9c0f15d2d7'); }
if (!defined('SMTP_PASS')) { define('SMTP_PASS', 'T3mOFSMK1SG1l4D1d7N8NefRd8xypwMy'); }
if (!defined('SMTP_SECURE')) { define('SMTP_SECURE', 'tls'); } // STARTTLS
if (!defined('SENDER_EMAIL')) { define('SENDER_EMAIL', 'noreply@tomsjavajive.com'); }
if (!defined('SENDER_NAME')) { define('SENDER_NAME', "Tom's Java Jive"); }
if (!defined('CYBERMAIL_API_KEY')) { define('CYBERMAIL_API_KEY', 'sk_live_d52bf062797105aeaafac9954c21ff988e9b41b77315807d'); }
// Twilio (optional)
if (!defined('TWILIO_SID')) { define('TWILIO_SID', ''); }
if (!defined('TWILIO_AUTH_TOKEN')) { define('TWILIO_AUTH_TOKEN', ''); }
if (!defined('TWILIO_PHONE')) { define('TWILIO_PHONE', ''); }
// File Uploads
if (!defined('UPLOAD_DIR')) { define('UPLOAD_DIR', __DIR__ . '/../uploads/'); }
if (!defined('MAX_UPLOAD_SIZE')) { define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); }
if (!defined('ALLOWED_IMAGE_TYPES')) { define('ALLOWED_IMAGE_TYPES', ['image/jpeg', 'image/png', 'image/gif', 'image/webp']); }
// Pagination
if (!defined('ITEMS_PER_PAGE')) { define('ITEMS_PER_PAGE', 12); }
if (!defined('ADMIN_ITEMS_PER_PAGE')) { define('ADMIN_ITEMS_PER_PAGE', 20); }
// Currency
if (!defined('CURRENCY_CODE')) { define('CURRENCY_CODE', 'USD'); }
if (!defined('TJJ_CURRENCY_SYMBOL')) { define('TJJ_CURRENCY_SYMBOL', '$'); }
// Timezone
date_default_timezone_set('America/New_York');
// Error Reporting
if (ENVIRONMENT === 'development' || DEBUG_MODE) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}
+2
View File
@@ -326,6 +326,8 @@ CREATE TABLE `orders` (
`order_status` enum('pending','confirmed','processing','shipped','delivered','cancelled','refunded') DEFAULT 'pending', `order_status` enum('pending','confirmed','processing','shipped','delivered','cancelled','refunded') DEFAULT 'pending',
`stripe_session_id` varchar(255) DEFAULT NULL, `stripe_session_id` varchar(255) DEFAULT NULL,
`stripe_payment_intent` varchar(255) DEFAULT NULL, `stripe_payment_intent` varchar(255) DEFAULT NULL,
`square_payment_id` varchar(255) DEFAULT NULL,
`square_order_id` varchar(255) DEFAULT NULL,
`tracking_number` varchar(100) DEFAULT NULL, `tracking_number` varchar(100) DEFAULT NULL,
`tracking_url` varchar(500) DEFAULT NULL, `tracking_url` varchar(500) DEFAULT NULL,
`notes` text DEFAULT NULL, `notes` text DEFAULT NULL,
+11 -11
View File
@@ -75,7 +75,7 @@ class Email {
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json'], CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => true,
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
@@ -115,8 +115,8 @@ class Email {
); );
} }
$html = $this->getTemplate('order_confirmation', [ $html = $this->getTemplate('order_confirmation', [
'order_number' => $order['order_number'], 'order_number' => htmlspecialchars($order['order_number']),
'customer_name' => $order['customer_name'] ?? 'Valued Customer', 'customer_name' => htmlspecialchars($order['customer_name'] ?? 'Valued Customer'),
'items_html' => $itemsHtml, 'items_html' => $itemsHtml,
'subtotal' => formatCurrency($order['subtotal']), 'subtotal' => formatCurrency($order['subtotal']),
'tax' => formatCurrency($order['tax']), 'tax' => formatCurrency($order['tax']),
@@ -136,11 +136,11 @@ class Email {
public function sendShippingNotification(array $order): array { public function sendShippingNotification(array $order): array {
$html = $this->getTemplate('shipping_notification', [ $html = $this->getTemplate('shipping_notification', [
'order_number' => $order['order_number'], 'order_number' => htmlspecialchars($order['order_number']),
'customer_name' => $order['customer_name'] ?? 'Valued Customer', 'customer_name' => htmlspecialchars($order['customer_name'] ?? 'Valued Customer'),
'tracking_number' => $order['tracking_number'], 'tracking_number' => htmlspecialchars($order['tracking_number']),
'tracking_url' => $order['tracking_url'] ?? '#', 'tracking_url' => htmlspecialchars($order['tracking_url'] ?? '#'),
'carrier' => $order['shipping_carrier'] ?? 'Our shipping partner' 'carrier' => htmlspecialchars($order['shipping_carrier'] ?? 'Our shipping partner')
]); ]);
return $this->send( return $this->send(
$order['customer_email'], $order['customer_email'],
@@ -154,8 +154,8 @@ class Email {
public function sendPasswordReset(string $email, string $resetToken, string $name = ''): array { public function sendPasswordReset(string $email, string $resetToken, string $name = ''): array {
$resetUrl = SITE_URL . '/reset-password.php?token=' . $resetToken; $resetUrl = SITE_URL . '/reset-password.php?token=' . $resetToken;
$html = $this->getTemplate('password_reset', [ $html = $this->getTemplate('password_reset', [
'customer_name' => $name ?: 'there', 'customer_name' => htmlspecialchars($name ?: 'there'),
'reset_url' => $resetUrl, 'reset_url' => htmlspecialchars($resetUrl),
'expires' => '1 hour' 'expires' => '1 hour'
]); ]);
return $this->send($email, "Reset Your Password - Tom's Java Jive", $html, null, ['tags' => ['password-reset']]); return $this->send($email, "Reset Your Password - Tom's Java Jive", $html, null, ['tags' => ['password-reset']]);
@@ -163,7 +163,7 @@ class Email {
public function sendWelcome(string $email, string $name = ''): array { public function sendWelcome(string $email, string $name = ''): array {
$html = $this->getTemplate('welcome', [ $html = $this->getTemplate('welcome', [
'customer_name' => $name ?: 'Coffee Lover', 'customer_name' => htmlspecialchars($name ?: 'Coffee Lover'),
'shop_url' => SITE_URL . '/shop.php' 'shop_url' => SITE_URL . '/shop.php'
]); ]);
return $this->send($email, "Welcome to Tom's Java Jive!", $html, null, ['tags' => ['welcome']]); return $this->send($email, "Welcome to Tom's Java Jive!", $html, null, ['tags' => ['welcome']]);
+61 -23
View File
@@ -349,31 +349,15 @@ function getCartTotal() {
} }
/** /**
* Send email via CyberMail API * Send email via CyberMail API.
* Thin wrapper around Email::send() (includes/email.php) so there is a
* single implementation of the CyberMail HTTP call, TLS settings, and
* error handling instead of two copies that can drift out of sync.
*/ */
function sendEmail($to, $subject, $htmlContent, $textContent = '') { function sendEmail($to, $subject, $htmlContent, $textContent = '') {
$apiKey = getSetting('cybermail_api_key', defined('CYBERMAIL_API_KEY') ? CYBERMAIL_API_KEY : ''); require_once __DIR__ . '/email.php';
$from = getSetting('cybermail_from_email', 'noreply@tomsjavajive.com'); $result = emailService()->send($to, $subject, $htmlContent, $textContent ?: null);
$fromName = getSetting('cybermail_from_name', "Tom's Java Jive"); return $result['success'] ?? false;
if (!$apiKey) {
error_log('[TJJ sendEmail] CYBERMAIL_API_KEY not configured');
return false;
}
$payload = ['from' => $from, 'from_name' => $fromName, 'to' => $to, 'subject' => $subject, 'html' => $htmlContent];
if ($textContent) $payload['text'] = $textContent;
$ch = curl_init('https://platform.cyberpersons.com/email/v1/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 20, CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 202) return true;
error_log('[TJJ sendEmail] CyberMail HTTP ' . $httpCode . ' — ' . $response);
return false;
} }
/** /**
@@ -382,3 +366,57 @@ function sendEmail($to, $subject, $htmlContent, $textContent = '') {
function logActivity($action, $details = [], $userId = null) { function logActivity($action, $details = [], $userId = null) {
// Implement activity logging if needed // Implement activity logging if needed
} }
/**
* Validate and save an uploaded image, verifying the ACTUAL file content
* (not the client-supplied MIME type or filename extension, both of which
* are attacker-controlled) before writing it to disk. Used by every admin
* image-upload endpoint so the checks live in exactly one place.
*
* @param string $fieldName Key in $_FILES to read
* @param string $destDir Absolute directory to save into (created if missing)
* @param string $prefix Filename prefix, e.g. "product_" or "splash_"
* @return array ['success' => bool, 'url' => string, 'path' => string] or ['error' => string]
*/
function handleImageUpload(string $fieldName, string $destDir, string $prefix = 'img_'): array {
if (empty($_FILES[$fieldName]) || $_FILES[$fieldName]['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'No file received'];
}
$file = $_FILES[$fieldName];
if ($file['size'] > MAX_UPLOAD_SIZE) {
return ['error' => 'File too large. Maximum ' . (MAX_UPLOAD_SIZE / (1024 * 1024)) . 'MB.'];
}
// Never trust $file['type'] (client-supplied) or the extension in
// $file['name'] (attacker-supplied) — inspect the real file bytes.
$imageInfo = @getimagesize($file['tmp_name']);
if ($imageInfo === false) {
return ['error' => 'File is not a valid image.'];
}
$extensionByMime = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
$mime = $imageInfo['mime'];
if (!in_array($mime, ALLOWED_IMAGE_TYPES, true) || !isset($extensionByMime[$mime])) {
return ['error' => 'Invalid file type. Use JPG, PNG, WebP, or GIF.'];
}
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
$filename = $prefix . time() . '_' . bin2hex(random_bytes(4)) . '.' . $extensionByMime[$mime];
$filepath = rtrim($destDir, '/\\') . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
return ['error' => 'Failed to save file. Check directory permissions.'];
}
return ['success' => true, 'filename' => $filename, 'path' => $filepath];
}
+82 -2
View File
@@ -150,10 +150,10 @@ class LoyaltyProgram {
db()->query( db()->query(
"UPDATE customers SET "UPDATE customers SET
reward_points = reward_points + :points, reward_points = reward_points + :points,
lifetime_points = COALESCE(lifetime_points, 0) + :points, lifetime_points = COALESCE(lifetime_points, 0) + :points2,
updated_at = NOW() updated_at = NOW()
WHERE customer_id = :id", WHERE customer_id = :id",
['points' => $totalPoints, 'id' => $customerId] ['points' => $totalPoints, 'points2' => $totalPoints, 'id' => $customerId]
); );
$newBalance = db()->fetch( $newBalance = db()->fetch(
@@ -417,6 +417,86 @@ class LoyaltyProgram {
'new_customer_bonus' => $newCustomerBonus 'new_customer_bonus' => $newCustomerBonus
]; ];
} }
/**
* Redeem a gift card code into wallet balance. Shared by the Wallet page
* and checkout so both go through the same transaction logic instead of
* duplicating it.
*/
public function redeemGiftCardToWallet(string $customerId, string $rawCode): array {
$code = strtoupper(str_replace(['-', ' '], '', trim($rawCode)));
if (empty($code) || strlen($code) < 8) {
return ['success' => false, 'error' => 'Invalid gift card code'];
}
$giftCard = db()->fetch(
"SELECT * FROM gift_cards WHERE code = :code AND is_active = 1",
['code' => $code]
);
if (!$giftCard) {
return ['success' => false, 'error' => 'Gift card not found or already used'];
}
if ($giftCard['current_balance'] <= 0) {
return ['success' => false, 'error' => 'This gift card has no remaining balance'];
}
if ($giftCard['expires_at'] && strtotime($giftCard['expires_at']) < time()) {
return ['success' => false, 'error' => 'This gift card has expired'];
}
$customer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customerId]);
if (!$customer) {
return ['success' => false, 'error' => 'Customer not found'];
}
$amount = $giftCard['current_balance'];
try {
db()->query("START TRANSACTION");
db()->query(
"UPDATE gift_cards SET current_balance = 0, is_active = 0 WHERE gift_card_id = :id",
['id' => $giftCard['gift_card_id']]
);
db()->insert('gift_card_transactions', [
'gift_card_id' => $giftCard['gift_card_id'],
'amount' => -$amount,
'balance_after' => 0,
'type' => 'redemption',
]);
$newWalletBalance = (float) $customer['wallet_balance'] + $amount;
db()->query(
"UPDATE customers SET wallet_balance = :balance, updated_at = NOW() WHERE customer_id = :id",
['balance' => $newWalletBalance, 'id' => $customerId]
);
db()->insert('wallet_transactions', [
'transaction_id' => generateId('wt_'),
'customer_id' => $customerId,
'amount' => $amount,
'balance_after' => $newWalletBalance,
'type' => 'deposit',
'description' => 'Gift card redeemed: ' . $code
]);
db()->query("COMMIT");
return [
'success' => true,
'amount' => $amount,
'new_balance' => $newWalletBalance
];
} catch (Exception $e) {
db()->query("ROLLBACK");
return ['success' => false, 'error' => 'Failed to redeem gift card. Please try again.'];
}
}
} }
// Helper function // Helper function
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* Tom's Java Jive - Square Integration (cURL-based, no SDK required)
*/
function squareApi(string $method, string $path, array $body = []): array {
$base = (defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox')
? 'https://connect.squareupsandbox.com/v2'
: 'https://connect.squareup.com/v2';
$ch = curl_init($base . $path);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . SQUARE_ACCESS_TOKEN,
'Square-Version: 2024-01-18',
],
];
if ($method !== 'GET') {
$opts[CURLOPT_CUSTOMREQUEST] = $method;
$opts[CURLOPT_POSTFIELDS] = json_encode($body ?: new stdClass());
}
curl_setopt_array($ch, $opts);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err) {
throw new Exception('Square API connection error: ' . $err);
}
$decoded = json_decode($resp ?: '{}', true);
if (!empty($decoded['errors'])) {
$msg = $decoded['errors'][0]['detail'] ?? ($decoded['errors'][0]['code'] ?? 'Unknown Square error');
throw new Exception($msg);
}
if ($httpCode >= 400) {
throw new Exception('Square API error (HTTP ' . $httpCode . ')');
}
return $decoded;
}
/**
* Create a payment. $sourceId is the nonce from the Web Payments SDK's tokenize() call.
*/
function squareCreatePayment(string $sourceId, float $amount, string $referenceId, array $options = []): array {
$body = [
'source_id' => $sourceId,
'idempotency_key' => $referenceId . '_' . bin2hex(random_bytes(8)),
'amount_money' => [
'amount' => (int) round($amount * 100),
'currency' => 'USD',
],
'location_id' => SQUARE_LOCATION_ID,
'autocomplete' => true,
'reference_id' => $referenceId,
];
if (!empty($options['note'])) {
$body['note'] = $options['note'];
}
return squareApi('POST', '/payments', $body);
}
function squareGetPayment(string $paymentId): array {
return squareApi('GET', '/payments/' . $paymentId);
}
function squareRefundPayment(string $paymentId, float $amount, string $reason = ''): array {
$body = [
'idempotency_key' => 'refund_' . $paymentId . '_' . bin2hex(random_bytes(6)),
'amount_money' => [
'amount' => (int) round($amount * 100),
'currency' => 'USD',
],
'payment_id' => $paymentId,
];
if ($reason) {
$body['reason'] = substr($reason, 0, 192);
}
return squareApi('POST', '/refunds', $body);
}
function isSquareConfigured(): bool {
return defined('SQUARE_ACCESS_TOKEN') && defined('SQUARE_APP_ID') && defined('SQUARE_LOCATION_ID')
&& !empty(SQUARE_ACCESS_TOKEN) && !empty(SQUARE_APP_ID) && !empty(SQUARE_LOCATION_ID)
&& SQUARE_ACCESS_TOKEN !== 'REPLACE_ME';
}
/**
* Square's webhook signature: base64(HMAC-SHA256(notification_url . raw_body, signature_key)).
* The notification URL must exactly match what's configured in the Square Dashboard subscription.
*/
function verifySquareWebhookSignature(string $payload, string $signature, string $notificationUrl, string $signatureKey): bool {
if (empty($signatureKey) || empty($signature)) {
return false;
}
$expected = base64_encode(hash_hmac('sha256', $notificationUrl . $payload, $signatureKey, true));
return hash_equals($expected, $signature);
}
/**
* Single source of truth for "an order's Square payment resolved" called from the
* synchronous create-payment response, the webhook, and the reconciliation poll, so
* loyalty points/emails are never awarded or sent more than once regardless of which
* path resolves the order first.
*/
function markSquarePaymentResult(string $paymentId, ?string $orderId, string $status): void {
$order = $orderId
? db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId])
: db()->fetch("SELECT * FROM orders WHERE square_payment_id = :pid", ['pid' => $paymentId]);
if (!$order) {
return;
}
if ($status === 'COMPLETED') {
if ($order['order_status'] !== 'confirmed') {
db()->update('orders',
[
'payment_status' => 'paid',
'order_status' => 'confirmed',
'square_payment_id' => $paymentId,
'payment_method' => 'square',
],
'order_id = :id',
['id' => $order['order_id']]
);
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$order['order_id']
);
}
if (!empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0 && !empty($order['customer_id'])) {
$walletAmount = (float) $order['wallet_amount_used'];
$cust = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $order['customer_id']]);
if ($cust) {
$newBalance = max(0, (float) $cust['wallet_balance'] - $walletAmount);
db()->update('customers', ['wallet_balance' => $newBalance], 'customer_id = :id', ['id' => $order['customer_id']]);
db()->insert('wallet_transactions', [
'transaction_id' => generateId('wt_'),
'customer_id' => $order['customer_id'],
'amount' => -$walletAmount,
'balance_after' => $newBalance,
'type' => 'purchase',
'description' => 'Applied to Order #' . $order['order_number'],
]);
}
}
$updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]);
if ($updated) {
emailService()->sendOrderConfirmation($updated);
}
}
} elseif (in_array($status, ['FAILED', 'CANCELED'], true)) {
db()->update('orders',
['payment_status' => 'failed'],
'order_id = :id',
['id' => $order['order_id']]
);
}
}
+7
View File
@@ -88,6 +88,13 @@ require_once __DIR__ . '/includes/header.php';
<span><?= $order['shipping_cost'] > 0 ? formatCurrency($order['shipping_cost']) : 'FREE' ?></span> <span><?= $order['shipping_cost'] > 0 ? formatCurrency($order['shipping_cost']) : 'FREE' ?></span>
</div> </div>
<?php if (!empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0): ?>
<div style="display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--color-border);">
<span>Wallet / Gift Card</span>
<span class="text-success">-<?= formatCurrency($order['wallet_amount_used']) ?></span>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: space-between; padding: 0.75rem 0; font-weight: 600; font-size: 1.125rem;"> <div style="display: flex; justify-content: space-between; padding: 0.75rem 0; font-weight: 600; font-size: 1.125rem;">
<span>Total</span> <span>Total</span>
<span><?= formatCurrency($order['total']) ?></span> <span><?= formatCurrency($order['total']) ?></span>
+224 -50
View File
@@ -1,13 +1,20 @@
<?php <?php
/** /**
* Tom's Java Jive - Payment Page (Stripe) * Tom's Java Jive - Payment Page
* Supports both PaymentIntent (card element) and Checkout Session (redirect) flows * Supports Stripe (legacy) or Square, gated by the PAYMENT_PROCESSOR constant.
*/ */
$pageTitle = "Payment - Tom's Java Jive"; $pageTitle = "Payment - Tom's Java Jive";
require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe';
if ($processor === 'square') {
require_once __DIR__ . '/includes/square.php';
} else {
require_once __DIR__ . '/includes/stripe.php'; require_once __DIR__ . '/includes/stripe.php';
}
$orderId = $_GET['order'] ?? $_SESSION['pending_order_id'] ?? ''; $orderId = $_GET['order'] ?? $_SESSION['pending_order_id'] ?? '';
$cancelled = isset($_GET['cancelled']); $cancelled = isset($_GET['cancelled']);
@@ -16,7 +23,6 @@ if (empty($orderId)) {
redirect('/cart.php'); redirect('/cart.php');
} }
// Get order
$order = db()->fetch( $order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id", "SELECT * FROM orders WHERE order_id = :id",
['id' => $orderId] ['id' => $orderId]
@@ -26,15 +32,19 @@ if (!$order) {
redirect('/cart.php'); redirect('/cart.php');
} }
// If already paid, redirect to confirmation
if ($order['payment_status'] === 'paid') { if ($order['payment_status'] === 'paid') {
clearCart(); clearCart();
redirect('/order-confirmation.php?order=' . $orderId); redirect('/order-confirmation.php?order=' . $orderId);
} }
$total = $order['total'];
if ($processor === 'square') {
$squareConfigured = isSquareConfigured();
} else {
$stripePublishableKey = STRIPE_PUBLISHABLE_KEY; $stripePublishableKey = STRIPE_PUBLISHABLE_KEY;
$stripeConfigured = isStripeConfigured(); $stripeConfigured = isStripeConfigured();
$total = $order['total']; }
require_once __DIR__ . '/includes/header.php'; require_once __DIR__ . '/includes/header.php';
?> ?>
@@ -62,8 +72,61 @@ require_once __DIR__ . '/includes/header.php';
</p> </p>
</div> </div>
<?php if ($processor === 'square'): ?>
<?php if (!$squareConfigured): ?>
<div class="alert alert-info mb-2">
<i class="fas fa-info-circle"></i> <strong>Demo Mode:</strong> Square is not configured. Click below to simulate a successful payment.
</div>
<form id="demo-payment-form">
<button type="submit" id="demo-submit" class="btn btn-primary btn-lg btn-block">
<span id="demo-text">Complete Demo Payment <?= formatCurrency($total) ?></span>
<span id="demo-spinner" style="display: none;">
<span class="loading"></span> Processing...
</span>
</button>
</form>
<?php else: ?>
<div class="payment-options mb-2" style="display: flex; gap: 0.5rem;">
<button type="button" id="pay-tab-card" class="btn btn-primary" style="flex: 1;">
<i class="fas fa-credit-card"></i> Card
</button>
<button type="button" id="pay-tab-giftcard" class="btn btn-secondary" style="flex: 1;">
<i class="fas fa-gift"></i> Gift Card
</button>
</div>
<form id="payment-form">
<div class="form-group">
<label class="form-label">Card Details</label>
<div id="card-element" style="padding: 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-md);"></div>
<div id="card-errors" class="form-error" style="margin-top: 0.5rem;"></div>
</div>
<button type="submit" id="submit-button" class="btn btn-secondary btn-lg btn-block">
<span id="button-text">Pay <?= formatCurrency($total) ?></span>
<span id="spinner" style="display: none;">
<span class="loading"></span> Processing...
</span>
</button>
</form>
<form id="gift-card-payment-form" style="display: none;">
<div class="form-group">
<label class="form-label">Square Gift Card</label>
<div id="gift-card-element" style="padding: 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-md);"></div>
<div id="gift-card-errors" class="form-error" style="margin-top: 0.5rem;"></div>
</div>
<button type="submit" id="gift-card-submit-button" class="btn btn-secondary btn-lg btn-block">
<span id="gift-card-button-text">Pay <?= formatCurrency($total) ?></span>
<span id="gift-card-spinner" style="display: none;">
<span class="loading"></span> Processing...
</span>
</button>
</form>
<?php endif; ?>
<?php else: ?>
<?php if (!$stripeConfigured): ?> <?php if (!$stripeConfigured): ?>
<!-- Demo Mode - No Stripe Keys -->
<div class="alert alert-info mb-2"> <div class="alert alert-info mb-2">
<i class="fas fa-info-circle"></i> <strong>Demo Mode:</strong> Stripe is not configured. Click below to simulate a successful payment. <i class="fas fa-info-circle"></i> <strong>Demo Mode:</strong> Stripe is not configured. Click below to simulate a successful payment.
</div> </div>
@@ -76,7 +139,6 @@ require_once __DIR__ . '/includes/header.php';
</button> </button>
</form> </form>
<?php else: ?> <?php else: ?>
<!-- Stripe Payment Options -->
<div class="payment-options mb-2"> <div class="payment-options mb-2">
<button type="button" id="checkout-btn" class="btn btn-primary btn-lg btn-block mb-1"> <button type="button" id="checkout-btn" class="btn btn-primary btn-lg btn-block mb-1">
<i class="fas fa-credit-card"></i> Pay with Stripe Checkout <i class="fas fa-credit-card"></i> Pay with Stripe Checkout
@@ -99,6 +161,7 @@ require_once __DIR__ . '/includes/header.php';
</button> </button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
<div id="payment-message" style="display: none; margin-top: 1rem;"></div> <div id="payment-message" style="display: none; margin-top: 1rem;"></div>
@@ -110,9 +173,12 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</section> </section>
<?php if ($processor === 'square' && $squareConfigured): ?>
<script src="<?= (defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox') ? 'https://sandbox.web.squarecdn.com/v1/square.js' : 'https://web.squarecdn.com/v1/square.js' ?>"></script>
<?php endif; ?>
<script> <script>
const orderId = '<?= $orderId ?>'; const orderId = '<?= $orderId ?>';
const stripeConfigured = <?= $stripeConfigured ? 'true' : 'false' ?>;
const messageEl = document.getElementById('payment-message'); const messageEl = document.getElementById('payment-message');
function showMessage(message, type = 'info') { function showMessage(message, type = 'info') {
@@ -121,29 +187,23 @@ function showMessage(message, type = 'info') {
messageEl.innerHTML = `<i class="fas fa-${type === 'error' ? 'exclamation-circle' : 'check-circle'}"></i> ${message}`; messageEl.innerHTML = `<i class="fas fa-${type === 'error' ? 'exclamation-circle' : 'check-circle'}"></i> ${message}`;
} }
<?php if (!$stripeConfigured): ?> function wireDemoForm(endpoint) {
// Demo mode payment
const demoForm = document.getElementById('demo-payment-form'); const demoForm = document.getElementById('demo-payment-form');
demoForm.addEventListener('submit', async function(e) { demoForm.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById('demo-submit'); const btn = document.getElementById('demo-submit');
const text = document.getElementById('demo-text'); const text = document.getElementById('demo-text');
const spinner = document.getElementById('demo-spinner'); const spinner = document.getElementById('demo-spinner');
btn.disabled = true; btn.disabled = true;
text.style.display = 'none'; text.style.display = 'none';
spinner.style.display = 'inline'; spinner.style.display = 'inline';
try { try {
const response = await fetch('/api/create-payment-intent.php', { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }) body: JSON.stringify({ order_id: orderId })
}); });
const data = await response.json(); const data = await response.json();
if (data.demo_mode && data.redirect) { if (data.demo_mode && data.redirect) {
showMessage('Payment simulated successfully! Redirecting...', 'success'); showMessage('Payment simulated successfully! Redirecting...', 'success');
setTimeout(() => window.location.href = data.redirect, 1000); setTimeout(() => window.location.href = data.redirect, 1000);
@@ -160,49 +220,172 @@ demoForm.addEventListener('submit', async function(e) {
spinner.style.display = 'none'; spinner.style.display = 'none';
} }
}); });
}
<?php if ($processor === 'square'): ?>
<?php if (!$squareConfigured): ?>
wireDemoForm('/api/create-square-payment.php');
<?php else: ?>
// Square Web Payments SDK
let squareCard, squareGiftCard;
const squarePayments = window.Square.payments('<?= SQUARE_APP_ID ?>', '<?= SQUARE_LOCATION_ID ?>');
(async function initSquare() {
squareCard = await squarePayments.card();
await squareCard.attach('#card-element');
})();
const cardTab = document.getElementById('pay-tab-card');
const giftCardTab = document.getElementById('pay-tab-giftcard');
const cardForm = document.getElementById('payment-form');
const giftCardForm = document.getElementById('gift-card-payment-form');
let giftCardInitialized = false;
cardTab.addEventListener('click', function() {
cardTab.className = 'btn btn-primary';
giftCardTab.className = 'btn btn-secondary';
cardForm.style.display = '';
giftCardForm.style.display = 'none';
});
giftCardTab.addEventListener('click', async function() {
cardTab.className = 'btn btn-secondary';
giftCardTab.className = 'btn btn-primary';
cardForm.style.display = 'none';
giftCardForm.style.display = '';
if (!giftCardInitialized) {
giftCardInitialized = true;
squareGiftCard = await squarePayments.giftCard();
await squareGiftCard.attach('#gift-card-element');
}
});
giftCardForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('gift-card-submit-button');
const buttonText = document.getElementById('gift-card-button-text');
const spinner = document.getElementById('gift-card-spinner');
submitBtn.disabled = true;
buttonText.style.display = 'none';
spinner.style.display = 'inline';
document.getElementById('gift-card-errors').textContent = '';
try {
const tokenResult = await squareGiftCard.tokenize();
if (tokenResult.status !== 'OK') {
const msg = (tokenResult.errors && tokenResult.errors[0] && tokenResult.errors[0].message) || 'Gift card validation failed';
document.getElementById('gift-card-errors').textContent = msg;
submitBtn.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
const response = await fetch('/api/create-square-payment.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId, source_id: tokenResult.token })
});
const data = await response.json();
if (data.error) {
showMessage(data.error, 'error');
submitBtn.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
if (data.success && data.redirect) {
showMessage('Payment successful! Redirecting...', 'success');
setTimeout(() => window.location.href = data.redirect, 1000);
}
} catch (err) {
showMessage('Payment failed. Please try again.', 'error');
console.error(err);
submitBtn.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
}
});
const form = document.getElementById('payment-form');
const submitButton = document.getElementById('submit-button');
const buttonText = document.getElementById('button-text');
const spinner = document.getElementById('spinner');
form.addEventListener('submit', async function(e) {
e.preventDefault();
submitButton.disabled = true;
buttonText.style.display = 'none';
spinner.style.display = 'inline';
document.getElementById('card-errors').textContent = '';
try {
const tokenResult = await squareCard.tokenize();
if (tokenResult.status !== 'OK') {
const msg = (tokenResult.errors && tokenResult.errors[0] && tokenResult.errors[0].message) || 'Card validation failed';
document.getElementById('card-errors').textContent = msg;
submitButton.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
const response = await fetch('/api/create-square-payment.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId, source_id: tokenResult.token })
});
const data = await response.json();
if (data.demo_mode && data.redirect) {
window.location.href = data.redirect;
return;
}
if (data.error) {
showMessage(data.error, 'error');
submitButton.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
return;
}
if (data.success && data.redirect) {
showMessage('Payment successful! Redirecting...', 'success');
setTimeout(() => window.location.href = data.redirect, 1000);
}
} catch (err) {
showMessage('Payment failed. Please try again.', 'error');
console.error(err);
submitButton.disabled = false;
buttonText.style.display = 'inline';
spinner.style.display = 'none';
}
});
<?php endif; ?>
<?php else: ?>
<?php if (!$stripeConfigured): ?>
wireDemoForm('/api/create-payment-intent.php');
<?php else: ?> <?php else: ?>
// Stripe initialized
const stripe = Stripe('<?= $stripePublishableKey ?>'); const stripe = Stripe('<?= $stripePublishableKey ?>');
const elements = stripe.elements(); const elements = stripe.elements();
const cardElement = elements.create('card', { const cardElement = elements.create('card', {
style: { style: { base: { fontSize: '16px', color: '#1B1B1B', '::placeholder': { color: '#9CA3AF' } } }
base: {
fontSize: '16px',
color: '#1B1B1B',
'::placeholder': { color: '#9CA3AF' }
}
}
}); });
cardElement.mount('#card-element'); cardElement.mount('#card-element');
// Handle validation errors
cardElement.on('change', function(event) { cardElement.on('change', function(event) {
const displayError = document.getElementById('card-errors'); const displayError = document.getElementById('card-errors');
if (event.error) { displayError.textContent = event.error ? event.error.message : '';
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
}); });
// Stripe Checkout button (redirect to hosted page)
document.getElementById('checkout-btn').addEventListener('click', async function() { document.getElementById('checkout-btn').addEventListener('click', async function() {
this.disabled = true; this.disabled = true;
this.innerHTML = '<span class="loading"></span> Redirecting to Stripe...'; this.innerHTML = '<span class="loading"></span> Redirecting to Stripe...';
try { try {
const response = await fetch('/api/create-checkout-session.php', { const response = await fetch('/api/create-checkout-session.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ order_id: orderId, origin_url: window.location.origin })
order_id: orderId,
origin_url: window.location.origin
})
}); });
const data = await response.json(); const data = await response.json();
if (data.demo_mode && data.redirect) { if (data.demo_mode && data.redirect) {
window.location.href = data.redirect; window.location.href = data.redirect;
} else if (data.url) { } else if (data.url) {
@@ -219,7 +402,6 @@ document.getElementById('checkout-btn').addEventListener('click', async function
} }
}); });
// PaymentIntent form (inline card element)
const form = document.getElementById('payment-form'); const form = document.getElementById('payment-form');
const submitButton = document.getElementById('submit-button'); const submitButton = document.getElementById('submit-button');
const buttonText = document.getElementById('button-text'); const buttonText = document.getElementById('button-text');
@@ -227,25 +409,20 @@ const spinner = document.getElementById('spinner');
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
submitButton.disabled = true; submitButton.disabled = true;
buttonText.style.display = 'none'; buttonText.style.display = 'none';
spinner.style.display = 'inline'; spinner.style.display = 'inline';
try { try {
const response = await fetch('/api/create-payment-intent.php', { const response = await fetch('/api/create-payment-intent.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }) body: JSON.stringify({ order_id: orderId })
}); });
const data = await response.json(); const data = await response.json();
if (data.demo_mode && data.redirect) { if (data.demo_mode && data.redirect) {
window.location.href = data.redirect; window.location.href = data.redirect;
return; return;
} }
if (data.error) { if (data.error) {
showMessage(data.error, 'error'); showMessage(data.error, 'error');
submitButton.disabled = false; submitButton.disabled = false;
@@ -253,8 +430,6 @@ form.addEventListener('submit', async function(e) {
spinner.style.display = 'none'; spinner.style.display = 'none';
return; return;
} }
// Confirm payment with Stripe
const { error, paymentIntent } = await stripe.confirmCardPayment(data.client_secret, { const { error, paymentIntent } = await stripe.confirmCardPayment(data.client_secret, {
payment_method: { payment_method: {
card: cardElement, card: cardElement,
@@ -264,14 +439,12 @@ form.addEventListener('submit', async function(e) {
} }
} }
}); });
if (error) { if (error) {
showMessage(error.message, 'error'); showMessage(error.message, 'error');
} else if (paymentIntent.status === 'succeeded') { } else if (paymentIntent.status === 'succeeded') {
showMessage('Payment successful! Redirecting...', 'success'); showMessage('Payment successful! Redirecting...', 'success');
setTimeout(() => window.location.href = '/order-confirmation.php?order=' + orderId, 1000); setTimeout(() => window.location.href = '/order-confirmation.php?order=' + orderId, 1000);
} }
} catch (err) { } catch (err) {
showMessage('Payment failed. Please try again.', 'error'); showMessage('Payment failed. Please try again.', 'error');
console.error(err); console.error(err);
@@ -282,6 +455,7 @@ form.addEventListener('submit', async function(e) {
} }
}); });
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
</script> </script>
<?php require_once __DIR__ . '/includes/footer.php'; ?> <?php require_once __DIR__ . '/includes/footer.php'; ?>