Auto-apply user balance on cash and card orders

Balance from cash change now silently reduces the amount owed on the
next order. For cash: deducted immediately in submitCash, reduces cash
the worker needs to collect (or skips cash task entirely if fully
covered). For card: reduces the Stripe PaymentIntent amount, deducted
in webhook on successful payment. Receipt shows "Balance applied" line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-03-02 14:16:21 -08:00
parent 96c2ed3fc1
commit c580e6ec78
6 changed files with 143 additions and 19 deletions

View file

@ -64,7 +64,8 @@ if (cgi.REQUEST_METHOD == "GET") {
LastName, LastName,
EmailAddress, EmailAddress,
ContactNumber, ContactNumber,
ImageExtension ImageExtension,
Balance
FROM Users FROM Users
WHERE ID = :userId WHERE ID = :userId
LIMIT 1 LIMIT 1
@ -88,7 +89,8 @@ if (cgi.REQUEST_METHOD == "GET") {
"LastName": qUser.LastName ?: "", "LastName": qUser.LastName ?: "",
"Email": qUser.EmailAddress ?: "", "Email": qUser.EmailAddress ?: "",
"Phone": qUser.ContactNumber ?: "", "Phone": qUser.ContactNumber ?: "",
"AvatarUrl": avatarUrl "AvatarUrl": avatarUrl,
"Balance": val(qUser.Balance)
} }
})); }));
abort; abort;

View file

