feat: Implement Phase 6 Shopping and Checkout
- 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:
@@ -36,9 +36,9 @@ async function selectWarehouse(variantId, quantity, transaction) {
|
||||
throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409);
|
||||
}
|
||||
|
||||
async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId }) {
|
||||
async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId, transaction: externalTransaction }) {
|
||||
assertQuantity(quantity); if (!reservationKey) throw fault("reservationKey is required", "INVALID_RESERVATION");
|
||||
return db.sequelize.transaction(async transaction => {
|
||||
const execute = async transaction => {
|
||||
const existing = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE });
|
||||
if (existing) return { reservation: existing, idempotent: true };
|
||||
const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" }, transaction });
|
||||
@@ -55,11 +55,12 @@ async function reserveStock({ reservationKey, warehouseId, variantId, quantity,
|
||||
const reservation = await db.InventoryReservation.create({ id: crypto.randomUUID(), reservation_key: reservationKey, warehouse_id: selected.warehouse.id, variant_id: variantId, quantity, reference_type: referenceType, reference_id: referenceId, user_id: userId, expires_at: expiresAt || new Date(Date.now() + ttlMinutes() * 60000) }, { transaction });
|
||||
await ledger({ event_id: `reserve:${reservationKey}`, warehouse_id: selected.warehouse.id, variant_id: variantId, type: "RESERVATION", reserved_delta: quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction);
|
||||
return { reservation, idempotent: false };
|
||||
});
|
||||
};
|
||||
return externalTransaction ? execute(externalTransaction) : db.sequelize.transaction(execute);
|
||||
}
|
||||
|
||||
async function finishReservation(reservationKey, status, requestId) {
|
||||
return db.sequelize.transaction(async transaction => {
|
||||
async function finishReservation(reservationKey, status, requestId, externalTransaction) {
|
||||
const execute = async transaction => {
|
||||
const reservation = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE });
|
||||
if (!reservation) throw fault("Reservation not found", "RESERVATION_NOT_FOUND", 404);
|
||||
if (reservation.status === status) return { reservation, idempotent: true };
|
||||
@@ -72,11 +73,12 @@ async function finishReservation(reservationKey, status, requestId) {
|
||||
await reservation.update({ status, released_at: consume ? null : new Date(), consumed_at: consume ? new Date() : null }, { transaction });
|
||||
await ledger({ event_id: `${status.toLowerCase()}:${reservationKey}`, warehouse_id: reservation.warehouse_id, variant_id: reservation.variant_id, type: consume ? "RESERVATION_CONSUME" : "RESERVATION_RELEASE", quantity_delta: consume ? -reservation.quantity : 0, reserved_delta: -reservation.quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction);
|
||||
return { reservation, idempotent: false };
|
||||
});
|
||||
};
|
||||
return externalTransaction ? execute(externalTransaction) : db.sequelize.transaction(execute);
|
||||
}
|
||||
|
||||
const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId);
|
||||
const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId);
|
||||
const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId, args.transaction);
|
||||
const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId, args.transaction);
|
||||
|
||||
async function transferStock({ eventId, sourceWarehouseId, destinationWarehouseId, variantId, quantity, actorUserId, requestId }) {
|
||||
assertQuantity(quantity); if (sourceWarehouseId === destinationWarehouseId) throw fault("Warehouses must differ", "INVALID_TRANSFER");
|
||||
|
||||
@@ -2,4 +2,5 @@ const parse = value => { const match = String(value).match(/^(\d+)(?:\.(\d{1,2})
|
||||
const format = cents => `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`;
|
||||
const subtractFloor = (a, b) => format(parse(a) > parse(b) ? parse(a) - parse(b) : 0n);
|
||||
const percentOff = (amount, percent) => { const [whole,fraction=""] = String(percent).split("."); const basis = BigInt(whole) * 100n + BigInt(fraction.padEnd(2,"0")); return format(parse(amount) * basis / 10000n); };
|
||||
module.exports = { parse, format, subtractFloor, percentOff };
|
||||
const add=(...values)=>format(values.reduce((sum,value)=>sum+parse(value),0n));const multiply=(amount,quantity)=>format(parse(amount)*BigInt(quantity));
|
||||
module.exports = { parse, format, subtractFloor, percentOff, add, multiply };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const {Op}=require("sequelize"),db=require("../../models"),money=require("../pricing/money");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});
|
||||
async function getAvailableMethods({address,subtotal,currency="LKR",at=new Date()}){const regions=await db.ShippingZoneRegion.findAll({where:{country_code:String(address.country_code).toUpperCase(),[Op.and]:[{[Op.or]:[{province:null},{province:address.province}]},{[Op.or]:[{district:null},{district:address.district}]}]},order:[["province","DESC"],["district","DESC"]]});if(!regions.length)throw fault("Destination is not supported","UNSUPPORTED_DESTINATION",409);const zone=await db.ShippingZone.findOne({where:{id:regions[0].shipping_zone_id,status:"ACTIVE"}});if(!zone)throw fault("Destination is not supported","UNSUPPORTED_DESTINATION",409);const rates=await db.ShippingRate.findAll({where:{shipping_zone_id:zone.id,currency,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:at}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:at}}]}]},include:[{model:db.ShippingMethod,as:"method",where:{status:"ACTIVE"}}]});return rates.filter(r=>(!r.minimum_subtotal||money.parse(subtotal)>=money.parse(r.minimum_subtotal))&&(!r.maximum_subtotal||money.parse(subtotal)<=money.parse(r.maximum_subtotal))).map(r=>{const free=Boolean(r.free_shipping_threshold&&money.parse(subtotal)>=money.parse(r.free_shipping_threshold));return {shippingRateId:r.id,methodId:r.method.id,code:r.method.code,name:r.method.name,amount:free?"0.00":String(r.base_amount),freeShippingApplied:free,estimatedMinDays:r.method.estimated_min_days,estimatedMaxDays:r.method.estimated_max_days,zone:{code:zone.code,name:zone.name},dutyMode:zone.duty_mode};});}
|
||||
module.exports={getAvailableMethods};
|
||||
@@ -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};
|
||||
Reference in New Issue
Block a user