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
+42
View File
@@ -0,0 +1,42 @@
# Shopping and Checkout API
All endpoints use `/api/v1` and require authentication unless stated otherwise. Ownership is derived exclusively from the authenticated identity; request bodies never accept `userId`.
## Cart
- `GET /cart`
- `POST /cart/items` with `variantId`, `quantity`
- `PATCH /cart/items/:itemId` with `quantity`; zero removes the item
- `DELETE /cart/items/:itemId`
- `DELETE /cart`
- `PUT /cart/coupon` and `DELETE /cart/coupon`
Only one ACTIVE cart exists per user. Adding items does not reserve inventory. Responses revalidate catalogue state, availability, MOQ, business pricing, promotions, coupons, and integer-scaled totals. Unavailable items remain visible with an explanatory status.
## Wishlist
- `GET /wishlist`
- `POST /wishlist` with `productId`
- `DELETE /wishlist/:productId`
Wishlist entries are owner-scoped, contain no quantity, and never reserve stock.
## Shipping
- `POST /shipping/quote` with an owned `addressId`; subtotal is read from the authoritative cart.
- Admin CRUD: `/admin/shipping/zones`, `/admin/shipping/methods`, `/admin/shipping/rates`.
Zones match country, then optional province/district. Rates support schedules, subtotal bands, currency, and configurable free-shipping thresholds. Unsupported destinations return an explicit error. Duty mode is descriptive; tax is zero until authoritative configuration exists.
## Checkout
- `POST /checkout` requires `Idempotency-Key` and owned shipping/billing address IDs plus a shipping method ID.
- `GET /checkout/active`
- `GET /checkout/:id`
- `POST /checkout/:id/cancel`
The server recalculates all prices, shipping, discounts, and availability. Client price/total fields are rejected. Checkout snapshots commerce-critical item/address/shipping data and atomically reserves every item using sorted lock order. The cart becomes `CHECKOUT_LOCKED`. Cancellation or bounded expiry reconciliation releases reservations and restores the cart. Same user/key/payload returns the existing checkout; a changed payload conflicts.
Business checkout uses the same cart/session and revalidates active approved status, customer/tier pricing, MOQ, and volume tiers. Business credit is not consumed.
No guest cart, Order, Payment, coupon redemption, tax provider, customs calculator, shipment, or delivery workflow exists in Phase 6.
+3
View File
@@ -414,3 +414,6 @@ Remaining identity work is operational: validate/deduplicate deployed RBAC data
## Phase 5 Completion Update
Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotions/coupons, and localized scheduled banners are implemented behind new permissions and a forward-only migration. Phase 5 adds 13 models, secured administration routes, public availability/banner projections, audit calls, a bounded expiry reconciliation cron, and Phase 6 pricing/reservation service boundaries. Module 05 is 85%, Module 07 is 78%, and Module 13 is 75%. Inventory is 90%, reservations 90%, business pricing 82%, promotions/coupons 78%, and banners 82%. Automated verification: 18 suites and 89 tests passed; syntax passed for 232 JavaScript files. Migration was not executed. Staging must precheck legacy stock/pricing/promotion/banner data, apply the migration, seed permissions/default warehouse, and test genuine MySQL locking plus cron behavior before Phase 6 production use.
## Phase 6 Completion Update
Date: 2026-09-03. Module 06 is 91%, Module 08 is 84%, and Module 07 is revised to 86%. Authenticated cart is 92%, wishlist 92%, shipping 86%, checkout 88%, and reservation integration 90%. Nine models, a forward-only migration, owner-scoped shopping APIs, permission-protected shipping administration, server-authoritative totals, atomic inventory reservation composition, idempotent checkout creation, and bounded expiry/cancellation release are implemented. Verification passes 20 suites/95 tests and 258 JavaScript syntax checks. The migration was not executed. Before Phase 7, staging must precheck legacy shopping/shipping tables and address/currency compatibility, seed shipping configuration/permissions, apply migrations, and validate real multi-connection InnoDB concurrency plus multi-instance expiry behavior.
@@ -0,0 +1,76 @@
# ZUMRI Phase 6 Shopping and Checkout
## Objective
Add authenticated shopping state and an atomic, priced, inventory-reserved checkout handoff without creating orders or payments.
## Existing Components Reused
Phase 3 identities/addresses/business accounts, Phase 4 catalogue, Phase 5 pricing/inventory/promotions, Phase 1 permissions, Phase 2 audit, Sequelize, Zod, and cron bootstrap.
## Cart Architecture
One ACTIVE cart per identity; items are unique per variant and owner-scoped. Guest carts were intentionally omitted. Version increments detect state changes and checkout locks mutation.
## Cart Pricing
Cart items are dynamically requoted with retail/business/volume/promotion/coupon precedence. DECIMAL strings use BigInt-scaled arithmetic.
## Wishlist
Unique owner/product entries expose only visible active products and never reserve inventory.
## Inventory Validation
Adding and projecting items checks authoritative aggregate availability. Unavailable items remain explainable.
## Reservation Integration
Cart does not reserve. Checkout extends the Phase 5 service with external transaction composition and reserves sorted variants all-or-nothing.
## Shipping Zones
Normalized country/province/district records provide deterministic destination matching, including international zones.
## Shipping Methods
Configurable methods include delivery estimates and tracking capability.
## Shipping Rates
Scheduled DECIMAL rates support currency, subtotal bands, and configurable free-shipping thresholds.
## International Shipping
Only configured zones are eligible; unsupported destinations fail explicitly.
## Duty/Tax Boundary
Zones declare NONE, ESTIMATED, PAYABLE_ON_DELIVERY, INCLUDED, or UNKNOWN. No customs amount is fabricated. Tax remains zero without authoritative configuration.
## Checkout Architecture
A user-scoped transaction locks the cart, revalidates catalogue/pricing/shipping, reserves inventory, writes snapshots, and locks the cart.
## Checkout Snapshots
Immutable checkout items capture product/variant/SKU, price, discount, total, currency, and pricing source. Owned address and shipping selections are copied as JSON.
## Checkout Totals
Merchandise subtotal minus discounts plus shipping plus configured tax/duty equals grand total. Client totals are prohibited.
## Idempotency
Unique `(userId, idempotencyKey)` plus SHA-256 request fingerprint returns the same session or rejects changed payloads.
## Checkout Expiry
A no-overlap minute reconciliation processes 100 sessions, locks each session, releases reservations idempotently, marks EXPIRED, and restores its cart.
## Coupon Integration
Cart coupons are provisional and revalidated at checkout. Authoritative redemption remains Phase 7.
## Business Checkout
The shared flow revalidates approved ACTIVE business context, MOQ, customer/tier price, and volume tier. Credit is untouched.
## Permissions
Shipping zone/method/rate read/manage permissions protect all administration.
## Audit
Cart, wishlist, checkout, expiry/cancellation, and shipping configuration use stable Phase 2 activity types without full addresses or carts.
## Database Changes
Forward-only migration `20260903060000` creates nine Phase 6 tables and required uniqueness/status-expiry indexes.
## Tests
Phase 6 unit tests cover decimal totals, strict client-total rejection, quantity validation, deterministic fingerprints, safe address snapshots, and shipping permission denial. Existing suites remain green.
## Remaining Known Issues
Real InnoDB concurrent checkout, migration, and multi-instance cron behavior require staging. Shipping weight bands, authoritative tax/duty, and coupon redemption are intentionally deferred.
## Phase 7 Prerequisites
Run legacy table/address/currency/reservation prechecks, migrate staging, seed zones/methods/rates and permissions, and prove concurrent final-stock and duplicate-idempotency behavior with real MySQL connections.
+2
View File
@@ -45,6 +45,8 @@ const envSchema = z.object({
DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2),
NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15),
CHECKOUT_TTL_MINUTES: z.coerce.number().int().positive().default(15),
CART_MAX_ITEM_QUANTITY: z.coerce.number().int().positive().default(100),
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
+1
View File
@@ -24,4 +24,5 @@ module.exports = {
CATALOGUE_CATEGORIES_MANAGE:"catalogue.categories.manage",CATALOGUE_BRANDS_MANAGE:"catalogue.brands.manage",CATALOGUE_COLLECTIONS_MANAGE:"catalogue.collections.manage",CATALOGUE_SIZE_GUIDES_MANAGE:"catalogue.size-guides.manage",CATALOGUE_REVIEWS_READ:"catalogue.reviews.read",CATALOGUE_REVIEWS_MODERATE:"catalogue.reviews.moderate",
INVENTORY_READ:"inventory.read",INVENTORY_ADJUST:"inventory.adjust",INVENTORY_TRANSFER:"inventory.transfer",INVENTORY_RESERVATIONS_READ:"inventory.reservations.read",INVENTORY_WAREHOUSES_MANAGE:"inventory.warehouses.manage",
BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage",
SHIPPING_ZONES_READ:"shipping.zones.read",SHIPPING_ZONES_MANAGE:"shipping.zones.manage",SHIPPING_METHODS_READ:"shipping.methods.read",SHIPPING_METHODS_MANAGE:"shipping.methods.manage",SHIPPING_RATES_READ:"shipping.rates.read",SHIPPING_RATES_MANAGE:"shipping.rates.manage",
};
@@ -0,0 +1,4 @@
const crypto=require("crypto"),db=require("../../models"),service=require("../../services/shipping/shipping.service"),cart=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shipping",targetId:id,requestId:req.id});exports.quote=async(req,res,next)=>{try{const address=await db.Address.findOne({where:{id:req.body.addressId,user_id:req.user.id}});if(!address)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});const projection=await cart.project(req.user.id);res.json({success:true,data:await service.getAvailableMethods({address,subtotal:projection.merchandiseTotal,currency:projection.currency})});}catch(e){next(e);}};const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll()});}catch(e){next(e);}};exports.zones=list(db.ShippingZone);exports.methods=list(db.ShippingMethod);exports.rates=list(db.ShippingRate);exports.createZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.create({id:crypto.randomUUID(),shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.updateZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Zone not found"),{status:404,code:"NOT_FOUND"});await row.update({code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.destroy({where:{shipping_zone_id:row.id},transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
exports.updateMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Method not found"),{status:404,code:"NOT_FOUND"});await row.update({code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
exports.updateRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rate not found"),{status:404,code:"NOT_FOUND"});await row.update({shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),service=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shopping",description:type,targetType:"CART",targetId:id,requestId:req.id});exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const x=await service.add(req.user.id,req.body.variantId,req.body.quantity);await audit(req,"CART_ITEM_ADDED",x.id);res.status(201).json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.update=async(req,res,next)=>{try{const x=await service.mutate(req.user.id,req.params.itemId,req.body.quantity);await audit(req,"CART_ITEM_UPDATED",x.id);res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{await service.mutate(req.user.id,req.params.itemId,0);await audit(req,"CART_ITEM_REMOVED",req.params.itemId);res.status(204).end();}catch(e){next(e);}};exports.clear=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await db.CartItem.destroy({where:{cart_id:cart.id}});await cart.increment("version");await audit(req,"CART_CLEARED",cart.id);res.status(204).end();}catch(e){next(e);}};exports.coupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:req.body.code});res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.removeCoupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:null});res.status(204).end();}catch(e){next(e);}};
@@ -0,0 +1 @@
const service=require("../../services/shopping/checkout.service"),{logActivity}=require("../../services/activity.service");exports.create=async(req,res,next)=>{try{const result=await service.create({userId:req.user.id,...req.body,idempotencyKey:req.get("Idempotency-Key"),requestId:req.id});if(!result.idempotent)await logActivity({user:req.user,type:"CHECKOUT_CREATED",module:"Checkout",targetId:result.checkout.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.checkout});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.getOwned(req.params.id,req.user.id)});}catch(e){next(e);}};exports.active=async(req,res,next)=>{try{const db=require("../../models"),row=await db.CheckoutSession.findOne({where:{user_id:req.user.id,status:["PENDING","READY"]},include:[{model:db.CheckoutItem,as:"items"}],order:[["createdAt","DESC"]]});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await service.close({id:req.params.id,userId:req.user.id,status:"CANCELLED",requestId:req.id});await logActivity({user:req.user,type:"CHECKOUT_CANCELLED",module:"Checkout",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.WishlistItem.findAll({where:{user_id:req.user.id},include:[{model:db.Product,as:"product",where:{status:"ACTIVE",visibility:"PUBLIC"}}]})});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const product=await db.Product.findOne({where:{id:req.body.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});const[row,created]=await db.WishlistItem.findOrCreate({where:{user_id:req.user.id,product_id:product.id},defaults:{id:crypto.randomUUID()}});if(created)await logActivity({user:req.user,type:"WISHLIST_ITEM_ADDED",module:"Shopping",targetId:row.id});res.status(created?201:200).json({success:true,data:row});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{const n=await db.WishlistItem.destroy({where:{user_id:req.user.id,product_id:req.params.productId}});if(!n)throw Object.assign(new Error("Wishlist item not found"),{status:404,code:"NOT_FOUND"});await logActivity({user:req.user,type:"WISHLIST_ITEM_REMOVED",module:"Shopping",targetId:req.params.productId});res.status(204).end();}catch(e){next(e);}};
+2
View File
@@ -93,6 +93,8 @@ db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTyp
db.Warehouse=require("./inventory/warehouse.model")(sequelize,DataTypes);db.InventoryBalance=require("./inventory/inventoryBalance.model")(sequelize,DataTypes);db.InventoryTransaction=require("./inventory/inventoryTransaction.model")(sequelize,DataTypes);db.InventoryReservation=require("./inventory/inventoryReservation.model")(sequelize,DataTypes);db.InventoryTransfer=require("./inventory/inventoryTransfer.model")(sequelize,DataTypes);
db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.VariantBusinessPrice=require("./pricing/variantBusinessPrice.model")(sequelize,DataTypes);db.BusinessPriceTier=require("./pricing/businessPriceTier.model")(sequelize,DataTypes);
db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.PromotionTarget=require("./merchandising/promotionTarget.model")(sequelize,DataTypes);db.Coupon=require("./merchandising/coupon.model")(sequelize,DataTypes);db.Banner=require("./merchandising/banner.model")(sequelize,DataTypes);db.BannerTranslation=require("./merchandising/bannerTranslation.model")(sequelize,DataTypes);
db.Cart=require("./shopping/cart.model")(sequelize,DataTypes);db.CartItem=require("./shopping/cartItem.model")(sequelize,DataTypes);db.WishlistItem=require("./shopping/wishlistItem.model")(sequelize,DataTypes);db.CheckoutSession=require("./shopping/checkoutSession.model")(sequelize,DataTypes);db.CheckoutItem=require("./shopping/checkoutItem.model")(sequelize,DataTypes);
db.ShippingZone=require("./shipping/shippingZone.model")(sequelize,DataTypes);db.ShippingZoneRegion=require("./shipping/shippingZoneRegion.model")(sequelize,DataTypes);db.ShippingMethod=require("./shipping/shippingMethod.model")(sequelize,DataTypes);db.ShippingRate=require("./shipping/shippingRate.model")(sequelize,DataTypes);
/* Associations */
Object.keys(db).forEach(model => {
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ShippingMethod",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},description:D.TEXT,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},estimated_min_days:{type:D.INTEGER,allowNull:false},estimated_max_days:{type:D.INTEGER,allowNull:false},tracking_supported:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"shipping_methods",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ShippingRate",{id:{type:D.STRING,primaryKey:true},shipping_zone_id:{type:D.STRING,allowNull:false},shipping_method_id:{type:D.STRING,allowNull:false},currency:{type:D.STRING(3),allowNull:false},base_amount:{type:D.DECIMAL(15,2),allowNull:false},free_shipping_threshold:D.DECIMAL(15,2),minimum_subtotal:D.DECIMAL(15,2),maximum_subtotal:D.DECIMAL(15,2),status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,ends_at:D.DATE},{tableName:"shipping_rates",timestamps:true});M.associate=db=>M.belongsTo(db.ShippingMethod,{foreignKey:"shipping_method_id",as:"method"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ShippingZone",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},duty_mode:{type:D.ENUM("NONE","ESTIMATED","PAYABLE_ON_DELIVERY","INCLUDED","UNKNOWN"),allowNull:false,defaultValue:"UNKNOWN"}},{tableName:"shipping_zones",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ShippingZoneRegion",{id:{type:D.STRING,primaryKey:true},shipping_zone_id:{type:D.STRING,allowNull:false},country_code:{type:D.STRING(2),allowNull:false},province:D.STRING(100),district:D.STRING(100)},{tableName:"shipping_zone_regions",timestamps:true});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Cart",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","CHECKOUT_LOCKED","CONVERTED","ABANDONED"),allowNull:false,defaultValue:"ACTIVE"},currency:{type:D.STRING(3),allowNull:false,defaultValue:"LKR"},coupon_code:D.STRING(50),version:{type:D.INTEGER,allowNull:false,defaultValue:0},expires_at:D.DATE},{tableName:"carts",timestamps:true,indexes:[{unique:true,fields:["user_id","status"]}]});M.associate=db=>{M.hasMany(db.CartItem,{foreignKey:"cart_id",as:"items"});M.belongsTo(db.User,{foreignKey:"user_id",as:"owner"});};return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("CartItem",{id:{type:D.STRING,primaryKey:true},cart_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false}},{tableName:"cart_items",timestamps:true,indexes:[{unique:true,fields:["cart_id","variant_id"]}]});M.associate=db=>M.belongsTo(db.ProductVariant,{foreignKey:"variant_id",as:"variant"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("CheckoutItem",{id:{type:D.STRING,primaryKey:true},checkout_session_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},reservation_key:{type:D.STRING(160),allowNull:false},sku:{type:D.STRING(100),allowNull:false},product_name:{type:D.STRING(255),allowNull:false},variant_description:D.STRING(255),quantity:{type:D.INTEGER,allowNull:false},unit_price:{type:D.DECIMAL(15,2),allowNull:false},discount_amount:{type:D.DECIMAL(15,2),allowNull:false},line_total:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},metadata:D.JSON},{tableName:"checkout_items",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("CheckoutSession",{id:{type:D.STRING,primaryKey:true},checkout_number:{type:D.STRING(80),allowNull:false,unique:true},user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,cart_id:{type:D.STRING,allowNull:false},cart_version:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("PENDING","READY","EXPIRED","CANCELLED","CONVERTED"),allowNull:false,defaultValue:"PENDING"},currency:{type:D.STRING(3),allowNull:false},shipping_address_snapshot:{type:D.JSON,allowNull:false},billing_address_snapshot:{type:D.JSON,allowNull:false},shipping_method_id:{type:D.STRING,allowNull:false},shipping_snapshot:{type:D.JSON,allowNull:false},subtotal:{type:D.DECIMAL(15,2),allowNull:false},discount_total:{type:D.DECIMAL(15,2),allowNull:false},shipping_amount:{type:D.DECIMAL(15,2),allowNull:false},tax_amount:{type:D.DECIMAL(15,2),allowNull:false},duty_amount:D.DECIMAL(15,2),grand_total:{type:D.DECIMAL(15,2),allowNull:false},coupon_code:D.STRING(50),idempotency_key:{type:D.STRING(160),allowNull:false},request_fingerprint:{type:D.STRING(64),allowNull:false},expires_at:{type:D.DATE,allowNull:false}},{tableName:"checkout_sessions",timestamps:true,indexes:[{unique:true,fields:["user_id","idempotency_key"]}]});M.associate=db=>M.hasMany(db.CheckoutItem,{foreignKey:"checkout_session_id",as:"items"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("WishlistItem",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false}},{tableName:"wishlist_items",timestamps:true,indexes:[{unique:true,fields:["user_id","product_id"]}]});M.associate=db=>M.belongsTo(db.Product,{foreignKey:"product_id",as:"product"});return M;};
+4
View File
@@ -32,6 +32,8 @@ const inventoryAdminRoutes = require("./inventory/admin.routes");
const merchandisingAdminRoutes = require("./merchandising/admin.routes");
const pricingAdminRoutes = require("./pricing/admin.routes");
const merchandisingPublicRoutes = require("./merchandising/public.routes");
const shoppingRoutes = require("./shopping.routes");
const shippingAdminRoutes = require("./shipping/admin.routes");
const router = express.Router();
@@ -56,5 +58,7 @@ router.use("/admin", inventoryAdminRoutes);
router.use("/admin", merchandisingAdminRoutes);
router.use("/admin", pricingAdminRoutes);
router.use("/", merchandisingPublicRoutes);
router.use("/", shoppingRoutes);
router.use("/admin", shippingAdminRoutes);
module.exports = router;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../../controllers/shipping/shipping.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/shopping.schemas");r.use(authenticate);r.get("/shipping/zones",checkPermission("shipping.zones.read",{custom:true}),c.zones);r.post("/shipping/zones",checkPermission("shipping.zones.manage",{custom:true}),validate(s.zone),c.createZone);r.patch("/shipping/zones/:id",checkPermission("shipping.zones.manage",{custom:true}),validate(s.zone),c.updateZone);r.get("/shipping/methods",checkPermission("shipping.methods.read",{custom:true}),c.methods);r.post("/shipping/methods",checkPermission("shipping.methods.manage",{custom:true}),validate(s.method),c.createMethod);r.patch("/shipping/methods/:id",checkPermission("shipping.methods.manage",{custom:true}),validate(s.method),c.updateMethod);r.get("/shipping/rates",checkPermission("shipping.rates.read",{custom:true}),c.rates);r.post("/shipping/rates",checkPermission("shipping.rates.manage",{custom:true}),validate(s.rate),c.createRate);r.patch("/shipping/rates/:id",checkPermission("shipping.rates.manage",{custom:true}),validate(s.rate),c.updateRate);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),cart=require("../controllers/shopping/cart.controller"),wish=require("../controllers/shopping/wishlist.controller"),checkout=require("../controllers/shopping/checkout.controller"),shipping=require("../controllers/shipping/shipping.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/shopping.schemas");r.use(authenticate);r.get("/cart",cart.get);r.post("/cart/items",validate(s.cartItem),cart.add);r.patch("/cart/items/:itemId",validate(s.cartQuantity),cart.update);r.delete("/cart/items/:itemId",cart.remove);r.delete("/cart",cart.clear);r.put("/cart/coupon",validate(s.coupon),cart.coupon);r.delete("/cart/coupon",cart.removeCoupon);r.get("/wishlist",wish.list);r.post("/wishlist",validate(s.wishlist),wish.add);r.delete("/wishlist/:productId",wish.remove);r.post("/shipping/quote",validate(s.shippingQuote),shipping.quote);r.post("/checkout",validate(s.checkout),checkout.create);r.get("/checkout/active",checkout.active);r.get("/checkout/:id",checkout.get);r.post("/checkout/:id/cancel",checkout.cancel);module.exports=r;
+10 -8
View File
@@ -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 -1
View File
@@ -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};
+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};
+1
View File
@@ -0,0 +1 @@
const{z}=require("zod"),wrap=body=>z.object({body:body.strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()}),id=z.string().min(1).max(160),money=z.string().regex(/^\d+(\.\d{1,2})?$/),date=z.coerce.date();exports.cartItem=wrap(z.object({variantId:id,quantity:z.number().int().positive().max(Number(process.env.CART_MAX_ITEM_QUANTITY||100))}));exports.cartQuantity=wrap(z.object({quantity:z.number().int().min(0).max(Number(process.env.CART_MAX_ITEM_QUANTITY||100))}));exports.coupon=wrap(z.object({code:z.string().trim().min(1).max(50).regex(/^[A-Za-z0-9_-]+$/).transform(x=>x.toUpperCase())}));exports.wishlist=wrap(z.object({productId:id}));exports.checkout=wrap(z.object({shippingAddressId:id,billingAddressId:id,shippingMethodId:id,couponCode:z.string().trim().max(50).optional()}));exports.shippingQuote=wrap(z.object({addressId:id}));exports.zone=wrap(z.object({code:z.string().min(1).max(60),name:z.string().min(1).max(150),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),dutyMode:z.enum(["NONE","ESTIMATED","PAYABLE_ON_DELIVERY","INCLUDED","UNKNOWN"]).default("UNKNOWN"),regions:z.array(z.object({countryCode:z.string().length(2),province:z.string().max(100).optional(),district:z.string().max(100).optional()}).strict()).min(1)}));exports.method=wrap(z.object({code:z.string().min(1).max(60),name:z.string().min(1).max(150),description:z.string().optional(),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),estimatedMinDays:z.number().int().nonnegative(),estimatedMaxDays:z.number().int().nonnegative(),trackingSupported:z.boolean().default(false)}));exports.rate=wrap(z.object({shippingZoneId:id,shippingMethodId:id,currency:z.string().length(3),baseAmount:money,freeShippingThreshold:money.optional(),minimumSubtotal:money.optional(),maximumSubtotal:money.optional(),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),startsAt:date.optional(),endsAt:date.optional()}));
+1
View File
@@ -0,0 +1 @@
const cron=require("node-cron"),checkout=require("../app/services/shopping/checkout.service");module.exports=()=>cron.schedule("* * * * *",async()=>{try{await checkout.expire(100);}catch(error){console.error("Checkout expiry reconciliation failed",{name:error.name,message:error.message});}},{noOverlap:true});
+2 -1
View File
@@ -11,11 +11,12 @@
const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron");
const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron");
const startCheckoutExpiryCron = require("./checkoutExpiry.cron");
function startAllCrons() {
console.log("Starting Cron Jobs...");
const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron()];
const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron()];
return async () => {
for (const task of tasks) {
@@ -0,0 +1,3 @@
"use strict";module.exports={async up(q,S){const ID={type:S.STRING,primaryKey:true,allowNull:false},STR={type:S.STRING},REQ={type:S.STRING,allowNull:false},DATE={type:S.DATE},DEC={type:S.DECIMAL(15,2)},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("carts",{id:ID,user_id:REQ,status:REQ,currency:{type:S.STRING(3),allowNull:false},coupon_code:STR,version:{type:S.INTEGER,allowNull:false,defaultValue:0},expires_at:DATE,...TS});await q.addConstraint("carts",{fields:["user_id","status"],type:"unique",name:"uq_cart_user_status"});await q.createTable("cart_items",{id:ID,cart_id:REQ,variant_id:REQ,quantity:{type:S.INTEGER,allowNull:false},...TS});await q.addConstraint("cart_items",{fields:["cart_id","variant_id"],type:"unique",name:"uq_cart_variant"});await q.createTable("wishlist_items",{id:ID,user_id:REQ,product_id:REQ,...TS});await q.addConstraint("wishlist_items",{fields:["user_id","product_id"],type:"unique",name:"uq_wishlist_product"});
await q.createTable("shipping_zones",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:REQ,status:REQ,duty_mode:REQ,...TS});await q.createTable("shipping_zone_regions",{id:ID,shipping_zone_id:REQ,country_code:{type:S.STRING(2),allowNull:false},province:STR,district:STR,...TS});await q.addIndex("shipping_zone_regions",["country_code","province","district"]);await q.createTable("shipping_methods",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:REQ,description:{type:S.TEXT},status:REQ,estimated_min_days:{type:S.INTEGER,allowNull:false},estimated_max_days:{type:S.INTEGER,allowNull:false},tracking_supported:{type:S.BOOLEAN,allowNull:false},...TS});await q.createTable("shipping_rates",{id:ID,shipping_zone_id:REQ,shipping_method_id:REQ,currency:{type:S.STRING(3),allowNull:false},base_amount:{...DEC,allowNull:false},free_shipping_threshold:DEC,minimum_subtotal:DEC,maximum_subtotal:DEC,status:REQ,starts_at:DATE,ends_at:DATE,...TS});await q.addIndex("shipping_rates",["shipping_zone_id","shipping_method_id","status"]);
await q.createTable("checkout_sessions",{id:ID,checkout_number:{type:S.STRING(80),allowNull:false,unique:true},user_id:REQ,business_customer_id:STR,cart_id:REQ,cart_version:{type:S.INTEGER,allowNull:false},status:REQ,currency:{type:S.STRING(3),allowNull:false},shipping_address_snapshot:{type:S.JSON,allowNull:false},billing_address_snapshot:{type:S.JSON,allowNull:false},shipping_method_id:REQ,shipping_snapshot:{type:S.JSON,allowNull:false},subtotal:{...DEC,allowNull:false},discount_total:{...DEC,allowNull:false},shipping_amount:{...DEC,allowNull:false},tax_amount:{...DEC,allowNull:false},duty_amount:DEC,grand_total:{...DEC,allowNull:false},coupon_code:STR,idempotency_key:{type:S.STRING(160),allowNull:false},request_fingerprint:{type:S.STRING(64),allowNull:false},expires_at:{type:S.DATE,allowNull:false},...TS});await q.addConstraint("checkout_sessions",{fields:["user_id","idempotency_key"],type:"unique",name:"uq_checkout_user_idempotency"});await q.addIndex("checkout_sessions",["status","expires_at"]);await q.createTable("checkout_items",{id:ID,checkout_session_id:REQ,product_id:REQ,variant_id:REQ,reservation_key:{type:S.STRING(160),allowNull:false},sku:{type:S.STRING(100),allowNull:false},product_name:REQ,variant_description:STR,quantity:{type:S.INTEGER,allowNull:false},unit_price:{...DEC,allowNull:false},discount_amount:{...DEC,allowNull:false},line_total:{...DEC,allowNull:false},currency:{type:S.STRING(3),allowNull:false},metadata:{type:S.JSON},...TS});},async down(){throw new Error("Phase 6 migration is forward-only; restore from a verified backup instead");}};
@@ -0,0 +1 @@
const money=require("../../app/services/pricing/money"),s=require("../../app/validation/shopping.schemas"),checkout=require("../../app/services/shopping/checkout.service");const input=body=>({body,params:{},query:{}});describe("Phase 6 commerce boundaries",()=>{test("calculates line totals without floating point",()=>{expect(money.multiply("999.99",3)).toBe("2999.97");expect(money.add("2999.97","250.03")).toBe("3250.00");});test("rejects client-controlled checkout totals",()=>expect(()=>s.checkout.parse(input({shippingAddressId:"a",billingAddressId:"b",shippingMethodId:"m",grandTotal:"1.00"}))).toThrow());test("requires positive integer cart quantities",()=>expect(()=>s.cartItem.parse(input({variantId:"v",quantity:1.5}))).toThrow());test("idempotency fingerprints identical payloads deterministically",()=>expect(checkout.fingerprint({a:1})).toBe(checkout.fingerprint({a:1})));test("address snapshots omit ownership and internal fields",()=>expect(checkout.addressSnapshot({recipient_name:"A",phone_number:"1",address_line_1:"X",country_code:"LK",user_id:"secret"})).toEqual({recipientName:"A",phone:"1",addressLine1:"X",addressLine2:undefined,city:undefined,district:undefined,province:undefined,postalCode:undefined,countryCode:"LK"}));});
+1
View File
@@ -0,0 +1 @@
const{checkPermission}=require("../../app/middleware/permission.middleware");describe("shipping administration security",()=>{test("customer cannot manage shipping",()=>{const req={user:{accountType:"CUSTOMER",permissions:[]},method:"POST"},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();checkPermission("shipping.rates.manage",{custom:true})(req,res,next);expect(res.status).toHaveBeenCalledWith(403);expect(next).not.toHaveBeenCalled();});});