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

View file

@ -43,7 +43,8 @@
<!--- Get order details with business fee rate --->
<cfset qOrder = queryExecute(
"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
INNER JOIN Businesses b ON b.ID = o.BusinessID
WHERE o.ID = ? LIMIT 1",
@ -74,20 +75,58 @@
<cfset subtotal = val(qSubtotal.Subtotal)>
<cfset platformFee = subtotal * feeRate>
<!--- Create Payment record with expected cash amount --->
<cfset CashAmountCents = round(CashAmount * 100)>
<!--- Calculate full order total for balance application --->
<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(
"INSERT INTO Payments (
PaymentPaidInCash,
PaymentFromPayfritBalance,
PaymentFromCreditCard,
PaymentSentByUserID,
PaymentReceivedByUserID,
PaymentOrderID,
PaymentPayfritsCut,
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 = OrderID, cfsqltype = "cf_sql_integer" },
{ value = platformFee, cfsqltype = "cf_sql_decimal" }
@ -97,27 +136,41 @@
<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(
"UPDATE Orders
SET StatusID = 1,
PaymentID = ?,
PaymentStatus = 'pending',
PaymentStatus = ?,
PlatformFee = ?,
TipAmount = ?,
BalanceApplied = ?,
SubmittedOn = NOW(),
LastEditedOn = NOW()
LastEditedOn = NOW(),
PaymentCompletedOn = CASE WHEN ? = 1 THEN NOW() ELSE PaymentCompletedOn END
WHERE ID = ?",
[
{ value = PaymentID, cfsqltype = "cf_sql_integer" },
{ value = paymentStatus, cfsqltype = "cf_sql_varchar" },
{ value = platformFee, 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" }
],
{ 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>
<cfset apiAbort({

View file

@ -78,7 +78,8 @@ try {
qOrder = queryExecute("
SELECT o.DeliveryFee, o.OrderTypeID, o.GrantID, o.GrantOwnerBusinessID,
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
LEFT JOIN Users u ON u.ID = o.UserID
WHERE o.ID = :orderID
@ -126,10 +127,36 @@ try {
payfritBusinessFee = subtotal * businessFeePercent;
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.
// To fully pass through Stripe fees: charge = (net + fixed) / (1 - percent)
totalCustomerPays = (totalBeforeCardFee + cardFeeFixed) / (1 - cardFeePercent);
cardFee = totalCustomerPays - totalBeforeCardFee;
totalCustomerPays = (adjustedBeforeCardFee + cardFeeFixed) / (1 - cardFeePercent);
cardFee = totalCustomerPays - adjustedBeforeCardFee;
// Convert to cents for Stripe
totalAmountCents = round(totalCustomerPays * 100);
@ -171,6 +198,14 @@ try {
response["PAYMENT_INTENT_ID"] = existingPi.id;
response["PUBLISHABLE_KEY"] = application.stripePublishableKey ?: "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN";
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));
abort;
} else if (piStatus == "succeeded") {
@ -360,7 +395,9 @@ try {
"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
};
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;

View file

@ -123,6 +123,17 @@ try {
", { orderID: orderID });
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 ===

View file

@ -144,7 +144,8 @@
</cfif>
<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
INNER JOIN Businesses b ON b.ID = o.BusinessID
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)>
<cfif CashReceivedCents LT orderTotalCents>
<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')#)." })>
<!--- Account for balance already applied in submitCash.cfm --->
<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>
<cfset payfritRevenueCents = round(payfritRevenueDollars * 100)>
<cfset changeCents = CashReceivedCents - orderTotalCents>
<cfset changeCents = CashReceivedCents - cashOwedCents>
<cfset businessReceivesCents = orderTotalCents - payfritRevenueCents>
<cfset cashResult = {
"orderTotalCents": orderTotalCents,
"cashOwedCents": cashOwedCents,
"balanceAppliedCents": balanceAppliedCents,
"cashReceivedCents": CashReceivedCents,
"changeCents": changeCents,
"customerFeeCents": round(customerFeeDollars * 100),
@ -418,6 +426,10 @@
<cfset response["BusinessFee"] = numberFormat(round(businessFeeDollars * 100) / 100, "0.00")>
<cfset response["PayfritRevenue"] = numberFormat(payfritRevenueCents / 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>
<cfset apiAbort(response)>

View file

@ -16,7 +16,7 @@
<cfset cart_grand_total = 0>
<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
FROM Orders O
JOIN Businesses B ON B.ID = O.BusinessID
@ -347,6 +347,15 @@
<span>#dollarFormat(cardFee)#</span>
</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">
<span>Total</span>
<span>#dollarFormat(order_grand_total)#</span>