@ -43,7 +43,8 @@
<!--- Get order details with business fee rate ---> <!--- Get order details with business fee rate --->
<cfset qOrder = queryExecute( <cfset qOrder = queryExecute(
"SELECT o.ID, o.StatusID, o.UserID, o.BusinessID, o.ServicePointID, o.PaymentID, "SELECT o.ID, o.StatusID, o.UserID, o.BusinessID, o.ServicePointID, o.PaymentID,
b.PayfritFee o.DeliveryFee, o.OrderTypeID,
b.PayfritFee, b.TaxRate
FROM Orders o FROM Orders o
INNER JOIN Businesses b ON b.ID = o.BusinessID INNER JOIN Businesses b ON b.ID = o.BusinessID
WHERE o.ID = ? LIMIT 1", WHERE o.ID = ? LIMIT 1",
@ -74,20 +75,58 @@
<cfset subtotal = val(qSubtotal.Subtotal)> <cfset subtotal = val(qSubtotal.Subtotal)>
<cfset platformFee = subtotal * feeRate> <cfset platformFee = subtotal * feeRate>
<!--- Create Payment record with expected cash amount ---> <!--- Calculate full order total for balance application --->
<cfset CashAmountCents = round(CashAmount * 100)> <cfset taxRate = (isNumeric(qOrder.TaxRate) AND val(qOrder.TaxRate) GT 0) ? val(qOrder.TaxRate) : 0>
<cfset taxAmount = subtotal * taxRate>
<cfset deliveryFee = (val(qOrder.OrderTypeID) EQ 3) ? val(qOrder.DeliveryFee) : 0>
<cfset orderTotal = subtotal + taxAmount + platformFee + Tip + deliveryFee>
<!--- Auto-apply user balance (silently reduces cash owed) --->
<cfset balanceApplied = 0>
<cfset cashNeeded = orderTotal>
<cfif val(qOrder.UserID) GT 0>
<cfset qBalance = queryExecute(
"SELECT Balance FROM Users WHERE ID = ?",
[ { value = qOrder.UserID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset userBalance = val(qBalance.Balance)>
<cfif userBalance GT 0>
<cfset balanceToApply = min(userBalance, orderTotal)>
<!--- Atomic deduct: only succeeds if balance is still sufficient --->
<cfset qDeduct = queryExecute(
"UPDATE Users SET Balance = Balance - ? WHERE ID = ? AND Balance >= ?",
[
{ value = balanceToApply, cfsqltype = "cf_sql_decimal" },
{ value = qOrder.UserID, cfsqltype = "cf_sql_integer" },
{ value = balanceToApply, cfsqltype = "cf_sql_decimal" }
],
{ datasource = "payfrit", result = "deductResult" }
)>
<cfif val(deductResult.recordCount) GT 0>
<cfset balanceApplied = balanceToApply>
<cfset cashNeeded = orderTotal - balanceApplied>
<cfif cashNeeded LT 0><cfset cashNeeded = 0></cfif>
</cfif>
</cfif>
</cfif>
<!--- Create Payment record with adjusted cash amount --->
<cfset CashAmountCents = round(cashNeeded * 100)>
<cfset qInsertPayment = queryExecute( <cfset qInsertPayment = queryExecute(
"INSERT INTO Payments ( "INSERT INTO Payments (
PaymentPaidInCash, PaymentPaidInCash,
PaymentFromPayfritBalance,
PaymentFromCreditCard, PaymentFromCreditCard,
PaymentSentByUserID, PaymentSentByUserID,
PaymentReceivedByUserID, PaymentReceivedByUserID,
PaymentOrderID, PaymentOrderID,
PaymentPayfritsCut, PaymentPayfritsCut,
PaymentAddedOn PaymentAddedOn
) VALUES (?, 0, ?, 0, ?, ?, NOW())", ) VALUES (?, ?, 0, ?, 0, ?, ?, NOW())",
[ [
{ value = CashAmount, cfsqltype = "cf_sql_decimal" }, { value = cashNeeded, cfsqltype = "cf_sql_decimal" },
{ value = balanceApplied, cfsqltype = "cf_sql_decimal" },
{ value = qOrder.UserID, cfsqltype = "cf_sql_integer" }, { value = qOrder.UserID, cfsqltype = "cf_sql_integer" },
{ value = OrderID, cfsqltype = "cf_sql_integer" }, { value = OrderID, cfsqltype = "cf_sql_integer" },
{ value = platformFee, cfsqltype = "cf_sql_decimal" } { value = platformFee, cfsqltype = "cf_sql_decimal" }
@ -97,27 +136,41 @@
<cfset PaymentID = insertResult.generatedKey> <cfset PaymentID = insertResult.generatedKey>
<!--- Update order: link payment, set status to submitted, store platform fee ---> <!--- Update order: link payment, set status to submitted, store platform fee and balance --->
<!--- If balance covers full order, mark as paid immediately (no cash task needed) --->
<cfset fullyPaidByBalance = (cashNeeded LTE 0)>
<cfset paymentStatus = fullyPaidByBalance ? "paid" : "pending">
<cfset queryExecute( <cfset queryExecute(
"UPDATE Orders "UPDATE Orders
SET StatusID = 1, SET StatusID = 1,
PaymentID = ?, PaymentID = ?,
PaymentStatus = 'pending', PaymentStatus = ?,
PlatformFee = ?, PlatformFee = ?,
TipAmount = ?, TipAmount = ?,
BalanceApplied = ?,
SubmittedOn = NOW(), SubmittedOn = NOW(),
LastEditedOn = NOW() LastEditedOn = NOW(),
PaymentCompletedOn = CASE WHEN ? = 1 THEN NOW() ELSE PaymentCompletedOn END
WHERE ID = ?", WHERE ID = ?",
[ [
{ value = PaymentID, cfsqltype = "cf_sql_integer" }, { value = PaymentID, cfsqltype = "cf_sql_integer" },
{ value = paymentStatus, cfsqltype = "cf_sql_varchar" },
{ value = platformFee, cfsqltype = "cf_sql_decimal" }, { value = platformFee, cfsqltype = "cf_sql_decimal" },
{ value = Tip, cfsqltype = "cf_sql_decimal" }, { value = Tip, cfsqltype = "cf_sql_decimal" },
{ value = balanceApplied, cfsqltype = "cf_sql_decimal" },
{ value = fullyPaidByBalance ? 1 : 0, cfsqltype = "cf_sql_integer" },
{ value = OrderID, cfsqltype = "cf_sql_integer" } { value = OrderID, cfsqltype = "cf_sql_integer" }
], ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfset apiAbort({ "OK": true, "OrderID": OrderID, "PaymentID": PaymentID, "CashAmountCents": CashAmountCents, "MESSAGE": "Order submitted with cash payment." })> <cfset response = { "OK": true, "OrderID": OrderID, "PaymentID": PaymentID, "CashAmountCents": CashAmountCents, "MESSAGE": "Order submitted with cash payment." }>
<cfif balanceApplied GT 0>
<cfset response["BalanceApplied"] = round(balanceApplied * 100) / 100>
<cfset response["BalanceAppliedCents"] = round(balanceApplied * 100)>
<cfset response["FullyPaidByBalance"] = (cashNeeded LTE 0)>
</cfif>
<cfset apiAbort(response)>
<cfcatch> <cfcatch>
<cfset apiAbort({ <cfset apiAbort({

View file

@ -78,7 +78,8 @@ try {
qOrder = queryExecute(" qOrder = queryExecute("
SELECT o.DeliveryFee, o.OrderTypeID, o.GrantID, o.GrantOwnerBusinessID, SELECT o.DeliveryFee, o.OrderTypeID, o.GrantID, o.GrantOwnerBusinessID,
o.GrantEconomicsType, o.GrantEconomicsValue, o.StripePaymentIntentID, o.GrantEconomicsType, o.GrantEconomicsValue, o.StripePaymentIntentID,
o.UserID, u.StripeCustomerId, u.EmailAddress, u.FirstName, u.LastName o.UserID, o.BalanceApplied AS PrevBalanceApplied,
u.StripeCustomerId, u.EmailAddress, u.FirstName, u.LastName, u.Balance
FROM Orders o FROM Orders o
LEFT JOIN Users u ON u.ID = o.UserID LEFT JOIN Users u ON u.ID = o.UserID
WHERE o.ID = :orderID WHERE o.ID = :orderID
@ -126,10 +127,36 @@ try {
payfritBusinessFee = subtotal * businessFeePercent; payfritBusinessFee = subtotal * businessFeePercent;
totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritCustomerFee; totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritCustomerFee;
// ============================================================
// AUTO-APPLY USER BALANCE (silently reduces card charge)
// ============================================================
balanceApplied = 0;
userBalance = val(qOrder.Balance ?: 0);
orderUserID = val(qOrder.UserID ?: 0);
if (userBalance > 0 && orderUserID > 0) {
balanceApplied = min(userBalance, totalBeforeCardFee);
// Ensure Stripe minimum: adjusted amount after card fee must be >= $0.50
adjustedTest = ((totalBeforeCardFee - balanceApplied) + cardFeeFixed) / (1 - cardFeePercent);
if (adjustedTest < 0.50) {
// Cap balance so Stripe charge stays >= $0.50
maxBalance = totalBeforeCardFee - ((0.50 * (1 - cardFeePercent)) - cardFeeFixed);
balanceApplied = max(0, min(userBalance, maxBalance));
}
// Store intent on order (actual deduction happens in webhook after payment succeeds)
if (balanceApplied > 0) {
queryExecute("UPDATE Orders SET BalanceApplied = :bal WHERE ID = :orderID",
{ bal: { value: balanceApplied, cfsqltype: "cf_sql_decimal" }, orderID: orderID },
{ datasource: "payfrit" });
}
}
// Apply balance: reduce the pre-card-fee amount, recalculate card fee on smaller amount
adjustedBeforeCardFee = totalBeforeCardFee - balanceApplied;
// Stripe charges 2.9% + $0.30 on the TOTAL charged, not the pre-fee amount. // Stripe charges 2.9% + $0.30 on the TOTAL charged, not the pre-fee amount.
// To fully pass through Stripe fees: charge = (net + fixed) / (1 - percent) // To fully pass through Stripe fees: charge = (net + fixed) / (1 - percent)
totalCustomerPays = (totalBeforeCardFee + cardFeeFixed) / (1 - cardFeePercent); totalCustomerPays = (adjustedBeforeCardFee + cardFeeFixed) / (1 - cardFeePercent);
cardFee = totalCustomerPays - totalBeforeCardFee; cardFee = totalCustomerPays - adjustedBeforeCardFee;
// Convert to cents for Stripe // Convert to cents for Stripe
totalAmountCents = round(totalCustomerPays * 100); totalAmountCents = round(totalCustomerPays * 100);
@ -171,6 +198,14 @@ try {
response["PAYMENT_INTENT_ID"] = existingPi.id; response["PAYMENT_INTENT_ID"] = existingPi.id;
response["PUBLISHABLE_KEY"] = application.stripePublishableKey ?: "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN"; response["PUBLISHABLE_KEY"] = application.stripePublishableKey ?: "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN";
response["REUSED"] = true; response["REUSED"] = true;
response["FEE_BREAKDOWN"] = {
"SUBTOTAL": subtotal, "TAX": tax, "TIP": tip,
"DELIVERY_FEE": deliveryFee, "PAYFRIT_FEE": payfritCustomerFee,
"CARD_FEE": cardFee, "BALANCE_APPLIED": balanceApplied,
"TOTAL": totalCustomerPays,
"TOTAL_BEFORE_BALANCE": (balanceApplied > 0) ? totalCustomerPays + balanceApplied : 0,
"GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents
};
writeOutput(serializeJSON(response)); writeOutput(serializeJSON(response));
abort; abort;
} else if (piStatus == "succeeded") { } else if (piStatus == "succeeded") {
@ -360,7 +395,9 @@ try {
"DELIVERY_FEE": deliveryFee, "DELIVERY_FEE": deliveryFee,
"PAYFRIT_FEE": payfritCustomerFee, "PAYFRIT_FEE": payfritCustomerFee,
"CARD_FEE": cardFee, "CARD_FEE": cardFee,
"BALANCE_APPLIED": balanceApplied,
"TOTAL": totalCustomerPays, "TOTAL": totalCustomerPays,
"TOTAL_BEFORE_BALANCE": (balanceApplied > 0) ? totalCustomerPays + balanceApplied : 0,
"GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents "GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents
}; };
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect; response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;

View file

@ -123,6 +123,17 @@ try {
", { orderID: orderID }); ", { orderID: orderID });
writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid"); writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid");
// === DEDUCT USER BALANCE (if balance was applied at checkout) ===
qBalOrder = queryTimed("
SELECT BalanceApplied, UserID FROM Orders WHERE ID = :orderID
", { orderID: orderID });
if (qBalOrder.recordCount > 0 && val(qBalOrder.BalanceApplied) > 0) {
queryTimed("
UPDATE Users SET Balance = GREATEST(Balance - :amount, 0) WHERE ID = :userID
", { amount: val(qBalOrder.BalanceApplied), userID: val(qBalOrder.UserID) });
writeLog(file="stripe_webhooks", text="Order #orderID# balance deducted: $#qBalOrder.BalanceApplied#");
}
} }
// === WORKER PAYOUT TRANSFER === // === WORKER PAYOUT TRANSFER ===

View file

@ -144,7 +144,8 @@
</cfif> </cfif>
<cfset qOrderTotal = queryTimed(" <cfset qOrderTotal = queryTimed("
SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate, b.PayfritFee SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID,
o.BalanceApplied, b.TaxRate, b.PayfritFee
FROM Orders o FROM Orders o
INNER JOIN Businesses b ON b.ID = o.BusinessID INNER JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = b'0' LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = b'0'
@ -171,16 +172,23 @@
<cfset orderTotalCents = round((cashSubtotal + cashTax + cashTip + cashDeliveryFee + customerFeeDollars) * 100)> <cfset orderTotalCents = round((cashSubtotal + cashTax + cashTip + cashDeliveryFee + customerFeeDollars) * 100)>
<cfif CashReceivedCents LT orderTotalCents> <!--- Account for balance already applied in submitCash.cfm --->
<cfset apiAbort({ "OK": false, "ERROR": "insufficient_cash", "MESSAGE": "Cash received ($#numberFormat(CashReceivedCents/100, '0.00')#) is less than order total ($#numberFormat(orderTotalCents/100, '0.00')#)." })> <cfset balanceAppliedCents = round(val(qOrderTotal.BalanceApplied) * 100)>
<cfset cashOwedCents = orderTotalCents - balanceAppliedCents>
<cfif cashOwedCents LT 0><cfset cashOwedCents = 0></cfif>
<cfif CashReceivedCents LT cashOwedCents>
<cfset apiAbort({ "OK": false, "ERROR": "insufficient_cash", "MESSAGE": "Cash received ($#numberFormat(CashReceivedCents/100, '0.00')#) is less than cash owed ($#numberFormat(cashOwedCents/100, '0.00')#)." })>
</cfif> </cfif>
<cfset payfritRevenueCents = round(payfritRevenueDollars * 100)> <cfset payfritRevenueCents = round(payfritRevenueDollars * 100)>
<cfset changeCents = CashReceivedCents - orderTotalCents> <cfset changeCents = CashReceivedCents - cashOwedCents>
<cfset businessReceivesCents = orderTotalCents - payfritRevenueCents> <cfset businessReceivesCents = orderTotalCents - payfritRevenueCents>
<cfset cashResult = { <cfset cashResult = {
"orderTotalCents": orderTotalCents, "orderTotalCents": orderTotalCents,
"cashOwedCents": cashOwedCents,
"balanceAppliedCents": balanceAppliedCents,
"cashReceivedCents": CashReceivedCents, "cashReceivedCents": CashReceivedCents,
"changeCents": changeCents, "changeCents": changeCents,
"customerFeeCents": round(customerFeeDollars * 100), "customerFeeCents": round(customerFeeDollars * 100),
@ -418,6 +426,10 @@
<cfset response["BusinessFee"] = numberFormat(round(businessFeeDollars * 100) / 100, "0.00")> <cfset response["BusinessFee"] = numberFormat(round(businessFeeDollars * 100) / 100, "0.00")>
<cfset response["PayfritRevenue"] = numberFormat(payfritRevenueCents / 100, "0.00")> <cfset response["PayfritRevenue"] = numberFormat(payfritRevenueCents / 100, "0.00")>
<cfset response["BusinessReceives"] = numberFormat(businessReceivesCents / 100, "0.00")> <cfset response["BusinessReceives"] = numberFormat(businessReceivesCents / 100, "0.00")>
<cfif balanceAppliedCents GT 0>
<cfset response["BalanceApplied"] = numberFormat(balanceAppliedCents / 100, "0.00")>
<cfset response["CashOwed"] = numberFormat(cashOwedCents / 100, "0.00")>
</cfif>
</cfif> </cfif>
<cfset apiAbort(response)> <cfset apiAbort(response)>

View file

@ -16,7 +16,7 @@
<cfset cart_grand_total = 0> <cfset cart_grand_total = 0>
<cfquery name="get_order_info"> <cfquery name="get_order_info">
SELECT O.OrderTypeID, O.BusinessID, O.Remarks, O.ID, SELECT O.OrderTypeID, O.BusinessID, O.Remarks, O.ID, O.BalanceApplied,
B.Name, B.TaxRate, B.PayfritFee B.Name, B.TaxRate, B.PayfritFee
FROM Orders O FROM Orders O
JOIN Businesses B ON B.ID = O.BusinessID JOIN Businesses B ON B.ID = O.BusinessID
@ -347,6 +347,15 @@
<span>#dollarFormat(cardFee)#</span> <span>#dollarFormat(cardFee)#</span>
</div> </div>
<cfset receiptBalanceApplied = val(get_order_info.BalanceApplied)>
<cfif receiptBalanceApplied GT 0>
<div class="total-row">
<span>Balance applied</span>
<span>-#dollarFormat(receiptBalanceApplied)#</span>
</div>
<cfset order_grand_total = order_grand_total - receiptBalanceApplied>
</cfif>
<div class="total-row grand-total"> <div class="total-row grand-total">
<span>Total</span> <span>Total</span>
<span>#dollarFormat(order_grand_total)#</span> <span>#dollarFormat(order_grand_total)#</span>