Individual line prices were displayed rounded (via dollars()) but the raw floating-point values were accumulated into the subtotal. This caused totals like $0.99 instead of $1.00 when item prices had fractional cents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
388 lines
12 KiB
PHP
388 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Public receipt page — no auth required.
|
|
* Secured by random v4 UUID unguessability.
|
|
* URL: /receipt/index.php?UUID={orderUuid}
|
|
*/
|
|
|
|
require_once __DIR__ . '/../api/helpers.php';
|
|
// No runAuth() — public page
|
|
|
|
$uuid = trim($_GET['UUID'] ?? '');
|
|
$isAdminView = (int) ($_GET['is_admin_view'] ?? 0);
|
|
|
|
if ($uuid === '') {
|
|
echo <<<HTML
|
|
<!DOCTYPE html>
|
|
<html><head><title>Receipt Not Found</title>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;color:#333}
|
|
.msg{text-align:center;padding:40px}.msg h2{margin-bottom:8px}</style>
|
|
</head><body><div class="msg"><h2>Receipt not found</h2><p>The receipt link may be invalid or expired.</p></div></body></html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
$order = queryOne("
|
|
SELECT O.OrderTypeID, O.BusinessID, O.Remarks, O.ID, O.BalanceApplied, O.PaymentID, O.TipAmount,
|
|
B.Name AS BusinessName, B.TaxRate, B.PayfritFee,
|
|
COALESCE(P.PaymentPaidInCash, 0) AS PaymentPaidInCash,
|
|
COALESCE(P.PaymentFromCreditCard, 0) AS PaymentFromCreditCard
|
|
FROM Orders O
|
|
JOIN Businesses B ON B.ID = O.BusinessID
|
|
LEFT JOIN Payments P ON P.PaymentID = O.PaymentID
|
|
WHERE O.UUID = ?
|
|
", [$uuid]);
|
|
|
|
if (!$order) {
|
|
echo <<<HTML
|
|
<!DOCTYPE html>
|
|
<html><head><title>Receipt Not Found</title>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;color:#333}
|
|
.msg{text-align:center;padding:40px}.msg h2{margin-bottom:8px}</style>
|
|
</head><body><div class="msg"><h2>Receipt not found</h2><p>The receipt link may be invalid or expired.</p></div></body></html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
$orderTask = queryOne("SELECT CreatedOn FROM Tasks WHERE OrderID = ? LIMIT 1", [(int) $order['ID']]);
|
|
|
|
$orderType = queryOne("SELECT Name AS OrderTypeName FROM tt_OrderTypes WHERE ID = ?", [(int) $order['OrderTypeID']]);
|
|
$orderTypeName = $orderType['OrderTypeName'] ?? '';
|
|
|
|
$deliveryAddress = '';
|
|
if ((int) $order['OrderTypeID'] === 3) {
|
|
$addr = queryOne("
|
|
SELECT A.Line1
|
|
FROM Addresses A
|
|
JOIN Orders O ON A.ID = O.AddressID
|
|
WHERE O.UUID = ?
|
|
", [$uuid]);
|
|
$deliveryAddress = $addr['Line1'] ?? '';
|
|
}
|
|
|
|
$parentItems = queryTimed("
|
|
SELECT OL.ID, OL.Quantity, OL.Remark,
|
|
I.ID AS ItemID, I.Name, I.Price, I.CategoryID
|
|
FROM OrderLineItems OL
|
|
JOIN Items I ON I.ID = OL.ItemID
|
|
JOIN Orders O ON O.ID = OL.OrderID
|
|
WHERE O.UUID = ?
|
|
AND OL.ParentOrderLineItemID = 0
|
|
ORDER BY OL.AddedOn DESC
|
|
", [$uuid]);
|
|
|
|
$cartGrandTotal = 0;
|
|
$payfritsCut = 0;
|
|
|
|
// Helper
|
|
function esc(string $s): string {
|
|
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
function dollars(float $amount): string {
|
|
return '$' . number_format($amount, 2);
|
|
}
|
|
|
|
// Build item rows
|
|
$itemRows = '';
|
|
foreach ($parentItems as $parent) {
|
|
$price = (float) $parent['Price'];
|
|
$qty = (int) $parent['Quantity'];
|
|
|
|
if ((int) $parent['CategoryID'] !== 31) {
|
|
$payfritsCut += round($price * $qty * (float) $order['PayfritFee'], 2);
|
|
}
|
|
|
|
$lineTotal = round($price * $qty, 2);
|
|
$cartGrandTotal += $lineTotal;
|
|
|
|
$itemRows .= '<tr>';
|
|
$itemRows .= '<td>' . esc($parent['Name']) . '</td>';
|
|
$itemRows .= '<td class="qty">' . $qty . '</td>';
|
|
$itemRows .= '<td class="amt">' . dollars($price) . '</td>';
|
|
$itemRows .= '<td class="amt">' . dollars($lineTotal) . '</td>';
|
|
$itemRows .= '</tr>';
|
|
|
|
// Child/modifier items
|
|
$children = queryTimed("
|
|
SELECT I.Name, I.Price, I.ParentItemID
|
|
FROM OrderLineItems OL
|
|
JOIN Items I ON I.ID = OL.ItemID
|
|
WHERE OL.ParentOrderLineItemID = ?
|
|
ORDER BY OL.AddedOn DESC
|
|
", [(int) $parent['ID']]);
|
|
|
|
foreach ($children as $child) {
|
|
$modParent = queryOne("SELECT Name FROM Items WHERE ID = ?", [(int) $child['ParentItemID']]);
|
|
$modParentName = $modParent['Name'] ?? '';
|
|
$modTotal = round((float) $child['Price'] * $qty, 2);
|
|
$cartGrandTotal += $modTotal;
|
|
|
|
$itemRows .= '<tr class="modifier">';
|
|
$itemRows .= '<td colspan="3">' . esc($modParentName) . ': ' . esc($child['Name']) . ' (' . dollars((float) $child['Price']) . ')</td>';
|
|
$itemRows .= '<td class="amt">' . dollars($modTotal) . '</td>';
|
|
$itemRows .= '</tr>';
|
|
}
|
|
|
|
if (!empty(trim($parent['Remark'] ?? ''))) {
|
|
$itemRows .= '<tr class="remark"><td colspan="4">' . esc($parent['Remark']) . '</td></tr>';
|
|
}
|
|
}
|
|
|
|
// Calculate totals (matches createPaymentIntent logic — no intermediate rounding)
|
|
$taxAmountRaw = $cartGrandTotal * (float) $order['TaxRate'];
|
|
$payfritFeeRaw = $payfritsCut;
|
|
|
|
// Delivery fee
|
|
$deliveryFee = 0;
|
|
if ((int) $order['OrderTypeID'] === 3) {
|
|
$delFee = queryOne("
|
|
SELECT B.DeliveryFlatFee
|
|
FROM Businesses B
|
|
JOIN Orders O ON B.ID = O.BusinessID
|
|
WHERE O.ID = ?
|
|
", [(int) $order['ID']]);
|
|
$deliveryFee = (float) ($delFee['DeliveryFlatFee'] ?? 0);
|
|
}
|
|
|
|
$isCashOrder = (float) $order['PaymentPaidInCash'] > 0;
|
|
$isCardOrder = (float) $order['PaymentFromCreditCard'] > 0;
|
|
|
|
$receiptTip = (float) ($order['TipAmount'] ?? 0);
|
|
$totalBeforeCardFee = $cartGrandTotal + $taxAmountRaw + $payfritFeeRaw + $deliveryFee + $receiptTip;
|
|
|
|
if ($isCardOrder) {
|
|
$cardFeePercent = 0.029;
|
|
$cardFeeFixed = 0.30;
|
|
$totalCustomerPaysRaw = ($totalBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent);
|
|
$orderGrandTotal = round($totalCustomerPaysRaw * 100) / 100;
|
|
$taxAmount = round($taxAmountRaw * 100) / 100;
|
|
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
|
$cardFee = round($cardFee * 100) / 100;
|
|
} else {
|
|
$orderGrandTotal = round($totalBeforeCardFee * 100) / 100;
|
|
$taxAmount = round($taxAmountRaw * 100) / 100;
|
|
$cardFee = 0;
|
|
}
|
|
|
|
$serviceFeeDisplay = round($payfritFeeRaw * 100) / 100;
|
|
$balanceApplied = (float) ($order['BalanceApplied'] ?? 0);
|
|
|
|
// Build the page
|
|
$businessName = esc($order['BusinessName']);
|
|
$orderTypeDisplay = esc($orderTypeName);
|
|
if ((int) $order['OrderTypeID'] === 3 && $deliveryAddress !== '') {
|
|
$orderTypeDisplay .= ' to ' . esc($deliveryAddress);
|
|
}
|
|
|
|
$metaHtml = '';
|
|
if ($orderTask && $isAdminView === 0) {
|
|
$createdOn = new DateTime($orderTask['CreatedOn'], new DateTimeZone('UTC'));
|
|
$metaHtml .= '<div class="meta-row"><span>Date</span><span>' . $createdOn->format('M j, Y') . '</span></div>';
|
|
$metaHtml .= '<div class="meta-row"><span>Time</span><span>' . $createdOn->format('g:i A') . '</span></div>';
|
|
}
|
|
$metaHtml .= '<div class="meta-row"><span>Order</span><span>#' . (int) $order['ID'] . '</span></div>';
|
|
|
|
$remarksHtml = '';
|
|
if (!empty(trim($order['Remarks'] ?? ''))) {
|
|
$remarksHtml = '<div class="order-remarks">' . esc($order['Remarks']) . '</div>';
|
|
}
|
|
|
|
$totalsHtml = '<div class="total-row"><span>Subtotal</span><span>' . dollars($cartGrandTotal) . '</span></div>';
|
|
|
|
if ($taxAmount > 0) {
|
|
$totalsHtml .= '<div class="total-row"><span>Tax</span><span>' . dollars($taxAmount) . '</span></div>';
|
|
}
|
|
if ($serviceFeeDisplay > 0) {
|
|
$totalsHtml .= '<div class="total-row"><span>Service fee</span><span>' . dollars($serviceFeeDisplay) . '</span></div>';
|
|
}
|
|
if ($deliveryFee > 0) {
|
|
$totalsHtml .= '<div class="total-row"><span>Delivery fee</span><span>' . dollars($deliveryFee) . '</span></div>';
|
|
}
|
|
if ($receiptTip > 0) {
|
|
$totalsHtml .= '<div class="total-row"><span>Tip</span><span>' . dollars($receiptTip) . '</span></div>';
|
|
}
|
|
if ($cardFee > 0) {
|
|
$totalsHtml .= '<div class="total-row"><span>Processing fee</span><span>' . dollars($cardFee) . '</span></div>';
|
|
}
|
|
|
|
$totalsHtml .= '<div class="total-row grand-total"><span>Total</span><span>' . dollars($orderGrandTotal) . '</span></div>';
|
|
|
|
if ($balanceApplied > 0) {
|
|
$totalsHtml .= '<div class="total-row" style="font-size:13px;color:#666;padding-top:8px"><span>Paid with balance</span><span>' . dollars($balanceApplied) . '</span></div>';
|
|
$amountCharged = $orderGrandTotal - $balanceApplied;
|
|
if ($amountCharged > 0) {
|
|
$chargeLabel = $isCashOrder ? 'Cash due' : 'Charged to card';
|
|
$totalsHtml .= '<div class="total-row" style="font-size:13px;color:#666"><span>' . $chargeLabel . '</span><span>' . dollars($amountCharged) . '</span></div>';
|
|
}
|
|
}
|
|
|
|
echo <<<HTML
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Receipt - {$businessName}</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: #f0f0f0;
|
|
color: #333;
|
|
padding: 20px;
|
|
}
|
|
.receipt {
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
|
overflow: hidden;
|
|
}
|
|
.receipt-header {
|
|
background: #1a1a2e;
|
|
color: #fff;
|
|
padding: 28px 24px 20px;
|
|
text-align: center;
|
|
}
|
|
.receipt-header h1 {
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
margin-bottom: 6px;
|
|
}
|
|
.receipt-header .order-type {
|
|
font-size: 14px;
|
|
opacity: 0.8;
|
|
}
|
|
.receipt-meta {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px dashed #ddd;
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
.receipt-meta .meta-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 4px;
|
|
}
|
|
.items {
|
|
padding: 16px 24px;
|
|
}
|
|
.items table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.items th {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: #999;
|
|
border-bottom: 1px solid #eee;
|
|
padding: 0 0 8px;
|
|
text-align: left;
|
|
}
|
|
.items th:last-child,
|
|
.items td.amt {
|
|
text-align: right;
|
|
}
|
|
.items td {
|
|
padding: 10px 0;
|
|
font-size: 14px;
|
|
border-bottom: 1px solid #f5f5f5;
|
|
vertical-align: top;
|
|
}
|
|
.items td.qty {
|
|
text-align: center;
|
|
color: #888;
|
|
width: 40px;
|
|
}
|
|
.items .modifier td {
|
|
padding: 2px 0 2px 16px;
|
|
font-size: 13px;
|
|
color: #777;
|
|
border-bottom: none;
|
|
}
|
|
.items .remark td {
|
|
padding: 2px 0 8px 16px;
|
|
font-size: 13px;
|
|
color: #c0392b;
|
|
border-bottom: none;
|
|
font-style: italic;
|
|
}
|
|
.totals {
|
|
padding: 16px 24px 24px;
|
|
border-top: 1px dashed #ddd;
|
|
}
|
|
.totals .total-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 4px 0;
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
.totals .grand-total {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: #1a1a2e;
|
|
border-top: 2px solid #1a1a2e;
|
|
margin-top: 8px;
|
|
padding-top: 12px;
|
|
}
|
|
.receipt-footer {
|
|
text-align: center;
|
|
padding: 16px 24px 24px;
|
|
font-size: 12px;
|
|
color: #aaa;
|
|
}
|
|
.order-remarks {
|
|
padding: 8px 24px;
|
|
font-size: 13px;
|
|
color: #c0392b;
|
|
font-style: italic;
|
|
border-bottom: 1px dashed #ddd;
|
|
}
|
|
@media print {
|
|
body { background: #fff; padding: 0; }
|
|
.receipt { box-shadow: none; border-radius: 0; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="receipt">
|
|
<div class="receipt-header">
|
|
<h1>{$businessName}</h1>
|
|
<div class="order-type">{$orderTypeDisplay}</div>
|
|
</div>
|
|
|
|
<div class="receipt-meta">
|
|
{$metaHtml}
|
|
</div>
|
|
|
|
{$remarksHtml}
|
|
|
|
<div class="items">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Item</th><th>Qty</th><th>Price</th><th>Total</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{$itemRows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="totals">
|
|
{$totalsHtml}
|
|
</div>
|
|
|
|
<div class="receipt-footer">
|
|
Powered by Payfrit
|
|
</div>
|
|
</div>
|
|
|
|
</body>
|
|
</html>
|
|
HTML;
|