Financial data integrity rules for fullstack apps: server-side price validation, atomic multi-resource operations, split payment rounding, concurrent inventory protection, webhook-authoritative payment confirmation, and refund safety. Auto-invoke when touching payments, pricing, transactions, inventory, splits, or checkout code.
Patterns distilled from 15+ payment/pricing issues across production apps handling real money.
Rule: The backend MUST recalculate prices from the source of truth (item/product database). NEVER accept prices from client URL params, route params, or request body.
// WRONG -- client sends price, backend trusts it
app.post('/payment/create-intent', async (req, res) => {
const { totalPrice, itemId } = req.body
const intent = await paymentProvider.createIntent({ amount: totalPrice })
})
// RIGHT -- backend recalculates
app.post('/payment/create-intent', async (req, res) => {
const { itemId, quantity } = req.body
const item = await itemRepo.findById(itemId)
const totalPrice = item.unitPrice * quantity
const intent = await paymentProvider.createIntent({ amount: totalPrice })
})
Rule: At webhook confirmation time, re-verify the charged amount matches the expected price. If mismatched, flag for manual review.
Rule: Multi-item or multi-resource transactions MUST be atomic -- all succeed or all rollback. A partial state is unacceptable.
// WRONG -- sequential creation with no rollback
for (const itemId of selectedItems) {
await reserveItem({ itemId, slot }) // if 3rd fails, 1st and 2nd are orphaned
}
// RIGHT -- track IDs and rollback on failure
const reservedIds: string[] = []
try {
for (const itemId of selectedItems) {
const reservation = await reserveItem({ itemId, slot })
reservedIds.push(reservation.id)
}
} catch (error) {
await Promise.allSettled(
reservedIds.map(id => cancelReservation(id))
)
throw error
}
// BEST -- single backend mutation with DB transaction
await createBatchReservation({ itemIds: selectedItems, slot })
// Backend wraps all inserts in a single transaction
Rule: NEVER use raw floating-point division for per-person amounts. Always round UP to ensure per-person amounts cover the total.
// WRONG -- floating point: $333.33 * 3 = $999.99 (short by $0.01)
const pricePerPerson = totalPrice / participantCount
const display = pricePerPerson.toFixed(2)
// RIGHT -- round up to nearest cent
const pricePerPerson = Math.ceil((totalPrice / participantCount) * 100) / 100
// $333.34 * 3 = $1000.02 -- covers the total
Rule: For non-exact splits, display with ~ prefix or show the remainder explicitly:
// Option A: approximate indicator
"~$333.34 per person"
// Option B: explicit remainder
"2 pay $333.33, 1 pays $333.34"
Rule: All monetary calculations must happen server-side. The client displays values received from the server.
Rule: Before opening a purchase or reservation flow, ALWAYS refetch the latest state from the server. Never rely on stale cached data.
// WRONG -- uses cached/stale data
const handlePurchase = () => {
if (item.availableQuantity > 0) { // might be stale!
openCheckout()
}
}
// RIGHT -- refetch before acting
const handlePurchase = async () => {
const { data } = await refetch() // fresh data from server
if (data.item.availableQuantity > 0) {
openCheckout()
} else {
showAlert('Item no longer available')
}
}
Rule: The backend MUST perform a final availability check at purchase/reservation time (not just at UI display time). If inventory is depleted when the backend processes the request, reject with a clear error.
Rule: Subscribe to real-time events (WebSocket, SSE) to optimistically update availability in the UI, reducing the window for stale data.
Rule: NEVER calculate refund amounts client-side. The backend must compute refunds based on the original payment record, applying business rules (partial refunds, cancellation windows, fees).
Rule: Refund amounts must match the original payment currency and precision. Always use the payment provider's refund API rather than creating a new reverse payment.
Rule: Payment is CONFIRMED only after the payment provider's webhook fires. NEVER confirm a transaction based on client-side redirect alone.
// WRONG -- trust the redirect callback
app.get('/payment/callback', async (req, res) => {
await confirmOrder(req.query.orderId) // user could fake this URL
res.redirect('/success')
})
// RIGHT -- webhook is the source of truth
app.post('/payment/webhook', async (req, res) => {
const isValid = verifyWebhookSignature(req)
if (!isValid) return res.status(401).send()
const payment = req.body
if (payment.status === 'approved') {
await confirmOrder(payment.metadata.orderId)
}
res.sendStatus(200)
})
Rule: Implement pending payment recovery -- poll or listen for unresolved payments that the user may have completed but the webhook hasn't arrived for yet.
totalPrice in request body -- backend must recalculatepricePerPerson = total / count without Math.ceil rounding~ when division is inexact