Development #2

Merged
Sathira merged 4 commits from development into main 2026-09-12 06:48:46 +00:00
38 changed files with 192 additions and 4 deletions
Showing only changes of commit cd2c1c6d08 - Show all commits
+42
View File
@@ -0,0 +1,42 @@
# Orders and Payments API
All `/api/v1` customer endpoints derive ownership from authentication.
## Orders
- `POST /orders/from-checkout` converts an owned READY checkout. Conversion locks the checkout, verifies ACTIVE reservations, copies immutable snapshots, converts the cart, and is idempotent by unique checkout ID.
- `GET /orders` and `GET /orders/:id` provide owner-scoped history/detail.
- `POST /orders/:id/cancel` releases unpaid reservations. Paid orders require an explicit refund.
- Admin: `GET /admin/orders`, `GET /admin/orders/:id`, cancel, and mark-processing action endpoints.
Legal transitions are centralized. Payment, order, and fulfillment status are separate.
## Payments and webhooks
- `POST /orders/:id/payment`, `GET /orders/:id/payment`
- `POST /payments/webhooks/payhere` is unauthenticated at the session layer but requires the PayHere merchant hash.
Online browser redirects are never authoritative. A verified webhook must match local payment reference, currency, and DECIMAL amount. Unique provider event IDs make retries idempotent. A first successful event consumes each reservation once, records coupon redemption, marks payment/order paid, and issues invoice metadata. Failure does not consume inventory.
PayHere configuration uses `PAYHERE_MERCHANT_ID`, `PAYHERE_MERCHANT_SECRET`, and notify/return/cancel URLs. Stripe has an explicit disabled adapter until its official SDK and webhook secret are configured. No raw card or provider secret is accepted or returned.
## Invoices
- `GET /orders/:id/invoice` is owner-scoped.
Invoice numbers use the locked ReferenceNumber sequence. Invoice metadata is created once per paid order. PDF generation is reserved for the existing document worker integration; no second PDF/storage subsystem was introduced.
## Refunds
- `POST /admin/orders/:id/refunds` requires `payments.refund` and `Idempotency-Key`.
Requested item quantities and captured totals are locked and validated. A refund never restocks inventory automatically. Provider refund execution remains disabled until provider API credentials/workflows are validated.
## Returns
- Customer: `POST /orders/:orderId/returns`, `GET /returns`, `GET /returns/:id`.
- Admin: list, approve, reject, mark-received, and complete actions.
The return window uses `RETURN_WINDOW_DAYS` (default 30). Quantity cannot exceed the remaining purchased quantity. Only accepted RESTOCKABLE items are added through the inventory service; damaged/non-restockable items are not. EXCHANGE records intent only.
Business credit purchasing is intentionally disabled: no locked credit ledger was added without an approved accounting policy. Delivery, loyalty, support AI, and analytics are outside Phase 7.
+3
View File
@@ -417,3 +417,6 @@ Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotion
## 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.
## Phase 7 Completion Update
Date: 2026-09-09. Module 09 is 86%, Module 10 is 76%, and Module 13 is revised to 82%. Orders are 90%, payments 78%, invoices 72%, refunds 68%, returns 82%, coupon redemption 80%, and verified-purchase reviews 90%. Eleven commerce models, a forward-only migration, transactional checkout conversion, owner/admin APIs, explicit state machines, verified/idempotent webhook processing, payment-time inventory consumption, invoice sequencing, itemized refund validation, RMA handling, and return-only restocking were added. Migration and real provider/MySQL/document-worker validation remain staging requirements. Module 16 AI Customer Support Chatbot is intentionally excluded from this backend and planned as a separate service.
+84
View File
@@ -0,0 +1,84 @@
# ZUMRI Phase 7 Orders and Payments
## Objective
Convert checkout snapshots into durable orders and establish authoritative payment, invoice, refund, and return lifecycles without delivery or rewards.
## Existing Components Reused
Checkout snapshots, inventory reservations, pricing money helpers, coupons, ReferenceNumber, document infrastructure, audit queue, authentication, and permissions.
## Order Architecture
Order/payment/fulfillment states are independent. Commercial and address/shipping details are immutable snapshots.
## Checkout Conversion
The centralized transaction locks an owned READY checkout, verifies reservations, returns any existing order, copies items, and converts checkout/cart.
## Order State Machine
Explicit transition sets reject illegal terminal-state transitions and arbitrary status patches.
## Order Snapshots
Items retain product/variant IDs, reservation key, SKU, names, quantity, unit price, discount, total, currency, and limited metadata.
## Payment Architecture
Payments retain immutable attempts. Only server/provider reconciliation changes authoritative financial state.
## Provider Adapters
Generic payment logic delegates verification/parsing/initiation/refund/status behavior to provider modules.
## PayHere
Merchant secrets are environmental. Browser redirects are non-authoritative; the notify hash, reference, amount, and currency must validate.
## Stripe
An explicit disabled boundary is present. Integration awaits official SDK/configuration rather than accepting unverified callbacks.
## Webhook Verification
Invalid signatures, currencies, amounts, and references are rejected.
## Webhook Idempotency
Unique provider/event records and locked payment/order rows ensure duplicate success cannot repeat side effects.
## Inventory Consumption
Checkout reserves; confirmed online payment consumes. Failed payment does not consume. Unpaid cancellation releases.
## Payment Reconciliation
Webhook persistence supports reconciliation, but remote status polling is deferred until the enabled provider supplies a validated status API.
## Business Credit
Disabled pending an approved immutable credit-ledger/accounting policy; concurrent unsafe balance mutation was not introduced.
## Invoice Architecture
One invoice per order uses transaction-safe ReferenceNumber sequencing. The existing document worker remains the PDF integration point.
## Refund Architecture
Idempotent itemized requests validate remaining quantity and captured amount. Provider processing is separate from request approval.
## Return/RMA Architecture
Owner-scoped requests and explicit admin transitions track physical receipt/condition independently from refunds.
## Exchange Boundary
EXCHANGE is recorded as resolution intent; no replacement order or fulfillment is created.
## Coupon Redemption
Redemption occurs once on confirmed payment while the coupon row is locked. Full-refund restoration is deferred by policy.
## Verified Purchase Reviews
Review creation derives verification only from an actual paid order containing the product.
## Permissions
Orders read/manage/cancel, payments read/manage/refund, invoices read, and returns read/manage were added.
## Audit Events
Order, payment creation, cancellation, refund request, return lifecycle, and admin operations reuse sanitized Phase 2 activity logging.
## Database Changes
Forward-only migration `20260909070000` creates eleven commerce tables with unique references/idempotency and lifecycle indexes.
## Tests
Unit tests cover order transitions, PayHere signature validation, forged notifications, secure return/refund inputs, and configurable return eligibility.
## Remaining Known Issues
Real PayHere, refunds, document generation, migration, webhook delivery, and MySQL concurrency were not exercised. Refund approval/provider completion APIs and reconciliation polling require provider policy/configuration.
## Phase 8 Prerequisites
Apply all migrations on restored staging, seed permissions, configure/validate PayHere sandbox, test concurrent duplicate webhooks, final-stock payment consumption, coupon quotas, invoice jobs, refund callbacks, and return restocking.
Module 16 AI Customer Support Chatbot is intentionally excluded and planned as a separate service.
+3
View File
@@ -47,6 +47,9 @@ const envSchema = z.object({
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),
RETURN_WINDOW_DAYS: z.coerce.number().int().positive().default(30),
PAYHERE_MERCHANT_ID: z.string().optional(), PAYHERE_MERCHANT_SECRET: z.string().optional(),
PAYHERE_NOTIFY_URL: z.string().url().optional(), PAYHERE_RETURN_URL: z.string().url().optional(), PAYHERE_CANCEL_URL: z.string().url().optional(),
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
@@ -25,4 +25,5 @@ module.exports = {
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",
ORDERS_READ:"orders.read",ORDERS_MANAGE:"orders.manage",ORDERS_CANCEL:"orders.cancel",PAYMENTS_READ:"payments.read",PAYMENTS_MANAGE:"payments.manage",PAYMENTS_REFUND:"payments.refund",INVOICES_READ:"invoices.read",RETURNS_READ:"returns.read",RETURNS_MANAGE:"returns.manage",
};
@@ -1,4 +1,4 @@
const crypto=require("crypto");const db=require("../../models");const {logActivity}=require("../../services/activity.service");
exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:false});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status}});}catch(e){return next(e);}};
exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const purchased=await db.Order.count({where:{user_id:req.user.id,payment_status:"PAID"},include:[{model:db.OrderItem,as:"items",where:{product_id:product.id},attributes:[]}]});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:purchased>0});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status,verifiedPurchase:review.verified_purchase}});}catch(e){return next(e);}};
exports.listAdmin=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;const x=await db.ProductReview.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){return next(e);}};
exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),orders=require("../../services/commerce/order.service"),returns=require("../../services/commerce/return.service"),refunds=require("../../services/commerce/refund.service"),state=require("../../services/commerce/orderState.service"),{logActivity}=require("../../services/activity.service");exports.orders=async(req,res,next)=>{try{res.json({success:true,data:await db.Order.findAll({include:[{model:db.OrderItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.order=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id,{include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,admin:true,requestId:req.id});await logActivity({user:req.user,type:"ORDER_CANCELLED",module:"Orders",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.processing=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"PROCESSING");await row.update({status:"PROCESSING"});res.json({success:true,data:row});}catch(e){next(e);}};exports.returns=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.returnAction=status=>async(req,res,next)=>{try{const row=await returns.transition({id:req.params.id,status,actorUserId:req.user.id,conditions:req.body.conditions||[]});await logActivity({user:req.user,type:`RETURN_${status}`,module:"Returns",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.refund=async(req,res,next)=>{try{const result=await refunds.request({orderId:req.params.id,actorUserId:req.user.id,operationKey:req.get("Idempotency-Key"),...req.body});await logActivity({user:req.user,type:"REFUND_REQUESTED",module:"Refunds",targetId:result.refund.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.refund});}catch(e){next(e);}};
@@ -0,0 +1,2 @@
const db=require("../../models"),orders=require("../../services/commerce/order.service"),payments=require("../../services/commerce/payment.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Orders",targetType:"ORDER",targetId:id,requestId:req.id});exports.create=async(req,res,next)=>{try{const result=await orders.createFromCheckout({checkoutId:req.body.checkoutId,userId:req.user.id});if(!result.idempotent)await audit(req,"ORDER_CREATED",result.order.id);res.status(result.idempotent?200:201).json({success:true,data:result.order});}catch(e){next(e);}};
exports.list=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={user_id:req.user.id};if(req.query.status)where.status=req.query.status;const x=await db.Order.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.Order.findOne({where:{id:req.params.id,user_id:req.user.id},include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments",attributes:{exclude:["failure_message"]}},{model:db.Invoice,as:"invoice"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,userId:req.user.id,requestId:req.id});await audit(req,"ORDER_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.payment=async(req,res,next)=>{try{const row=await db.Payment.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}],attributes:{exclude:["failure_message"]}});res.json({success:true,data:row});}catch(e){next(e);}};exports.invoice=async(req,res,next)=>{try{const row=await db.Invoice.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}]});if(!row)throw Object.assign(new Error("Invoice not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.startPayment=async(req,res,next)=>{try{const row=await payments.create({orderId:req.params.id,userId:req.user.id,...req.body});await audit(req,"PAYMENT_CREATED",row.id);res.status(201).json({success:true,data:{id:row.id,paymentReference:row.payment_reference,provider:row.provider,status:row.status,amount:String(row.amount),currency:row.currency}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),service=require("../../services/commerce/return.service"),{logActivity}=require("../../services/activity.service");exports.request=async(req,res,next)=>{try{const row=await service.request({orderId:req.params.orderId,userId:req.user.id,...req.body});await logActivity({user:req.user,type:"RETURN_REQUESTED",module:"Returns",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const service=require("../../services/commerce/payment.service");exports.payhere=async(req,res,next)=>{try{await service.handleWebhook("PAYHERE",req.body);res.status(200).send("OK");}catch(e){next(e);}};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("CouponRedemption",{id:{type:D.STRING,primaryKey:true},coupon_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},order_id:{type:D.STRING,allowNull:false},redeemed_at:{type:D.DATE,allowNull:false}},{tableName:"coupon_redemptions",timestamps:true,indexes:[{unique:true,fields:["coupon_id","order_id"]}]});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Invoice",{id:{type:D.STRING,primaryKey:true},invoice_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.ENUM("ISSUED","VOID"),allowNull:false},currency:{type:D.STRING(3),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},issued_at:{type:D.DATE,allowNull:false},paid_at:D.DATE,document_upload_id:D.INTEGER},{tableName:"invoices",timestamps:true});M.associate=db=>M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Order",{id:{type:D.STRING,primaryKey:true},order_number:{type:D.STRING(80),allowNull:false,unique:true},user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,checkout_session_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.ENUM("PENDING_PAYMENT","PAID","PROCESSING","READY_FOR_FULFILLMENT","PARTIALLY_FULFILLED","FULFILLED","COMPLETED","CANCELLED","REFUND_PENDING","PARTIALLY_REFUNDED","REFUNDED"),allowNull:false,defaultValue:"PENDING_PAYMENT"},payment_status:{type:D.ENUM("PENDING","AUTHORIZED","PAID","FAILED","CANCELLED","PARTIALLY_REFUNDED","REFUNDED"),allowNull:false,defaultValue:"PENDING"},fulfillment_status:{type:D.ENUM("UNFULFILLED","PARTIALLY_FULFILLED","FULFILLED"),allowNull:false,defaultValue:"UNFULFILLED"},currency:{type:D.STRING(3),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},shipping_address_snapshot:{type:D.JSON,allowNull:false},billing_address_snapshot:{type:D.JSON,allowNull:false},shipping_method_snapshot:{type:D.JSON,allowNull:false},coupon_code:D.STRING(50),business_partner_snapshot:D.JSON,placed_at:{type:D.DATE,allowNull:false},cancelled_at:D.DATE,completed_at:D.DATE},{tableName:"orders",timestamps:true});M.associate=db=>{M.hasMany(db.OrderItem,{foreignKey:"order_id",as:"items"});M.hasMany(db.Payment,{foreignKey:"order_id",as:"payments"});M.hasOne(db.Invoice,{foreignKey:"order_id",as:"invoice"});};return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("OrderItem",{id:{type:D.STRING,primaryKey:true},order_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},returned_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},refunded_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},metadata:D.JSON},{tableName:"order_items",timestamps:true,updatedAt:false});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Payment",{id:{type:D.STRING,primaryKey:true},order_id:{type:D.STRING,allowNull:false},payment_reference:{type:D.STRING(100),allowNull:false,unique:true},provider:{type:D.ENUM("PAYHERE","STRIPE","INTERNAL_CREDIT","CASH","MANUAL"),allowNull:false},method:{type:D.STRING(50),allowNull:false},status:{type:D.ENUM("PENDING","REQUIRES_ACTION","AUTHORIZED","PAID","FAILED","CANCELLED","PARTIALLY_REFUNDED","REFUNDED"),allowNull:false,defaultValue:"PENDING"},currency:{type:D.STRING(3),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},provider_transaction_id:D.STRING,provider_customer_reference:D.STRING,failure_code:D.STRING,failure_message:D.STRING(500),authorized_at:D.DATE,paid_at:D.DATE,failed_at:D.DATE,cancelled_at:D.DATE},{tableName:"payments",timestamps:true});M.associate=db=>{M.hasMany(db.PaymentAttempt,{foreignKey:"payment_id",as:"attempts"});M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("PaymentAttempt",{id:{type:D.STRING,primaryKey:true},payment_id:{type:D.STRING,allowNull:false},attempt_number:{type:D.INTEGER,allowNull:false},provider_request_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.STRING(40),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},provider_response_code:D.STRING,provider_transaction_id:D.STRING,started_at:{type:D.DATE,allowNull:false},completed_at:D.DATE,metadata:D.JSON},{tableName:"payment_attempts",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("PaymentWebhookEvent",{id:{type:D.STRING,primaryKey:true},provider:{type:D.STRING(30),allowNull:false},provider_event_id:{type:D.STRING(160),allowNull:false},event_type:{type:D.STRING(80),allowNull:false},payload_hash:{type:D.STRING(64),allowNull:false},processing_status:{type:D.ENUM("RECEIVED","PROCESSED","FAILED","IGNORED"),allowNull:false},received_at:{type:D.DATE,allowNull:false},processed_at:D.DATE,error_code:D.STRING},{tableName:"payment_webhook_events",timestamps:true,indexes:[{unique:true,fields:["provider","provider_event_id"]}]});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("Refund",{id:{type:D.STRING,primaryKey:true},refund_number:{type:D.STRING(80),allowNull:false,unique:true},operation_key:{type:D.STRING(160),allowNull:false,unique:true},order_id:{type:D.STRING,allowNull:false},payment_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("REQUESTED","APPROVED","PROCESSING","COMPLETED","FAILED","REJECTED","CANCELLED"),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},reason_code:{type:D.STRING(80),allowNull:false},reason_text:D.STRING(500),provider_refund_id:D.STRING,requested_by:{type:D.STRING,allowNull:false},approved_by:D.STRING,requested_at:{type:D.DATE,allowNull:false},processed_at:D.DATE},{tableName:"refunds",timestamps:true});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("RefundItem",{id:{type:D.STRING,primaryKey:true},refund_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false}},{tableName:"refund_items",timestamps:true});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ReturnItem",{id:{type:D.STRING,primaryKey:true},return_request_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},resolution:{type:D.ENUM("REFUND","EXCHANGE"),allowNull:false},condition:{type:D.ENUM("PENDING","RESTOCKABLE","DAMAGED","NON_RESTOCKABLE"),allowNull:false,defaultValue:"PENDING"},refund_amount:D.DECIMAL(15,2)},{tableName:"return_items",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ReturnRequest",{id:{type:D.STRING,primaryKey:true},return_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("REQUESTED","APPROVED","REJECTED","AWAITING_RETURN","IN_TRANSIT","RECEIVED","INSPECTING","COMPLETED","CANCELLED"),allowNull:false},reason_code:{type:D.STRING(80),allowNull:false},reason_text:D.STRING(500),requested_at:{type:D.DATE,allowNull:false},approved_at:D.DATE,received_at:D.DATE,completed_at:D.DATE},{tableName:"return_requests",timestamps:true});
+1
View File
@@ -95,6 +95,7 @@ db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.
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);
db.Order=require("./commerce/order.model")(sequelize,DataTypes);db.OrderItem=require("./commerce/orderItem.model")(sequelize,DataTypes);db.Payment=require("./commerce/payment.model")(sequelize,DataTypes);db.PaymentAttempt=require("./commerce/paymentAttempt.model")(sequelize,DataTypes);db.PaymentWebhookEvent=require("./commerce/paymentWebhookEvent.model")(sequelize,DataTypes);db.Invoice=require("./commerce/invoice.model")(sequelize,DataTypes);db.Refund=require("./commerce/refund.model")(sequelize,DataTypes);db.RefundItem=require("./commerce/refundItem.model")(sequelize,DataTypes);db.ReturnRequest=require("./commerce/returnRequest.model")(sequelize,DataTypes);db.ReturnItem=require("./commerce/returnItem.model")(sequelize,DataTypes);db.CouponRedemption=require("./commerce/couponRedemption.model")(sequelize,DataTypes);
/* Associations */
Object.keys(db).forEach(model => {
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),orders=require("../controllers/commerce/order.controller"),returns=require("../controllers/commerce/return.controller"),webhook=require("../controllers/commerce/webhook.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/commerce.schemas");r.post("/payments/webhooks/payhere",webhook.payhere);r.use(authenticate);r.get("/orders",orders.list);r.post("/orders/from-checkout",validate(s.fromCheckout),orders.create);r.get("/orders/:id",orders.get);r.post("/orders/:id/cancel",orders.cancel);r.post("/orders/:id/payment",validate(s.payment),orders.startPayment);r.get("/orders/:id/payment",orders.payment);r.get("/orders/:id/invoice",orders.invoice);r.post("/orders/:orderId/returns",validate(s.returnRequest),returns.request);r.get("/returns",returns.list);r.get("/returns/:id",returns.get);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../../controllers/commerce/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/commerce.schemas");r.use(authenticate);r.get("/orders",checkPermission("orders.read",{custom:true}),c.orders);r.get("/orders/:id",checkPermission("orders.read",{custom:true}),c.order);r.post("/orders/:id/cancel",checkPermission("orders.cancel",{custom:true}),c.cancel);r.post("/orders/:id/mark-processing",checkPermission("orders.manage",{custom:true}),c.processing);r.post("/orders/:id/refunds",checkPermission("payments.refund",{custom:true}),validate(s.refund),c.refund);r.get("/returns",checkPermission("returns.read",{custom:true}),c.returns);r.post("/returns/:id/approve",checkPermission("returns.manage",{custom:true}),c.returnAction("APPROVED"));r.post("/returns/:id/reject",checkPermission("returns.manage",{custom:true}),c.returnAction("REJECTED"));r.post("/returns/:id/mark-received",checkPermission("returns.manage",{custom:true}),c.returnAction("RECEIVED"));r.post("/returns/:id/complete",checkPermission("returns.manage",{custom:true}),validate(s.returnComplete),c.returnAction("COMPLETED"));module.exports=r;
+4
View File
@@ -34,6 +34,8 @@ const pricingAdminRoutes = require("./pricing/admin.routes");
const merchandisingPublicRoutes = require("./merchandising/public.routes");
const shoppingRoutes = require("./shopping.routes");
const shippingAdminRoutes = require("./shipping/admin.routes");
const commerceRoutes = require("./commerce.routes");
const commerceAdminRoutes = require("./commerce/admin.routes");
const router = express.Router();
@@ -60,5 +62,7 @@ router.use("/admin", pricingAdminRoutes);
router.use("/", merchandisingPublicRoutes);
router.use("/", shoppingRoutes);
router.use("/admin", shippingAdminRoutes);
router.use("/", commerceRoutes);
router.use("/admin", commerceAdminRoutes);
module.exports = router;
+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) {
+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);exports.fromCheckout=wrap(z.object({checkoutId:id}));exports.payment=wrap(z.object({provider:z.enum(["PAYHERE"]).default("PAYHERE"),method:z.string().min(1).max(50).default("ONLINE")}));exports.returnRequest=wrap(z.object({reasonCode:z.string().min(1).max(80),reasonText:z.string().max(500).optional(),items:z.array(z.object({orderItemId:id,quantity:z.number().int().positive(),resolution:z.enum(["REFUND","EXCHANGE"])}).strict()).min(1)}));exports.refund=wrap(z.object({paymentId:id,reasonCode:z.string().min(1).max(80),reasonText:z.string().max(500).optional(),items:z.array(z.object({orderItemId:id,quantity:z.number().int().positive()}).strict()).min(1)}));exports.returnComplete=wrap(z.object({conditions:z.array(z.object({returnItemId:id,condition:z.enum(["RESTOCKABLE","DAMAGED","NON_RESTOCKABLE"]),warehouseId:id.optional()}).strict()).default([])}));
@@ -0,0 +1,4 @@
"use strict";module.exports={async up(q,S){const ID={type:S.STRING,primaryKey:true,allowNull:false},REQ={type:S.STRING,allowNull:false},STR={type:S.STRING},DATE={type:S.DATE},DEC={type:S.DECIMAL(15,2)},INT={type:S.INTEGER,allowNull:false,defaultValue:0},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("orders",{id:ID,order_number:{type:S.STRING(80),allowNull:false,unique:true},user_id:REQ,business_customer_id:STR,checkout_session_id:{type:S.STRING,allowNull:false,unique:true},status:REQ,payment_status:REQ,fulfillment_status:REQ,currency:{type:S.STRING(3),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},shipping_address_snapshot:{type:S.JSON,allowNull:false},billing_address_snapshot:{type:S.JSON,allowNull:false},shipping_method_snapshot:{type:S.JSON,allowNull:false},coupon_code:STR,business_partner_snapshot:{type:S.JSON},placed_at:{type:S.DATE,allowNull:false},cancelled_at:DATE,completed_at:DATE,...TS});await q.addIndex("orders",["user_id","createdAt"]);await q.addIndex("orders",["status"]);await q.createTable("order_items",{id:ID,order_id:REQ,product_id:REQ,variant_id:REQ,reservation_key:{type:S.STRING(160),allowNull:false},sku:REQ,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},returned_quantity:INT,refunded_quantity:INT,metadata:{type:S.JSON},createdAt:{type:S.DATE,allowNull:false}});
await q.createTable("payments",{id:ID,order_id:REQ,payment_reference:{type:S.STRING(100),allowNull:false,unique:true},provider:REQ,method:REQ,status:REQ,currency:{type:S.STRING(3),allowNull:false},amount:{...DEC,allowNull:false},provider_transaction_id:STR,provider_customer_reference:STR,failure_code:STR,failure_message:{type:S.STRING(500)},authorized_at:DATE,paid_at:DATE,failed_at:DATE,cancelled_at:DATE,...TS});await q.addIndex("payments",["order_id","status"]);await q.createTable("payment_attempts",{id:ID,payment_id:REQ,attempt_number:{type:S.INTEGER,allowNull:false},provider_request_id:{type:S.STRING,allowNull:false,unique:true},status:REQ,amount:{...DEC,allowNull:false},provider_response_code:STR,provider_transaction_id:STR,started_at:{type:S.DATE,allowNull:false},completed_at:DATE,metadata:{type:S.JSON},...TS});await q.createTable("payment_webhook_events",{id:ID,provider:REQ,provider_event_id:{type:S.STRING(160),allowNull:false},event_type:REQ,payload_hash:{type:S.STRING(64),allowNull:false},processing_status:REQ,received_at:{type:S.DATE,allowNull:false},processed_at:DATE,error_code:STR,...TS});await q.addConstraint("payment_webhook_events",{fields:["provider","provider_event_id"],type:"unique",name:"uq_payment_webhook_provider_event"});
await q.createTable("invoices",{id:ID,invoice_number:{type:S.STRING(80),allowNull:false,unique:true},order_id:{type:S.STRING,allowNull:false,unique:true},status:REQ,currency:{type:S.STRING(3),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},issued_at:{type:S.DATE,allowNull:false},paid_at:DATE,document_upload_id:{type:S.INTEGER},...TS});await q.createTable("refunds",{id:ID,refund_number:{type:S.STRING(80),allowNull:false,unique:true},operation_key:{type:S.STRING(160),allowNull:false,unique:true},order_id:REQ,payment_id:REQ,status:REQ,amount:{...DEC,allowNull:false},currency:{type:S.STRING(3),allowNull:false},reason_code:REQ,reason_text:{type:S.STRING(500)},provider_refund_id:STR,requested_by:REQ,approved_by:STR,requested_at:{type:S.DATE,allowNull:false},processed_at:DATE,...TS});await q.addIndex("refunds",["order_id","status"]);await q.createTable("refund_items",{id:ID,refund_id:REQ,order_item_id:REQ,quantity:{type:S.INTEGER,allowNull:false},amount:{...DEC,allowNull:false},...TS});
await q.createTable("return_requests",{id:ID,return_number:{type:S.STRING(80),allowNull:false,unique:true},order_id:REQ,user_id:REQ,status:REQ,reason_code:REQ,reason_text:{type:S.STRING(500)},requested_at:{type:S.DATE,allowNull:false},approved_at:DATE,received_at:DATE,completed_at:DATE,...TS});await q.addIndex("return_requests",["order_id","status"]);await q.createTable("return_items",{id:ID,return_request_id:REQ,order_item_id:REQ,quantity:{type:S.INTEGER,allowNull:false},resolution:REQ,condition:REQ,refund_amount:DEC,...TS});await q.createTable("coupon_redemptions",{id:ID,coupon_id:REQ,user_id:REQ,order_id:REQ,redeemed_at:{type:S.DATE,allowNull:false},...TS});await q.addConstraint("coupon_redemptions",{fields:["coupon_id","order_id"],type:"unique",name:"uq_coupon_order_redemption"});},async down(){throw new Error("Phase 7 migration is forward-only; restore from a verified backup instead");}};
+1
View File
@@ -0,0 +1 @@
const state=require("../../app/services/commerce/orderState.service");describe("Phase 7 order state machine",()=>{test.each([["PENDING_PAYMENT","PAID"],["PAID","PROCESSING"],["FULFILLED","COMPLETED"],["REFUND_PENDING","REFUNDED"]])("allows %s -> %s",(from,to)=>expect(state.canTransition(from,to)).toBe(true));test.each([["CANCELLED","PAID"],["REFUNDED","PROCESSING"],["PENDING_PAYMENT","FULFILLED"]])("rejects %s -> %s",(from,to)=>expect(()=>state.assertTransition(from,to)).toThrow(/Illegal order transition/));});
@@ -0,0 +1 @@
const crypto=require("crypto"),provider=require("../../app/services/commerce/providers/payhere.provider");const md5=x=>crypto.createHash("md5").update(x).digest("hex").toUpperCase();describe("PayHere adapter boundary",()=>{beforeEach(()=>process.env.PAYHERE_MERCHANT_SECRET="test-secret");test("verifies the provider hash and parses paid status",()=>{const p={merchant_id:"m",order_id:"ORD-1",payhere_amount:"100.00",payhere_currency:"LKR",status_code:"2",payment_id:"pay-1"};p.md5sig=md5(`${p.merchant_id}${p.order_id}${p.payhere_amount}${p.payhere_currency}${p.status_code}${md5(process.env.PAYHERE_MERCHANT_SECRET)}`);expect(provider.verifyWebhook(p)).toBe(true);expect(provider.parseWebhook(p).status).toBe("PAID");});test("rejects forged hash",()=>expect(provider.verifyWebhook({merchant_id:"m",order_id:"o",payhere_amount:"1.00",payhere_currency:"LKR",status_code:"2",md5sig:"bad"})).toBe(false));});
+1
View File
@@ -0,0 +1 @@
const s=require("../../app/validation/commerce.schemas"),returns=require("../../app/services/commerce/return.service");const input=body=>({body,params:{},query:{}});describe("Phase 7 input boundaries",()=>{test("customer return cannot submit refund amount",()=>expect(()=>s.returnRequest.parse(input({reasonCode:"DAMAGED",items:[{orderItemId:"i",quantity:1,resolution:"REFUND",amount:"999"}]}))).toThrow());test("refund quantities are positive integers",()=>expect(()=>s.refund.parse(input({paymentId:"p",reasonCode:"ADMIN",items:[{orderItemId:"i",quantity:0}]}))).toThrow());test("return window is centralized",()=>expect(returns.windowDays()).toBeGreaterThan(0));});