feat: Implement return and refund functionality in commerce module
CI / test (push) Successful in 10m27s

- Added return request handling in return.controller.js
- Implemented webhook handling for PayHere payments in webhook.controller.js
- Created models for coupon redemptions, invoices, orders, order items, payments, payment attempts, payment webhook events, refunds, return items, and return requests.
- Developed services for order management, payment processing, refunds, and returns.
- Introduced validation schemas for payment and return requests.
- Created migration scripts for new database tables related to orders, payments, refunds, and returns.
- Added unit tests for order state transitions and PayHere provider functionality.
This commit is contained in:
Sathira Sri Sathara
2026-09-09 12:39:57 +05:30
parent ad9287b804
commit cd2c1c6d08
38 changed files with 192 additions and 4 deletions
+4
View File
@@ -0,0 +1,4 @@
const crypto=require("crypto"),db=require("../../models"),inventory=require("../inventory/inventory.service"),state=require("./orderState.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});const ref=async(prefix,key,t)=>`${prefix}-${new Date().getUTCFullYear()}-${pad(await nextSequence(key,t),6)}`;
async function createFromCheckout({checkoutId,userId}){return db.sequelize.transaction(async transaction=>{const checkout=await db.CheckoutSession.findOne({where:{id:checkoutId,user_id:userId},include:[{model:db.CheckoutItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(!checkout)throw fault("Checkout not found","NOT_FOUND",404);let order=await db.Order.findOne({where:{checkout_session_id:checkout.id},transaction,lock:transaction.LOCK.UPDATE});if(order)return {order,idempotent:true};if(checkout.status!=="READY"||new Date(checkout.expires_at)<=new Date())throw fault("Checkout is not ready","CHECKOUT_NOT_READY",409);for(const item of checkout.items){const reservation=await db.InventoryReservation.findOne({where:{reservation_key:item.reservation_key,status:"ACTIVE"},transaction,lock:transaction.LOCK.UPDATE});if(!reservation)throw fault("Checkout reservation is unavailable","RESERVATION_UNAVAILABLE",409);}order=await db.Order.create({id:crypto.randomUUID(),order_number:await ref("ORD","orders",transaction),user_id:userId,business_customer_id:checkout.business_customer_id,checkout_session_id:checkout.id,status:"PENDING_PAYMENT",payment_status:"PENDING",currency:checkout.currency,subtotal:checkout.subtotal,discount_total:checkout.discount_total,shipping_amount:checkout.shipping_amount,tax_amount:checkout.tax_amount,duty_amount:checkout.duty_amount,grand_total:checkout.grand_total,shipping_address_snapshot:checkout.shipping_address_snapshot,billing_address_snapshot:checkout.billing_address_snapshot,shipping_method_snapshot:checkout.shipping_snapshot,coupon_code:checkout.coupon_code,placed_at:new Date()},{transaction});await db.OrderItem.bulkCreate(checkout.items.map(x=>({id:crypto.randomUUID(),order_id:order.id,product_id:x.product_id,variant_id:x.variant_id,reservation_key:x.reservation_key,sku:x.sku,product_name:x.product_name,variant_description:x.variant_description,quantity:x.quantity,unit_price:x.unit_price,discount_amount:x.discount_amount,line_total:x.line_total,currency:x.currency,metadata:x.metadata})),{transaction});await checkout.update({status:"CONVERTED"},{transaction});await db.Cart.update({status:"CONVERTED"},{where:{id:checkout.cart_id,status:"CHECKOUT_LOCKED"},transaction});return {order,idempotent:false};});}
async function cancel({orderId,userId,admin=false,requestId}){return db.sequelize.transaction(async transaction=>{const where={id:orderId,...(!admin&&{user_id:userId})},order=await db.Order.findOne({where,include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments"}],transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Order not found","NOT_FOUND",404);if(order.status==="CANCELLED")return order;if(order.payment_status==="PAID")throw fault("Paid orders require a refund","REFUND_REQUIRED",409);state.assertTransition(order.status,"CANCELLED");for(const item of order.items)await inventory.releaseReservation({reservationKey:item.reservation_key,requestId,transaction});await db.Payment.update({status:"CANCELLED",cancelled_at:new Date()},{where:{order_id:order.id,status:["PENDING","REQUIRES_ACTION"]},transaction});await order.update({status:"CANCELLED",payment_status:"CANCELLED",cancelled_at:new Date()},{transaction});return order;});}
module.exports={createFromCheckout,cancel,ref};
@@ -0,0 +1 @@
const transitions={PENDING_PAYMENT:new Set(["PAID","CANCELLED"]),PAID:new Set(["PROCESSING","REFUND_PENDING"]),PROCESSING:new Set(["READY_FOR_FULFILLMENT","REFUND_PENDING"]),READY_FOR_FULFILLMENT:new Set(["PARTIALLY_FULFILLED","FULFILLED"]),PARTIALLY_FULFILLED:new Set(["FULFILLED"]),FULFILLED:new Set(["COMPLETED"]),COMPLETED:new Set(["REFUND_PENDING"]),REFUND_PENDING:new Set(["PARTIALLY_REFUNDED","REFUNDED"]),PARTIALLY_REFUNDED:new Set(["REFUND_PENDING","REFUNDED"]),CANCELLED:new Set(),REFUNDED:new Set()};const canTransition=(from,to)=>Boolean(transitions[from]?.has(to));const assertTransition=(from,to)=>{if(!canTransition(from,to))throw Object.assign(new Error(`Illegal order transition ${from} -> ${to}`),{code:"ILLEGAL_ORDER_TRANSITION",status:409});};module.exports={transitions,canTransition,assertTransition};
+6
View File
@@ -0,0 +1,6 @@
const crypto=require("crypto"),db=require("../../models"),money=require("../pricing/money"),inventory=require("../inventory/inventory.service"),state=require("./orderState.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util"),payhere=require("./providers/payhere.provider");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});const adapters={PAYHERE:payhere};
async function create({orderId,userId,provider="PAYHERE",method="ONLINE"}){if(!adapters[provider])throw fault("Payment provider is unavailable","PROVIDER_NOT_CONFIGURED",503);return db.sequelize.transaction(async transaction=>{const order=await db.Order.findOne({where:{id:orderId,user_id:userId,status:"PENDING_PAYMENT"},transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Payable order not found","NOT_FOUND",404);let payment=await db.Payment.findOne({where:{order_id:order.id,status:["PENDING","REQUIRES_ACTION"]},transaction,lock:transaction.LOCK.UPDATE});if(payment)return payment;payment=await db.Payment.create({id:crypto.randomUUID(),order_id:order.id,payment_reference:order.order_number,provider,method,status:"PENDING",currency:order.currency,amount:order.grand_total},{transaction});await db.PaymentAttempt.create({id:crypto.randomUUID(),payment_id:payment.id,attempt_number:1,provider_request_id:crypto.randomUUID(),status:"STARTED",amount:payment.amount,started_at:new Date(),metadata:{provider}},{transaction});return payment;});}
async function redeemCoupon(order,transaction){if(!order.coupon_code)return;const coupon=await db.Coupon.findOne({where:{code:order.coupon_code},transaction,lock:transaction.LOCK.UPDATE});if(!coupon)return;const used=await db.CouponRedemption.count({where:{coupon_id:coupon.id},transaction});if(coupon.max_uses&&used>=coupon.max_uses)throw fault("Coupon usage limit reached","COUPON_LIMIT_REACHED",409);await db.CouponRedemption.findOrCreate({where:{coupon_id:coupon.id,order_id:order.id},defaults:{id:crypto.randomUUID(),user_id:order.user_id,redeemed_at:new Date()},transaction});}
async function issueInvoice(order,transaction){const existing=await db.Invoice.findOne({where:{order_id:order.id},transaction,lock:transaction.LOCK.UPDATE});if(existing)return existing;const n=await nextSequence("invoices",transaction);return db.Invoice.create({id:crypto.randomUUID(),invoice_number:`INV-${new Date().getUTCFullYear()}-${pad(n,6)}`,order_id:order.id,status:"ISSUED",currency:order.currency,subtotal:order.subtotal,discount_total:order.discount_total,shipping_amount:order.shipping_amount,tax_amount:order.tax_amount,duty_amount:order.duty_amount,grand_total:order.grand_total,issued_at:new Date(),paid_at:new Date()},{transaction});}
async function handleWebhook(provider,payload){const adapter=adapters[provider];if(!adapter||!adapter.verifyWebhook(payload))throw fault("Invalid webhook signature","INVALID_WEBHOOK_SIGNATURE",401);const event=adapter.parseWebhook(payload),hash=crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");return db.sequelize.transaction(async transaction=>{let record=await db.PaymentWebhookEvent.findOne({where:{provider,provider_event_id:event.eventId},transaction,lock:transaction.LOCK.UPDATE});if(record?.processing_status==="PROCESSED")return{idempotent:true,status:"PROCESSED"};if(!record)record=await db.PaymentWebhookEvent.create({id:crypto.randomUUID(),provider,provider_event_id:event.eventId,event_type:event.eventType,payload_hash:hash,processing_status:"RECEIVED",received_at:new Date()},{transaction});const payment=await db.Payment.findOne({where:{payment_reference:event.paymentReference,provider},transaction,lock:transaction.LOCK.UPDATE});if(!payment)throw fault("Payment not found","PAYMENT_NOT_FOUND",404);if(payment.currency!==event.currency||money.parse(payment.amount)!==money.parse(event.amount))throw fault("Payment amount or currency mismatch","PAYMENT_MISMATCH",409);const order=await db.Order.findByPk(payment.order_id,{include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(event.status==="PAID"&&payment.status!=="PAID"){state.assertTransition(order.status,"PAID");for(const item of [...order.items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id)))await inventory.consumeReservation({reservationKey:item.reservation_key,requestId:`webhook:${record.id}`,transaction});await redeemCoupon(order,transaction);await payment.update({status:"PAID",provider_transaction_id:event.providerTransactionId,paid_at:new Date()},{transaction});await order.update({status:"PAID",payment_status:"PAID"},{transaction});await issueInvoice(order,transaction);}else if(event.status==="FAILED"&&payment.status!=="PAID"){await payment.update({status:"FAILED",failed_at:new Date(),failure_code:event.eventType},{transaction});await order.update({payment_status:"FAILED"},{transaction});}await record.update({processing_status:"PROCESSED",processed_at:new Date()},{transaction});return{idempotent:false,status:event.status,paymentId:payment.id};});}
module.exports={create,handleWebhook,redeemCoupon,issueInvoice,adapters};
@@ -0,0 +1,5 @@
const crypto=require("crypto"),md5=x=>crypto.createHash("md5").update(String(x)).digest("hex").toUpperCase();
function verifyWebhook(p){const secret=process.env.PAYHERE_MERCHANT_SECRET;if(!secret)return false;const expected=md5(`${p.merchant_id}${p.order_id}${p.payhere_amount}${p.payhere_currency}${p.status_code}${md5(secret)}`),actual=String(p.md5sig||"").toUpperCase().padEnd(expected.length,"0").slice(0,expected.length);return crypto.timingSafeEqual(Buffer.from(expected),Buffer.from(actual));}
function parseWebhook(p){return{eventId:p.payment_id||`${p.order_id}:${p.status_code}:${p.payhere_amount}`,paymentReference:p.order_id,providerTransactionId:p.payment_id,status:String(p.status_code)==="2"?"PAID":String(p.status_code)==="0"?"PENDING":"FAILED",amount:String(p.payhere_amount),currency:p.payhere_currency,eventType:`PAYHERE_${p.status_code}`};}
function createPayment({reference,amount,currency}){const merchantId=process.env.PAYHERE_MERCHANT_ID,secret=process.env.PAYHERE_MERCHANT_SECRET;if(!merchantId||!secret)throw Object.assign(new Error("PayHere is not configured"),{code:"PROVIDER_NOT_CONFIGURED",status:503});return{merchant_id:merchantId,order_id:reference,amount,currency,hash:md5(`${merchantId}${reference}${amount}${currency}${md5(secret)}`),notify_url:process.env.PAYHERE_NOTIFY_URL,return_url:process.env.PAYHERE_RETURN_URL,cancel_url:process.env.PAYHERE_CANCEL_URL};}
const unavailable=()=>{throw Object.assign(new Error("Provider operation is not configured"),{code:"PROVIDER_OPERATION_UNAVAILABLE",status:503});};module.exports={createPayment,verifyWebhook,parseWebhook,refund:unavailable,getPaymentStatus:unavailable};
@@ -0,0 +1 @@
const unavailable=()=>{throw Object.assign(new Error("Stripe adapter is disabled until the official SDK and webhook secret are configured"),{code:"PROVIDER_NOT_CONFIGURED",status:503});};module.exports={createPayment:unavailable,verifyWebhook:()=>false,parseWebhook:unavailable,refund:unavailable,getPaymentStatus:unavailable};
+1
View File
@@ -0,0 +1 @@
const crypto=require("crypto"),db=require("../../models"),money=require("../pricing/money"),{nextSequence,pad}=require("../../utils/referenceNumber.util");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});async function request({orderId,paymentId,items,reasonCode,reasonText,actorUserId,operationKey}){return db.sequelize.transaction(async transaction=>{const prior=await db.Refund.findOne({where:{operation_key:operationKey},transaction,lock:transaction.LOCK.UPDATE});if(prior)return{refund:prior,idempotent:true};const order=await db.Order.findByPk(orderId,{transaction,lock:transaction.LOCK.UPDATE}),payment=await db.Payment.findOne({where:{id:paymentId,order_id:orderId,status:["PAID","PARTIALLY_REFUNDED"]},transaction,lock:transaction.LOCK.UPDATE});if(!order||!payment)throw fault("Refundable payment not found","NOT_REFUNDABLE",409);let total="0.00";const rows=[];for(const input of items){const item=await db.OrderItem.findOne({where:{id:input.orderItemId,order_id:orderId},transaction,lock:transaction.LOCK.UPDATE});if(!item||input.quantity<=0||item.refunded_quantity+input.quantity>item.quantity)throw fault("Refund quantity exceeds purchased quantity","INVALID_REFUND_QUANTITY",409);const amount=money.multiply(item.unit_price,input.quantity);total=money.add(total,amount);rows.push({item,input,amount});}const completed=await db.Refund.sum("amount",{where:{payment_id:payment.id,status:"COMPLETED"},transaction})||"0.00";if(money.parse(total)+money.parse(completed)>money.parse(payment.amount))throw fault("Refund exceeds captured amount","REFUND_EXCEEDS_PAYMENT",409);const refund=await db.Refund.create({id:crypto.randomUUID(),refund_number:`REF-${new Date().getUTCFullYear()}-${pad(await nextSequence("refunds",transaction),6)}`,operation_key:operationKey,order_id:orderId,payment_id:paymentId,status:"REQUESTED",amount:total,currency:order.currency,reason_code:reasonCode,reason_text:reasonText,requested_by:actorUserId,requested_at:new Date()},{transaction});await db.RefundItem.bulkCreate(rows.map(x=>({id:crypto.randomUUID(),refund_id:refund.id,order_item_id:x.item.id,quantity:x.input.quantity,amount:x.amount})),{transaction});await order.update({status:"REFUND_PENDING"},{transaction});return{refund,idempotent:false};});}module.exports={request};
+5
View File
@@ -0,0 +1,5 @@
const crypto=require("crypto"),db=require("../../models"),inventory=require("../inventory/inventory.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util");
const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s}),windowDays=()=>Number(process.env.RETURN_WINDOW_DAYS||30);
async function request({orderId,userId,items,reasonCode,reasonText}){return db.sequelize.transaction(async transaction=>{const order=await db.Order.findOne({where:{id:orderId,user_id:userId,status:["PAID","PROCESSING","READY_FOR_FULFILLMENT","FULFILLED","COMPLETED","PARTIALLY_REFUNDED"]},transaction,lock:transaction.LOCK.UPDATE});if(!order||Date.now()-new Date(order.placed_at).getTime()>windowDays()*86400000)throw fault("Order is not return eligible","RETURN_NOT_ELIGIBLE",409);const rows=[];for(const input of items){const item=await db.OrderItem.findOne({where:{id:input.orderItemId,order_id:order.id},transaction,lock:transaction.LOCK.UPDATE});if(!item||input.quantity<=0||item.returned_quantity+input.quantity>item.quantity)throw fault("Return quantity exceeds remaining quantity","INVALID_RETURN_QUANTITY",409);rows.push({item,input});}const row=await db.ReturnRequest.create({id:crypto.randomUUID(),return_number:`RMA-${new Date().getUTCFullYear()}-${pad(await nextSequence("returns",transaction),6)}`,order_id:order.id,user_id:userId,status:"REQUESTED",reason_code:reasonCode,reason_text:reasonText,requested_at:new Date()},{transaction});await db.ReturnItem.bulkCreate(rows.map(x=>({id:crypto.randomUUID(),return_request_id:row.id,order_item_id:x.item.id,quantity:x.input.quantity,resolution:x.input.resolution,condition:"PENDING"})),{transaction});return row;});}
async function transition({id,status,actorUserId,conditions=[]}){return db.sequelize.transaction(async transaction=>{const row=await db.ReturnRequest.findByPk(id,{transaction,lock:transaction.LOCK.UPDATE});if(!row)throw fault("Return not found","NOT_FOUND",404);const allowed={REQUESTED:["APPROVED","REJECTED","CANCELLED"],APPROVED:["RECEIVED"],AWAITING_RETURN:["RECEIVED"],RECEIVED:["COMPLETED"]};if(!allowed[row.status]?.includes(status))throw fault("Illegal return transition","ILLEGAL_RETURN_TRANSITION",409);await row.update({status,...(status==="APPROVED"&&{approved_at:new Date()}),...(status==="RECEIVED"&&{received_at:new Date()}),...(status==="COMPLETED"&&{completed_at:new Date()})},{transaction});if(status==="COMPLETED"){const items=await db.ReturnItem.findAll({where:{return_request_id:row.id},transaction,lock:transaction.LOCK.UPDATE});for(const item of items){const decision=conditions.find(x=>x.returnItemId===item.id),condition=decision?.condition||item.condition;await item.update({condition},{transaction});const orderItem=await db.OrderItem.findByPk(item.order_item_id,{transaction,lock:transaction.LOCK.UPDATE});await orderItem.increment("returned_quantity",{by:item.quantity,transaction});if(condition==="RESTOCKABLE"){if(!decision?.warehouseId)throw fault("Warehouse is required for restock","WAREHOUSE_REQUIRED");await inventory.adjustStock({eventId:`return:${row.id}:${item.id}`,warehouseId:decision.warehouseId,variantId:orderItem.variant_id,quantityDelta:item.quantity,reason:"Accepted return receipt",actorUserId,requestId:`return:${row.id}`,transaction});}}}return row;});}
module.exports={request,transition,windowDays};
+4 -3
View File
@@ -12,9 +12,9 @@ const availabilityStatus = ({ on_hand, reserved, low_stock_threshold }) => {
return available <= 0 ? "OUT_OF_STOCK" : available <= Number(low_stock_threshold) ? "LOW_STOCK" : "IN_STOCK";
};
async function adjustStock({ eventId, warehouseId, variantId, quantityDelta, reason, actorUserId, requestId }) {
async function adjustStock({ eventId, warehouseId, variantId, quantityDelta, reason, actorUserId, requestId, transaction: externalTransaction }) {
if (!eventId || !Number.isInteger(quantityDelta) || quantityDelta === 0) throw fault("A non-zero integer adjustment and eventId are required", "INVALID_ADJUSTMENT");
return db.sequelize.transaction(async transaction => {
const execute = async transaction => {
const prior = await db.InventoryTransaction.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE });
if (prior) return { idempotent: true, transaction: prior };
let balance = await lockBalance(warehouseId, variantId, transaction);
@@ -24,7 +24,8 @@ async function adjustStock({ eventId, warehouseId, variantId, quantityDelta, rea
await balance.update({ on_hand: next }, { transaction });
const entry = await ledger({ event_id: eventId, warehouse_id: warehouseId, variant_id: variantId, type: "ADJUSTMENT", quantity_delta: quantityDelta, reason, actor_user_id: actorUserId, request_id: requestId }, transaction);
return { balance, transaction: entry, idempotent: false };
});
};
return externalTransaction ? execute(externalTransaction) : db.sequelize.transaction(execute);
}
async function selectWarehouse(variantId, quantity, transaction) {