Instead of recalculating the grand total from line items + rates (which can drift by a penny due to floating point), use the actual PaymentFromCreditCard or PaymentPaidInCash values from the Payments table. This ensures the receipt always matches what the customer was actually charged.
403 lines
12 KiB
PHP
403 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;
|
|
|
|
// Use ACTUAL payment amounts from the database as the source of truth,
|
|
// so the receipt total always matches what was really charged.
|
|
$actualCardPaid = (float) $order['PaymentFromCreditCard'];
|
|
$actualCashPaid = (float) $order['PaymentPaidInCash'];
|
|
$taxAmount = round($taxAmountRaw * 100) / 100;
|
|
|
|
if ($isCardOrder && $actualCardPaid > 0) {
|
|
// Grand total = what was charged to card + any balance applied
|
|
$orderGrandTotal = round(($actualCardPaid + $balanceApplied) * 100) / 100;
|
|
// Back-calculate card/processing fee as the remainder
|
|
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
|
$cardFee = round($cardFee * 100) / 100;
|
|
if ($cardFee < 0) $cardFee = 0;
|
|
} elseif ($isCashOrder && $actualCashPaid > 0) {
|
|
$orderGrandTotal = round(($actualCashPaid + $balanceApplied) * 100) / 100;
|
|
$cardFee = 0;
|
|
} elseif ($isCardOrder) {
|
|
// Fallback: recalculate if no payment record yet
|
|
$cardFeePercent = 0.029;
|
|
$cardFeeFixed = 0.30;
|
|
$totalCustomerPaysRaw = ($totalBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent);
|
|
$orderGrandTotal = round($totalCustomerPaysRaw * 100) / 100;
|
|
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
|
$cardFee = round($cardFee * 100) / 100;
|
|
} else {
|
|
$orderGrandTotal = round($totalBeforeCardFee * 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;
|