Stripe and beacon API updates
- createPaymentIntent: improved error handling - webhook: updated payment processing - resolve_business: minor fix Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec0f2dea6a
commit
084e815c6c
3 changed files with 44 additions and 12 deletions
|
|
@ -84,7 +84,7 @@ function resolveSingleBusiness(uuid, major) {
|
||||||
|
|
||||||
var headerImageURL = "";
|
var headerImageURL = "";
|
||||||
if (len(qBiz.HeaderImageExtension)) {
|
if (len(qBiz.HeaderImageExtension)) {
|
||||||
headerImageURL = "/uploads/businesses/#qBiz.ID#/header.#qBiz.HeaderImageExtension#";
|
headerImageURL = "/uploads/headers/#qBiz.ID#.#qBiz.HeaderImageExtension#";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,23 @@ try {
|
||||||
abort;
|
abort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get order's delivery fee and grant economics (if applicable)
|
// Get order's delivery fee, grant economics, and existing PaymentIntent
|
||||||
qOrder = queryExecute("
|
qOrder = queryExecute("
|
||||||
SELECT DeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue
|
SELECT DeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue, StripePaymentIntentID
|
||||||
FROM Orders
|
FROM Orders
|
||||||
WHERE ID = :orderID
|
WHERE ID = :orderID
|
||||||
", { orderID: orderID }, { datasource: "payfrit" });
|
", { orderID: orderID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
// Check if order already has a PaymentIntent (idempotency check)
|
||||||
|
if (qOrder.recordCount > 0 && len(trim(qOrder.StripePaymentIntentID ?: "")) > 0) {
|
||||||
|
response["OK"] = false;
|
||||||
|
response["ERROR"] = "existing_payment_intent";
|
||||||
|
response["MESSAGE"] = "Order already has a PaymentIntent. Use existing checkout session or retry payment.";
|
||||||
|
response["PAYMENT_INTENT_ID"] = qOrder.StripePaymentIntentID;
|
||||||
|
writeOutput(serializeJSON(response));
|
||||||
|
abort;
|
||||||
|
}
|
||||||
|
|
||||||
deliveryFee = 0;
|
deliveryFee = 0;
|
||||||
if (qOrder.recordCount > 0 && qOrder.OrderTypeID == 3) {
|
if (qOrder.recordCount > 0 && qOrder.OrderTypeID == 3) {
|
||||||
deliveryFee = val(qOrder.DeliveryFee);
|
deliveryFee = val(qOrder.DeliveryFee);
|
||||||
|
|
@ -110,15 +120,18 @@ try {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Customer fee from database (PayfritFee), default 5% if not set
|
// Customer fee from database (PayfritFee), default 5% if not set
|
||||||
customerFeePercent = isNumeric(qBusiness.PayfritFee) && qBusiness.PayfritFee > 0 ? qBusiness.PayfritFee : 0.05;
|
customerFeePercent = isNumeric(qBusiness.PayfritFee) && qBusiness.PayfritFee > 0 ? qBusiness.PayfritFee : 0.05;
|
||||||
businessFeePercent = 0.05; // 5% business pays to Payfrit (always)
|
businessFeePercent = customerFeePercent; // Business fee mirrors customer fee
|
||||||
cardFeePercent = 0.029; // 2.9% Stripe fee
|
cardFeePercent = 0.029; // 2.9% Stripe fee
|
||||||
cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
|
cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
|
||||||
|
|
||||||
payfritCustomerFee = subtotal * customerFeePercent;
|
payfritCustomerFee = subtotal * customerFeePercent;
|
||||||
payfritBusinessFee = subtotal * businessFeePercent;
|
payfritBusinessFee = subtotal * businessFeePercent;
|
||||||
totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritCustomerFee;
|
totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritCustomerFee;
|
||||||
cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
|
|
||||||
totalCustomerPays = totalBeforeCardFee + cardFee;
|
// 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;
|
||||||
|
|
||||||
// Convert to cents for Stripe
|
// Convert to cents for Stripe
|
||||||
totalAmountCents = round(totalCustomerPays * 100);
|
totalAmountCents = round(totalCustomerPays * 100);
|
||||||
|
|
@ -131,6 +144,9 @@ try {
|
||||||
httpService.setUsername(stripeSecretKey);
|
httpService.setUsername(stripeSecretKey);
|
||||||
httpService.setPassword("");
|
httpService.setPassword("");
|
||||||
|
|
||||||
|
// IDEMPOTENCY: Prevent duplicate PaymentIntents on client retry
|
||||||
|
httpService.addParam(type="header", name="Idempotency-Key", value="pi-order-#orderID#");
|
||||||
|
|
||||||
httpService.addParam(type="formfield", name="amount", value=totalAmountCents);
|
httpService.addParam(type="formfield", name="amount", value=totalAmountCents);
|
||||||
httpService.addParam(type="formfield", name="currency", value="usd");
|
httpService.addParam(type="formfield", name="currency", value="usd");
|
||||||
httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true");
|
httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true");
|
||||||
|
|
@ -166,6 +182,20 @@ try {
|
||||||
abort;
|
abort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store expected amount and PaymentIntent ID for webhook verification
|
||||||
|
queryExecute("
|
||||||
|
UPDATE Orders
|
||||||
|
SET ExpectedAmountCents = :expectedCents,
|
||||||
|
StripePaymentIntentID = :piID
|
||||||
|
WHERE ID = :orderID
|
||||||
|
", {
|
||||||
|
expectedCents: totalAmountCents,
|
||||||
|
piID: piData.id,
|
||||||
|
orderID: orderID
|
||||||
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
writeLog(file="stripe_webhooks", text="PaymentIntent #piData.id# created for order #orderID# (#totalAmountCents# cents, idempotency key: pi-order-#orderID#)");
|
||||||
|
|
||||||
// Fees are calculated dynamically, not stored in DB
|
// Fees are calculated dynamically, not stored in DB
|
||||||
|
|
||||||
// Link PaymentIntent to worker payout ledger (if a ledger row exists for this order's task)
|
// Link PaymentIntent to worker payout ledger (if a ledger row exists for this order's task)
|
||||||
|
|
|
||||||
|
|
@ -233,18 +233,19 @@ try {
|
||||||
refundAmount = eventData.amount_refunded / 100;
|
refundAmount = eventData.amount_refunded / 100;
|
||||||
|
|
||||||
if (paymentIntentID != "") {
|
if (paymentIntentID != "") {
|
||||||
|
// FIX: Use correct column name (was OrderStripePaymentIntentID)
|
||||||
qOrder = queryTimed("
|
qOrder = queryTimed("
|
||||||
SELECT ID FROM Orders
|
SELECT ID FROM Orders
|
||||||
WHERE OrderStripePaymentIntentID = :paymentIntentID
|
WHERE StripePaymentIntentID = :paymentIntentID
|
||||||
", { paymentIntentID: paymentIntentID });
|
", { paymentIntentID: paymentIntentID });
|
||||||
|
|
||||||
if (qOrder.recordCount > 0) {
|
if (qOrder.recordCount > 0) {
|
||||||
queryTimed("
|
queryTimed("
|
||||||
UPDATE Orders
|
UPDATE Orders
|
||||||
SET PaymentStatus = 'refunded',
|
SET PaymentStatus = 'refunded',
|
||||||
OrderRefundAmount = :refundAmount,
|
RefundAmount = :refundAmount,
|
||||||
OrderRefundedOn = NOW()
|
RefundedOn = NOW()
|
||||||
WHERE OrderStripePaymentIntentID = :paymentIntentID
|
WHERE StripePaymentIntentID = :paymentIntentID
|
||||||
", {
|
", {
|
||||||
paymentIntentID: paymentIntentID,
|
paymentIntentID: paymentIntentID,
|
||||||
refundAmount: refundAmount
|
refundAmount: refundAmount
|
||||||
|
|
@ -261,16 +262,17 @@ try {
|
||||||
paymentIntentID = eventData.payment_intent ?: "";
|
paymentIntentID = eventData.payment_intent ?: "";
|
||||||
|
|
||||||
if (paymentIntentID != "") {
|
if (paymentIntentID != "") {
|
||||||
|
// FIX: Use correct column name (was OrderStripePaymentIntentID)
|
||||||
qOrder = queryTimed("
|
qOrder = queryTimed("
|
||||||
SELECT ID, BusinessID FROM Orders
|
SELECT ID, BusinessID FROM Orders
|
||||||
WHERE OrderStripePaymentIntentID = :paymentIntentID
|
WHERE StripePaymentIntentID = :paymentIntentID
|
||||||
", { paymentIntentID: paymentIntentID });
|
", { paymentIntentID: paymentIntentID });
|
||||||
|
|
||||||
if (qOrder.recordCount > 0) {
|
if (qOrder.recordCount > 0) {
|
||||||
queryTimed("
|
queryTimed("
|
||||||
UPDATE Orders
|
UPDATE Orders
|
||||||
SET PaymentStatus = 'disputed'
|
SET PaymentStatus = 'disputed'
|
||||||
WHERE OrderStripePaymentIntentID = :paymentIntentID
|
WHERE StripePaymentIntentID = :paymentIntentID
|
||||||
", { paymentIntentID: paymentIntentID });
|
", { paymentIntentID: paymentIntentID });
|
||||||
|
|
||||||
// Create a task for the dispute
|
// Create a task for the dispute
|
||||||
|
|
|
||||||
Reference in a new issue