payfrit-api/receipt/index.php
Mike b25198b3f5 fix: receipt total now uses actual payment amount from DB
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.
2026-03-22 21:38:17 +00:00

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;