feat: Implement Phase 6 Shopping and Checkout
CI / test (push) Has been cancelled
CI / test (pull_request) Has been cancelled

- Introduced authenticated shopping state and atomic checkout process without creating orders or payments.
- Reused existing components from previous phases including identities, addresses, catalogues, pricing, and inventory.
- Developed cart architecture to support one active cart per user with unique items per variant.
- Implemented dynamic cart pricing with various precedence rules for promotions and coupons.
- Created a wishlist feature that exposes only visible active products without reserving inventory.
- Integrated inventory validation to ensure availability of items before adding to cart.
- Developed a checkout architecture that locks the cart, revalidates pricing and shipping, and reserves inventory.
- Added shipping zones, methods, and rates with configurable options for international shipping and duty/tax boundaries.
- Implemented checkout snapshots to capture immutable checkout details.
- Introduced idempotency for checkout requests to prevent duplicate processing.
- Added functionality for coupon integration and business checkout validation.
- Established permissions for managing shipping zones, methods, and rates.
- Created comprehensive unit tests covering various aspects of the shopping and checkout processes.
- Added cron job for handling checkout expiry reconciliation.
- Created migration script to set up new database tables and constraints for carts, checkout sessions, and shipping.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 23:45:01 +05:30
parent 5643d89236
commit ad9287b804
33 changed files with 187 additions and 10 deletions
+9
View File
@@ -0,0 +1,9 @@
const crypto=require("crypto"),db=require("../../models"),pricing=require("../pricing/pricing.service"),money=require("../pricing/money"),inventory=require("../inventory/inventory.service");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});
async function context(userId){return db.BusinessCustomer.findOne({where:{user_id:userId}});}
async function activeCart(userId,{transaction,lock}={}){let cart=await db.Cart.findOne({where:{user_id:userId,status:"ACTIVE"},transaction,lock:lock&&transaction.LOCK.UPDATE});if(!cart)cart=await db.Cart.create({id:crypto.randomUUID(),user_id:userId,status:"ACTIVE",currency:"LKR"},{transaction});return cart;}
async function validateVariant(variantId){const variant=await db.ProductVariant.findOne({where:{id:variantId,status:"ACTIVE"},include:[{model:db.Product,as:"product",where:{status:"ACTIVE",visibility:"PUBLIC"}},{model:db.ProductOptionValue,as:"optionValues"}]});if(!variant)throw fault("Product variant is unavailable","PRODUCT_UNAVAILABLE",409);return variant;}
async function availability(variantId,quantity){const balances=await db.InventoryBalance.findAll({where:{variant_id:variantId}}),available=balances.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=balances.reduce((n,x)=>n+Number(x.low_stock_threshold),0);return {status:available<quantity?(available<=0?"OUT_OF_STOCK":"INSUFFICIENT_QUANTITY"):(available<=threshold?"LOW_STOCK":"AVAILABLE"),available:available>=quantity};}
async function project(userId){const cart=await activeCart(userId),items=await db.CartItem.findAll({where:{cart_id:cart.id},order:[["createdAt","ASC"]]}),businessCustomer=await context(userId);let subtotal="0.00",merchandiseTotal="0.00";const projected=[];for(const item of items){let variant,state,quote;try{variant=await validateVariant(item.variant_id);quote=await pricing.quoteVariant({variantId:item.variant_id,businessCustomer,quantity:item.quantity,couponCode:cart.coupon_code});state=await availability(item.variant_id,item.quantity);}catch(e){projected.push({id:item.id,variantId:item.variant_id,quantity:item.quantity,status:e.code||"PRODUCT_UNAVAILABLE"});continue;}const lineSubtotal=money.multiply(quote.baseEligiblePrice,item.quantity),lineTotal=money.multiply(quote.effectiveUnitPrice,item.quantity),lineDiscount=money.subtractFloor(lineSubtotal,lineTotal);subtotal=money.add(subtotal,lineSubtotal);merchandiseTotal=money.add(merchandiseTotal,lineTotal);projected.push({id:item.id,variantId:item.variant_id,quantity:item.quantity,sku:variant.sku,status:state.status,unitPrice:quote.effectiveUnitPrice,lineSubtotal,discount:lineDiscount,lineTotal,pricing:quote});}return {id:cart.id,status:cart.status,version:cart.version,couponCode:cart.coupon_code,items:projected,subtotal,discountTotal:money.subtractFloor(subtotal,merchandiseTotal),merchandiseTotal,currency:cart.currency};}
async function add(userId,variantId,quantity){await validateVariant(variantId);const businessCustomer=await context(userId);await pricing.quoteVariant({variantId,businessCustomer,quantity});const state=await availability(variantId,quantity);if(!state.available)throw fault("Requested quantity is unavailable",state.status,409);return db.sequelize.transaction(async transaction=>{const cart=await activeCart(userId,{transaction,lock:true});let item=await db.CartItem.findOne({where:{cart_id:cart.id,variant_id:variantId},transaction,lock:transaction.LOCK.UPDATE});const next=(item?item.quantity:0)+quantity;if(next>Number(process.env.CART_MAX_ITEM_QUANTITY||100))throw fault("Cart quantity limit exceeded","CART_QUANTITY_LIMIT");if(item)await item.update({quantity:next},{transaction});else item=await db.CartItem.create({id:crypto.randomUUID(),cart_id:cart.id,variant_id:variantId,quantity},{transaction});await cart.increment("version",{transaction});return item;});}
async function mutate(userId,itemId,quantity){return db.sequelize.transaction(async transaction=>{const cart=await activeCart(userId,{transaction,lock:true}),item=await db.CartItem.findOne({where:{id:itemId,cart_id:cart.id},transaction,lock:transaction.LOCK.UPDATE});if(!item)throw fault("Cart item not found","NOT_FOUND",404);if(quantity===0)await item.destroy({transaction});else{await validateVariant(item.variant_id);await item.update({quantity},{transaction});}await cart.increment("version",{transaction});return item;});}
module.exports={activeCart,validateVariant,availability,project,add,mutate,context};
@@ -0,0 +1,6 @@
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),cartService=require("./cart.service"),pricing=require("../pricing/pricing.service"),shipping=require("../shipping/shipping.service"),inventory=require("../inventory/inventory.service"),money=require("../pricing/money");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});const ttl=()=>Number(process.env.CHECKOUT_TTL_MINUTES||15);const fingerprint=b=>crypto.createHash("sha256").update(JSON.stringify(b)).digest("hex");const addressSnapshot=a=>({recipientName:a.recipient_name,phone:a.phone_number,addressLine1:a.address_line_1,addressLine2:a.address_line_2,city:a.city,district:a.district,province:a.province,postalCode:a.postal_code,countryCode:a.country_code});
async function getOwned(id,userId,transaction,lock=false){const row=await db.CheckoutSession.findOne({where:{id,user_id:userId},include:[{model:db.CheckoutItem,as:"items"}],transaction,lock:lock&&transaction.LOCK.UPDATE});if(!row)throw fault("Checkout not found","NOT_FOUND",404);return row;}
async function create({userId,shippingAddressId,billingAddressId,shippingMethodId,couponCode,idempotencyKey,requestId}){if(!idempotencyKey)throw fault("Idempotency-Key is required","IDEMPOTENCY_KEY_REQUIRED");const body={shippingAddressId,billingAddressId,shippingMethodId,couponCode:couponCode||null},hash=fingerprint(body);return db.sequelize.transaction(async transaction=>{const existing=await db.CheckoutSession.findOne({where:{user_id:userId,idempotency_key:idempotencyKey},transaction,lock:transaction.LOCK.UPDATE});if(existing){if(existing.request_fingerprint!==hash)throw fault("Idempotency key payload conflict","IDEMPOTENCY_CONFLICT",409);return {checkout:existing,idempotent:true};}const cart=await db.Cart.findOne({where:{user_id:userId,status:"ACTIVE"},transaction,lock:transaction.LOCK.UPDATE});if(!cart)throw fault("Active cart not found","EMPTY_CART",409);const items=await db.CartItem.findAll({where:{cart_id:cart.id},transaction});if(!items.length)throw fault("Cart is empty","EMPTY_CART",409);const addresses=await db.Address.findAll({where:{id:[shippingAddressId,billingAddressId],user_id:userId},transaction});const ship=addresses.find(x=>x.id===shippingAddressId),bill=addresses.find(x=>x.id===billingAddressId);if(!ship||!bill)throw fault("Owned addresses are required","INVALID_ADDRESS",404);const businessCustomer=await cartService.context(userId),checkoutId=crypto.randomUUID(),expiresAt=new Date(Date.now()+ttl()*60000);let subtotal="0.00",merchandise="0.00";const snapshots=[];for(const item of [...items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id))){const variant=await cartService.validateVariant(item.variant_id),quote=await pricing.quoteVariant({variantId:item.variant_id,businessCustomer,quantity:item.quantity,couponCode:couponCode||cart.coupon_code}),lineSubtotal=money.multiply(quote.baseEligiblePrice,item.quantity),lineTotal=money.multiply(quote.effectiveUnitPrice,item.quantity);subtotal=money.add(subtotal,lineSubtotal);merchandise=money.add(merchandise,lineTotal);snapshots.push({id:crypto.randomUUID(),checkout_session_id:checkoutId,product_id:variant.product_id,variant_id:variant.id,reservation_key:`checkout:${checkoutId}:${variant.id}`,sku:variant.sku,product_name:variant.product.translations?.[0]?.name||variant.product.slug,variant_description:variant.sku,quantity:item.quantity,unit_price:quote.effectiveUnitPrice,discount_amount:money.subtractFloor(lineSubtotal,lineTotal),line_total:lineTotal,currency:variant.currency,metadata:{pricingSource:quote.businessPrice?"BUSINESS":"RETAIL"}});}const methods=await shipping.getAvailableMethods({address:ship,subtotal:merchandise,currency:cart.currency}),method=methods.find(x=>x.methodId===shippingMethodId);if(!method)throw fault("Shipping method is unavailable","INVALID_SHIPPING_METHOD",409);const grand=money.add(merchandise,method.amount,"0.00");const checkout=await db.CheckoutSession.create({id:checkoutId,checkout_number:`CHK-${checkoutId.slice(0,8).toUpperCase()}`,user_id:userId,business_customer_id:businessCustomer?.business_customer_id,cart_id:cart.id,cart_version:cart.version,status:"PENDING",currency:cart.currency,shipping_address_snapshot:addressSnapshot(ship),billing_address_snapshot:addressSnapshot(bill),shipping_method_id:shippingMethodId,shipping_snapshot:method,subtotal,discount_total:money.subtractFloor(subtotal,merchandise),shipping_amount:method.amount,tax_amount:"0.00",duty_amount:null,grand_total:grand,coupon_code:couponCode||cart.coupon_code,idempotency_key:idempotencyKey,request_fingerprint:hash,expires_at:expiresAt},{transaction});for(const item of snapshots)await inventory.reserveStock({reservationKey:item.reservation_key,variantId:item.variant_id,quantity:item.quantity,referenceType:"CHECKOUT",referenceId:checkout.id,userId,expiresAt,requestId,transaction});await db.CheckoutItem.bulkCreate(snapshots,{transaction});await checkout.update({status:"READY"},{transaction});await cart.update({status:"CHECKOUT_LOCKED"},{transaction});return {checkout,idempotent:false};});}
async function close({id,userId,status,requestId}){return db.sequelize.transaction(async transaction=>{const checkout=await getOwned(id,userId,transaction,true);if(checkout.status===status)return checkout;if(!["PENDING","READY"].includes(checkout.status))throw fault("Checkout is not cancellable","CHECKOUT_NOT_CANCELLABLE",409);for(const item of [...checkout.items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id)))await inventory.releaseReservation({reservationKey:item.reservation_key,expired:status==="EXPIRED",requestId,transaction});await checkout.update({status},{transaction});await db.Cart.update({status:"ACTIVE"},{where:{id:checkout.cart_id,status:"CHECKOUT_LOCKED"},transaction});return checkout;});}
async function expire(limit=100){const rows=await db.CheckoutSession.findAll({where:{status:{[Op.in]:["PENDING","READY"]},expires_at:{[Op.lte]:new Date()}},limit,order:[["expires_at","ASC"]]});for(const row of rows){try{await close({id:row.id,userId:row.user_id,status:"EXPIRED"});}catch(e){if(e.code!=="CHECKOUT_NOT_CANCELLABLE")throw e;}}return rows.length;}
module.exports={create,getOwned,close,expire,ttl,fingerprint,addressSnapshot};