From cd2c1c6d084bee29512c687fdfe10301009cbce5 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Wed, 9 Sep 2026 12:39:57 +0530 Subject: [PATCH 1/4] feat: Implement return and refund functionality in commerce module - Added return request handling in return.controller.js - Implemented webhook handling for PayHere payments in webhook.controller.js - Created models for coupon redemptions, invoices, orders, order items, payments, payment attempts, payment webhook events, refunds, return items, and return requests. - Developed services for order management, payment processing, refunds, and returns. - Introduced validation schemas for payment and return requests. - Created migration scripts for new database tables related to orders, payments, refunds, and returns. - Added unit tests for order state transitions and PayHere provider functionality. --- Documentation/API_ORDERS_PAYMENTS.md | 42 ++++++++++ Documentation/CURRENT_BACKEND_STATUS.md | 3 + Documentation/PHASE_7_ORDERS_PAYMENTS.md | 84 +++++++++++++++++++ app/config/env.config.js | 3 + app/constants/permissions.js | 1 + .../catalogue/review.controller.js | 2 +- app/controllers/commerce/admin.controller.js | 1 + app/controllers/commerce/order.controller.js | 2 + app/controllers/commerce/return.controller.js | 1 + .../commerce/webhook.controller.js | 1 + app/models/commerce/couponRedemption.model.js | 1 + app/models/commerce/invoice.model.js | 1 + app/models/commerce/order.model.js | 1 + app/models/commerce/orderItem.model.js | 1 + app/models/commerce/payment.model.js | 1 + app/models/commerce/paymentAttempt.model.js | 1 + .../commerce/paymentWebhookEvent.model.js | 1 + app/models/commerce/refund.model.js | 1 + app/models/commerce/refundItem.model.js | 1 + app/models/commerce/returnItem.model.js | 1 + app/models/commerce/returnRequest.model.js | 1 + app/models/index.js | 1 + app/routes/commerce.routes.js | 1 + app/routes/commerce/admin.routes.js | 1 + app/routes/index.js | 4 + app/services/commerce/order.service.js | 4 + app/services/commerce/orderState.service.js | 1 + app/services/commerce/payment.service.js | 6 ++ .../commerce/providers/payhere.provider.js | 5 ++ .../commerce/providers/stripe.provider.js | 1 + app/services/commerce/refund.service.js | 1 + app/services/commerce/return.service.js | 5 ++ app/services/inventory/inventory.service.js | 7 +- app/validation/commerce.schemas.js | 1 + .../20260909070000-phase-7-orders-payments.js | 4 + tests/unit/commerce.order-state.test.js | 1 + tests/unit/commerce.payhere-provider.test.js | 1 + tests/unit/commerce.validation.test.js | 1 + 38 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 Documentation/API_ORDERS_PAYMENTS.md create mode 100644 Documentation/PHASE_7_ORDERS_PAYMENTS.md create mode 100644 app/controllers/commerce/admin.controller.js create mode 100644 app/controllers/commerce/order.controller.js create mode 100644 app/controllers/commerce/return.controller.js create mode 100644 app/controllers/commerce/webhook.controller.js create mode 100644 app/models/commerce/couponRedemption.model.js create mode 100644 app/models/commerce/invoice.model.js create mode 100644 app/models/commerce/order.model.js create mode 100644 app/models/commerce/orderItem.model.js create mode 100644 app/models/commerce/payment.model.js create mode 100644 app/models/commerce/paymentAttempt.model.js create mode 100644 app/models/commerce/paymentWebhookEvent.model.js create mode 100644 app/models/commerce/refund.model.js create mode 100644 app/models/commerce/refundItem.model.js create mode 100644 app/models/commerce/returnItem.model.js create mode 100644 app/models/commerce/returnRequest.model.js create mode 100644 app/routes/commerce.routes.js create mode 100644 app/routes/commerce/admin.routes.js create mode 100644 app/services/commerce/order.service.js create mode 100644 app/services/commerce/orderState.service.js create mode 100644 app/services/commerce/payment.service.js create mode 100644 app/services/commerce/providers/payhere.provider.js create mode 100644 app/services/commerce/providers/stripe.provider.js create mode 100644 app/services/commerce/refund.service.js create mode 100644 app/services/commerce/return.service.js create mode 100644 app/validation/commerce.schemas.js create mode 100644 migrations/20260909070000-phase-7-orders-payments.js create mode 100644 tests/unit/commerce.order-state.test.js create mode 100644 tests/unit/commerce.payhere-provider.test.js create mode 100644 tests/unit/commerce.validation.test.js diff --git a/Documentation/API_ORDERS_PAYMENTS.md b/Documentation/API_ORDERS_PAYMENTS.md new file mode 100644 index 0000000..ab67d44 --- /dev/null +++ b/Documentation/API_ORDERS_PAYMENTS.md @@ -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. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index 27976be..93ac397 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -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. diff --git a/Documentation/PHASE_7_ORDERS_PAYMENTS.md b/Documentation/PHASE_7_ORDERS_PAYMENTS.md new file mode 100644 index 0000000..92b1301 --- /dev/null +++ b/Documentation/PHASE_7_ORDERS_PAYMENTS.md @@ -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. diff --git a/app/config/env.config.js b/app/config/env.config.js index 18eea3d..9da3058 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -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(), diff --git a/app/constants/permissions.js b/app/constants/permissions.js index 86d9753..f1998ce 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -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", }; diff --git a/app/controllers/catalogue/review.controller.js b/app/controllers/catalogue/review.controller.js index c44e943..cda2dc7 100644 --- a/app/controllers/catalogue/review.controller.js +++ b/app/controllers/catalogue/review.controller.js @@ -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);}}; diff --git a/app/controllers/commerce/admin.controller.js b/app/controllers/commerce/admin.controller.js new file mode 100644 index 0000000..05377e6 --- /dev/null +++ b/app/controllers/commerce/admin.controller.js @@ -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);}}; diff --git a/app/controllers/commerce/order.controller.js b/app/controllers/commerce/order.controller.js new file mode 100644 index 0000000..4ec1e88 --- /dev/null +++ b/app/controllers/commerce/order.controller.js @@ -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);}}; diff --git a/app/controllers/commerce/return.controller.js b/app/controllers/commerce/return.controller.js new file mode 100644 index 0000000..5d46fc1 --- /dev/null +++ b/app/controllers/commerce/return.controller.js @@ -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);}}; diff --git a/app/controllers/commerce/webhook.controller.js b/app/controllers/commerce/webhook.controller.js new file mode 100644 index 0000000..9c650e1 --- /dev/null +++ b/app/controllers/commerce/webhook.controller.js @@ -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);}}; diff --git a/app/models/commerce/couponRedemption.model.js b/app/models/commerce/couponRedemption.model.js new file mode 100644 index 0000000..891ee12 --- /dev/null +++ b/app/models/commerce/couponRedemption.model.js @@ -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"]}]}); diff --git a/app/models/commerce/invoice.model.js b/app/models/commerce/invoice.model.js new file mode 100644 index 0000000..adc1570 --- /dev/null +++ b/app/models/commerce/invoice.model.js @@ -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;}; diff --git a/app/models/commerce/order.model.js b/app/models/commerce/order.model.js new file mode 100644 index 0000000..ef5ece3 --- /dev/null +++ b/app/models/commerce/order.model.js @@ -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;}; diff --git a/app/models/commerce/orderItem.model.js b/app/models/commerce/orderItem.model.js new file mode 100644 index 0000000..00f51b5 --- /dev/null +++ b/app/models/commerce/orderItem.model.js @@ -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}); diff --git a/app/models/commerce/payment.model.js b/app/models/commerce/payment.model.js new file mode 100644 index 0000000..9c82b61 --- /dev/null +++ b/app/models/commerce/payment.model.js @@ -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;}; diff --git a/app/models/commerce/paymentAttempt.model.js b/app/models/commerce/paymentAttempt.model.js new file mode 100644 index 0000000..330938d --- /dev/null +++ b/app/models/commerce/paymentAttempt.model.js @@ -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}); diff --git a/app/models/commerce/paymentWebhookEvent.model.js b/app/models/commerce/paymentWebhookEvent.model.js new file mode 100644 index 0000000..5f8c97a --- /dev/null +++ b/app/models/commerce/paymentWebhookEvent.model.js @@ -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"]}]}); diff --git a/app/models/commerce/refund.model.js b/app/models/commerce/refund.model.js new file mode 100644 index 0000000..38f1449 --- /dev/null +++ b/app/models/commerce/refund.model.js @@ -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}); diff --git a/app/models/commerce/refundItem.model.js b/app/models/commerce/refundItem.model.js new file mode 100644 index 0000000..58a594c --- /dev/null +++ b/app/models/commerce/refundItem.model.js @@ -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}); diff --git a/app/models/commerce/returnItem.model.js b/app/models/commerce/returnItem.model.js new file mode 100644 index 0000000..b459c4a --- /dev/null +++ b/app/models/commerce/returnItem.model.js @@ -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}); diff --git a/app/models/commerce/returnRequest.model.js b/app/models/commerce/returnRequest.model.js new file mode 100644 index 0000000..774ae3d --- /dev/null +++ b/app/models/commerce/returnRequest.model.js @@ -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}); diff --git a/app/models/index.js b/app/models/index.js index c0a012b..c3735f0 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -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 => { diff --git a/app/routes/commerce.routes.js b/app/routes/commerce.routes.js new file mode 100644 index 0000000..925575a --- /dev/null +++ b/app/routes/commerce.routes.js @@ -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; diff --git a/app/routes/commerce/admin.routes.js b/app/routes/commerce/admin.routes.js new file mode 100644 index 0000000..300dccd --- /dev/null +++ b/app/routes/commerce/admin.routes.js @@ -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; diff --git a/app/routes/index.js b/app/routes/index.js index adf9da8..57dc487 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -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; diff --git a/app/services/commerce/order.service.js b/app/services/commerce/order.service.js new file mode 100644 index 0000000..8604637 --- /dev/null +++ b/app/services/commerce/order.service.js @@ -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}; diff --git a/app/services/commerce/orderState.service.js b/app/services/commerce/orderState.service.js new file mode 100644 index 0000000..bed3824 --- /dev/null +++ b/app/services/commerce/orderState.service.js @@ -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}; diff --git a/app/services/commerce/payment.service.js b/app/services/commerce/payment.service.js new file mode 100644 index 0000000..95feaa2 --- /dev/null +++ b/app/services/commerce/payment.service.js @@ -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}; diff --git a/app/services/commerce/providers/payhere.provider.js b/app/services/commerce/providers/payhere.provider.js new file mode 100644 index 0000000..3b0f7f6 --- /dev/null +++ b/app/services/commerce/providers/payhere.provider.js @@ -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}; diff --git a/app/services/commerce/providers/stripe.provider.js b/app/services/commerce/providers/stripe.provider.js new file mode 100644 index 0000000..29844cd --- /dev/null +++ b/app/services/commerce/providers/stripe.provider.js @@ -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}; diff --git a/app/services/commerce/refund.service.js b/app/services/commerce/refund.service.js new file mode 100644 index 0000000..85ae387 --- /dev/null +++ b/app/services/commerce/refund.service.js @@ -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}; diff --git a/app/services/commerce/return.service.js b/app/services/commerce/return.service.js new file mode 100644 index 0000000..23cbe92 --- /dev/null +++ b/app/services/commerce/return.service.js @@ -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}; diff --git a/app/services/inventory/inventory.service.js b/app/services/inventory/inventory.service.js index 98b959e..40cafb9 100644 --- a/app/services/inventory/inventory.service.js +++ b/app/services/inventory/inventory.service.js @@ -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) { diff --git a/app/validation/commerce.schemas.js b/app/validation/commerce.schemas.js new file mode 100644 index 0000000..889b30e --- /dev/null +++ b/app/validation/commerce.schemas.js @@ -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([])})); diff --git a/migrations/20260909070000-phase-7-orders-payments.js b/migrations/20260909070000-phase-7-orders-payments.js new file mode 100644 index 0000000..0be6a2c --- /dev/null +++ b/migrations/20260909070000-phase-7-orders-payments.js @@ -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");}}; diff --git a/tests/unit/commerce.order-state.test.js b/tests/unit/commerce.order-state.test.js new file mode 100644 index 0000000..dfd5826 --- /dev/null +++ b/tests/unit/commerce.order-state.test.js @@ -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/));}); diff --git a/tests/unit/commerce.payhere-provider.test.js b/tests/unit/commerce.payhere-provider.test.js new file mode 100644 index 0000000..b9d4633 --- /dev/null +++ b/tests/unit/commerce.payhere-provider.test.js @@ -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));}); diff --git a/tests/unit/commerce.validation.test.js b/tests/unit/commerce.validation.test.js new file mode 100644 index 0000000..a585ab1 --- /dev/null +++ b/tests/unit/commerce.validation.test.js @@ -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));}); -- 2.52.0 From 222483d194651249ead5819acf44446352e6d96b Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Wed, 9 Sep 2026 12:55:32 +0530 Subject: [PATCH 2/4] feat: Implement Phase 8 Delivery and Rider Management - Introduced new Delivery and Rider API documentation. - Added new models for RiderProfile, Shipment, ShipmentItem, ShipmentAssignment, ShipmentEvent, and ShipmentProof. - Developed controllers for admin and rider logistics, including shipment management and rider actions. - Created services for handling shipment creation, assignment, and state transitions. - Implemented validation schemas for shipment and rider operations. - Added new routes for admin and rider logistics, including tracking endpoints. - Established a state machine for shipment status transitions. - Created migration scripts for new database tables and relationships. - Added unit tests for shipment state transitions and validation security. --- Documentation/API_DELIVERY_RIDER.md | 41 +++++++++ Documentation/CURRENT_BACKEND_STATUS.md | 3 + Documentation/PHASE_8_DELIVERY_RIDER.md | 87 +++++++++++++++++++ app/constants/permissions.js | 1 + app/controllers/logistics/admin.controller.js | 1 + app/controllers/logistics/rider.controller.js | 4 + .../logistics/tracking.controller.js | 1 + app/models/index.js | 1 + app/models/logistics/riderProfile.model.js | 1 + app/models/logistics/shipment.model.js | 1 + .../logistics/shipmentAssignment.model.js | 1 + app/models/logistics/shipmentEvent.model.js | 1 + app/models/logistics/shipmentItem.model.js | 1 + app/models/logistics/shipmentProof.model.js | 1 + app/routes/index.js | 6 ++ app/routes/logistics/admin.routes.js | 1 + app/routes/logistics/rider.routes.js | 1 + app/routes/logistics/tracking.routes.js | 1 + app/services/logistics/shipment.service.js | 9 ++ .../logistics/shipmentState.service.js | 1 + app/validation/logistics.schemas.js | 1 + .../20260909080000-phase-8-delivery-riders.js | 1 + tests/unit/logistics.state.test.js | 1 + .../logistics.validation-security.test.js | 1 + 24 files changed, 168 insertions(+) create mode 100644 Documentation/API_DELIVERY_RIDER.md create mode 100644 Documentation/PHASE_8_DELIVERY_RIDER.md create mode 100644 app/controllers/logistics/admin.controller.js create mode 100644 app/controllers/logistics/rider.controller.js create mode 100644 app/controllers/logistics/tracking.controller.js create mode 100644 app/models/logistics/riderProfile.model.js create mode 100644 app/models/logistics/shipment.model.js create mode 100644 app/models/logistics/shipmentAssignment.model.js create mode 100644 app/models/logistics/shipmentEvent.model.js create mode 100644 app/models/logistics/shipmentItem.model.js create mode 100644 app/models/logistics/shipmentProof.model.js create mode 100644 app/routes/logistics/admin.routes.js create mode 100644 app/routes/logistics/rider.routes.js create mode 100644 app/routes/logistics/tracking.routes.js create mode 100644 app/services/logistics/shipment.service.js create mode 100644 app/services/logistics/shipmentState.service.js create mode 100644 app/validation/logistics.schemas.js create mode 100644 migrations/20260909080000-phase-8-delivery-riders.js create mode 100644 tests/unit/logistics.state.test.js create mode 100644 tests/unit/logistics.validation-security.test.js diff --git a/Documentation/API_DELIVERY_RIDER.md b/Documentation/API_DELIVERY_RIDER.md new file mode 100644 index 0000000..c82f5e7 --- /dev/null +++ b/Documentation/API_DELIVERY_RIDER.md @@ -0,0 +1,41 @@ +# Delivery and Rider API + +All endpoints use `/api/v1`. There is no public tracking endpoint. + +## Admin fulfillment + +- `GET /admin/shipments`, `GET /admin/shipments/:id` +- `POST /admin/shipments` creates a delivery from immutable Order address/shipping snapshots and item quantities. +- `POST /admin/shipments/return-pickup` creates one pickup for an approved return. +- `POST /admin/shipments/:id/assign`, `/unassign`, `/reschedule`, `/cancel` +- `GET /admin/dispatch` projects unassigned, active, and attention-needed shipments. + +Administration requires explicit shipment, dispatch, rider, or return-logistics permission. Shipment creation supports partial quantities, locks the order, checks existing non-cancelled allocations, and uses `Idempotency-Key` event identity. + +## Rider administration + +- `GET /admin/riders`, `GET /admin/riders/:id` +- `POST /admin/riders` attaches an operational profile to an existing RIDER User; it does not create another identity/password system. +- `PATCH /admin/riders/:id` +- `GET /admin/riders/:id/assignments` + +Profile status is independent of User account status. Availability is AVAILABLE, BUSY, or OFFLINE and capacity is configurable. + +## Rider workflow + +- `GET /rider/shipments`, `GET /rider/shipments/:id` +- Explicit actions: accept, reject, pickup, in-transit, out-for-delivery, deliver, and fail-delivery. +- `PUT /rider/location` stores optional latest coordinates; location is not included in customer tracking. + +Every operation derives the rider from authentication and constrains the current assignment. State changes use row locks and unique event IDs. Delivery requires structured proof; linked photo/signature uploads must be AVAILABLE images uploaded by that rider. + +## Customer tracking + +- `GET /orders/:orderId/tracking` +- `GET /returns/:id/tracking` + +Responses contain shipment status and ordered event projections, not rider phone, location, capacity, assignment history, payment, or provider data. + +## Behavior boundaries + +Delivered item quantities recalculate Order fulfillment without changing payment status. Failed delivery never refunds or restocks. Return-pickup delivery marks the RMA received for Phase 7 inspection; it does not refund or restock. External couriers, maps, route optimization, delivery OTP, live sockets, and AI dispatch are not implemented. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index 93ac397..f3d7c6c 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -420,3 +420,6 @@ Date: 2026-09-03. Module 06 is 91%, Module 08 is 84%, and Module 07 is revised t ## 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. +## Phase 8 Completion Update + +Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/dispatch 86%, tracking 88%, proof of delivery 82%, and return logistics 84%. Module 09 is revised to 90% through physical fulfillment integration. Six logistics models, a forward-only migration, explicit shipment states/actions, partial fulfillment, locked rider capacity/assignment, append-only events, proof media validation, safe tracking, and approved-return pickup were added. Automated checks pass 25 suites/123 tests with 305 JavaScript files syntax-checked. The migration was not executed. Staging must validate real InnoDB concurrency, multi-instance idempotency, proof storage, notification fan-out, permission seeding, and return handoff before production or Phase 9 rollout. Module 16 AI Customer Support Chatbot remains excluded from this backend and will be developed separately. diff --git a/Documentation/PHASE_8_DELIVERY_RIDER.md b/Documentation/PHASE_8_DELIVERY_RIDER.md new file mode 100644 index 0000000..195248e --- /dev/null +++ b/Documentation/PHASE_8_DELIVERY_RIDER.md @@ -0,0 +1,87 @@ +# ZUMRI Phase 8 Delivery and Rider Management + +## Objective +Add physical delivery and approved-return transportation after the paid Order boundary. + +## Existing Components Reused +User/RIDER authentication, Order/OrderItem snapshots, ReturnRequest, ReferenceNumber, Upload, audit, notification foundations, and permissions. + +## Shipment Architecture +Orders can have many CUSTOMER_DELIVERY shipments; returns have one RETURN_PICKUP. Shipment owns immutable destination and operational timestamps, never prices or payments. + +## Shipment Items +ShipmentItem references OrderItem and supports partial allocation. Locked allocation checks prevent non-cancelled totals exceeding purchases. + +## Shipment State Machine +Central legal transitions cover readiness, assignment, pickup, transit, delivery, failure, rescheduling, and cancellation. No generic status patch exists. + +## Order Fulfillment Integration +Delivered shipment quantities derive UNFULFILLED, PARTIALLY_FULFILLED, or FULFILLED. Delivery never changes payment or initiates a refund. + +## Rider Profile +Operational data attaches one-to-one to an existing RIDER User; authentication and identity remain on User. + +## Rider Availability +ACTIVE/INACTIVE/SUSPENDED is separate from AVAILABLE/BUSY/OFFLINE. Assignment locks the rider and checks active capacity. + +## Assignment Architecture +Append-preserved assignment rows record assignment, acceptance/rejection, unassignment, and completion. Shipment caches only the current rider. + +## Dispatch +The dispatch view queries ready, active, failed, and rescheduled shipments without adding redundant persistence. + +## Rider Workflow +Riders see and act only on their own current assignments through explicit legal action endpoints. + +## Delivery Events +ShipmentEvent is append-only. Unique event IDs provide retry idempotency and chronological customer timelines. + +## Proof of Delivery +Structured proof supports photo, signature, recipient confirmation, and return-pickup photo. Existing private Upload records are ownership/status/MIME checked. + +## Failed Deliveries +Failure requires a reason code and increments attempts. It does not refund, cancel the Order, or restock. + +## Rescheduling +Admin may schedule a failed delivery with bounded window/reason fields; no calendar optimizer is introduced. + +## Customer Tracking +Authenticated ownership is mandatory. Safe projections exclude private rider/location and financial data. + +## Location Privacy +Only latest optional coordinates are retained. They are operational data, absent from customer responses and broad audit metadata. + +## Return Logistics +Physical transport remains separate from Phase 7 inspection, refund, and inventory decisions. + +## Return Pickup +Only approved returns create an idempotent pickup. Delivery marks the request RECEIVED without premature refund/restock. + +## Notifications +Existing Phase 2 infrastructure remains the integration boundary. Automated delivery notification fan-out needs durable outbox/worker hardening before production. + +## Permissions +Shipment read/create/manage/assign, rider read/manage, dispatch read/manage, proof read, and return-logistics read/manage permissions were added. + +## Audit +Creation, assignment, rider actions, cancellation, reschedule, and return pickup use stable Phase 2 activity types without precise location. + +## Idempotency +Creation, assignment, and rider transitions use client keys mapped to unique ShipmentEvent IDs. Repeated delivery cannot duplicate proof/event/fulfillment changes. + +## Concurrency +Transactions and row locks protect creation allocations, assignment, capacity, transitions, completion, and return-pickup uniqueness. Real multi-connection InnoDB tests remain required. + +## Database Changes +Forward-only migration `20260909080000` creates six logistics tables with reference, event, lookup, and uniqueness indexes. + +## Tests +Unit tests cover all primary legal/illegal transitions, owner-derived rider authorization, address injection rejection, failure reason validation, and location ranges. + +## Remaining Known Issues +Real migrations/concurrency, durable notification fan-out, upload storage, pagination tuning, and assignment race testing require staging. No external courier or real-time GPS system exists. + +## Phase 9 Prerequisites +Apply migrations on restored staging, seed permissions/rider profiles, validate real concurrent allocation/assignment/delivery requests, proof uploads, notification idempotency, and return receipt handoff. + +Module 16 AI Customer Support Chatbot remains intentionally outside this backend and will be developed separately. diff --git a/app/constants/permissions.js b/app/constants/permissions.js index f1998ce..7698fd5 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -26,4 +26,5 @@ module.exports = { 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", + SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage", }; diff --git a/app/controllers/logistics/admin.controller.js b/app/controllers/logistics/admin.controller.js new file mode 100644 index 0000000..822e593 --- /dev/null +++ b/app/controllers/logistics/admin.controller.js @@ -0,0 +1 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),state=require("../../services/logistics/shipmentState.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID(),audit=(req,type,id)=>logActivity({user:req.user,type,module:"Logistics",targetType:"SHIPMENT",targetId:id,requestId:req.id});exports.shipments=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;res.json({success:true,data:await db.Shipment.findAll({where,include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.shipment=async(req,res,next)=>{try{const row=await db.Shipment.findByPk(req.params.id,{include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"},{model:db.ShipmentEvent,as:"events"},{model:db.ShipmentProof,as:"proofs"}]});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.create=async(req,res,next)=>{try{const result=await service.createDelivery({...req.body,actorUserId:req.user.id,eventId:`create:${key(req)}`});await audit(req,"SHIPMENT_CREATED",result.shipment.id);res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.returnPickup=async(req,res,next)=>{try{const result=await service.createReturnPickup({...req.body,actorUserId:req.user.id,eventId:`return-pickup:${key(req)}`});res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{const result=await service.assign({shipmentId:req.params.id,riderId:req.body.riderId,actorUserId:req.user.id,eventId:`assign:${key(req)}`});await audit(req,"RIDER_ASSIGNED",result.shipment.id);res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.unassign=async(req,res,next)=>{try{const row=await service.unassign({shipmentId:req.params.id,actorUserId:req.user.id,eventId:`unassign:${key(req)}`,reason:req.body.note});await audit(req,"RIDER_UNASSIGNED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.reschedule=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"RESCHEDULED");await row.update({status:"RESCHEDULED",scheduled_date:req.body.scheduledDate,time_window_start:req.body.timeWindowStart,time_window_end:req.body.timeWindowEnd,notes:req.body.reason},{transaction:t});await db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:row.id,event_id:`reschedule:${key(req)}`,type:"SHIPMENT_RESCHEDULED",status:"RESCHEDULED",actor_user_id:req.user.id,note:req.body.reason,occurred_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_RESCHEDULED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});state.assertTransition(row.status,"CANCELLED");await row.update({status:"CANCELLED",cancelled_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.dispatch=async(req,res,next)=>{try{res.json({success:true,data:{unassigned:await db.Shipment.findAll({where:{status:"READY_FOR_ASSIGNMENT"}}),active:await db.Shipment.findAll({where:{status:{[Op.in]:["ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY"]}}}),attention:await db.Shipment.findAll({where:{status:{[Op.in]:["DELIVERY_FAILED","RESCHEDULED"]}}})}});}catch(e){next(e);}}; diff --git a/app/controllers/logistics/rider.controller.js b/app/controllers/logistics/rider.controller.js new file mode 100644 index 0000000..efc81ab --- /dev/null +++ b/app/controllers/logistics/rider.controller.js @@ -0,0 +1,4 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID();exports.list=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});res.json({success:true,data:await db.Shipment.findAll({where:{assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),row=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentEvent,as:"events"}]});if(!row)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:{id:row.id,shipmentNumber:row.shipment_number,recipientName:row.recipient_name,recipientPhone:row.recipient_phone,address:row.address_snapshot,status:row.status,items:row.items,events:row.events}});}catch(e){next(e);}};const action=to=>async(req,res,next)=>{try{const result=await service.riderAction({shipmentId:req.params.id,userId:req.user.id,to,eventId:`${to}:${key(req)}`,note:req.body.note,proof:req.body.type?req.body:undefined});await logActivity({user:req.user,type:`SHIPMENT_${to}`,module:"Logistics",targetId:result.shipment.id,requestId:req.id});res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.accept=action("PICKUP_PENDING");exports.pickup=action("PICKED_UP");exports.inTransit=action("IN_TRANSIT");exports.outForDelivery=action("OUT_FOR_DELIVERY");exports.deliver=action("DELIVERED");exports.fail=action("DELIVERY_FAILED");exports.location=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});await rider.update({current_latitude:req.body.latitude,current_longitude:req.body.longitude,last_location_at:new Date()});res.status(204).end();}catch(e){next(e);}}; +exports.reject=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),shipment=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id}});if(!shipment)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});const row=await service.unassign({shipmentId:shipment.id,actorUserId:req.user.id,eventId:`reject:${key(req)}`,reason:req.body.note});await db.ShipmentAssignment.update({status:"REJECTED",rejected_at:new Date()},{where:{shipment_id:shipment.id,rider_id:rider.id,status:"UNASSIGNED"}});res.json({success:true,data:row});}catch(e){next(e);}}; +exports.adminUpdate=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await row.update({employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_UPDATED",module:"Logistics",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}}; +exports.adminList=async(req,res,next)=>{try{res.json({success:true,data:await db.RiderProfile.findAll({include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]})});}catch(e){next(e);}};exports.adminGet=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id,{include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]});if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.adminCreate=async(req,res,next)=>{try{const user=await db.User.findOne({where:{id:req.body.userId,accountType:"rider"}});if(!user)throw Object.assign(new Error("Existing RIDER user required"),{status:400,code:"RIDER_USER_REQUIRED"});const b=req.body,row=await db.RiderProfile.create({id:crypto.randomUUID(),user_id:user.id,employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_CREATED",module:"Logistics",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.assignments=async(req,res,next)=>{try{res.json({success:true,data:await db.ShipmentAssignment.findAll({where:{rider_id:req.params.id},order:[["assigned_at","DESC"]]})});}catch(e){next(e);}}; diff --git a/app/controllers/logistics/tracking.controller.js b/app/controllers/logistics/tracking.controller.js new file mode 100644 index 0000000..02db2a8 --- /dev/null +++ b/app/controllers/logistics/tracking.controller.js @@ -0,0 +1 @@ +const db=require("../../models");const project=s=>({shipmentNumber:s.shipment_number,type:s.type,status:s.status,shippingMethod:s.shipping_method_name,deliveredAt:s.delivered_at,events:s.events.map(e=>({type:e.type,status:e.status,note:e.note,occurredAt:e.occurred_at}))});exports.order=async(req,res,next)=>{try{const order=await db.Order.findOne({where:{id:req.params.orderId,user_id:req.user.id}});if(!order)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});const shipments=await db.Shipment.findAll({where:{order_id:order.id,type:"CUSTOMER_DELIVERY"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}],order:[["createdAt","ASC"]]});res.json({success:true,data:{orderNumber:order.order_number,shipments:shipments.map(project)}});}catch(e){next(e);}};exports.return=async(req,res,next)=>{try{const request=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!request)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});const shipment=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}]});res.json({success:true,data:{returnNumber:request.return_number,shipment:shipment&&project(shipment)}});}catch(e){next(e);}}; diff --git a/app/models/index.js b/app/models/index.js index c3735f0..2d58cbf 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -96,6 +96,7 @@ db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db. 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); +db.RiderProfile=require("./logistics/riderProfile.model")(sequelize,DataTypes);db.Shipment=require("./logistics/shipment.model")(sequelize,DataTypes);db.ShipmentItem=require("./logistics/shipmentItem.model")(sequelize,DataTypes);db.ShipmentAssignment=require("./logistics/shipmentAssignment.model")(sequelize,DataTypes);db.ShipmentEvent=require("./logistics/shipmentEvent.model")(sequelize,DataTypes);db.ShipmentProof=require("./logistics/shipmentProof.model")(sequelize,DataTypes); /* Associations */ Object.keys(db).forEach(model => { diff --git a/app/models/logistics/riderProfile.model.js b/app/models/logistics/riderProfile.model.js new file mode 100644 index 0000000..d0bacc2 --- /dev/null +++ b/app/models/logistics/riderProfile.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("RiderProfile",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false,unique:true},employee_code:{type:D.STRING(60),allowNull:false,unique:true},phone_override:D.STRING(30),vehicle_type:D.STRING(60),vehicle_registration:D.STRING(60),status:{type:D.ENUM("ACTIVE","INACTIVE","SUSPENDED"),allowNull:false,defaultValue:"ACTIVE"},availability_status:{type:D.ENUM("AVAILABLE","BUSY","OFFLINE"),allowNull:false,defaultValue:"OFFLINE"},current_latitude:D.DECIMAL(10,7),current_longitude:D.DECIMAL(10,7),last_location_at:D.DATE,max_active_assignments:{type:D.INTEGER,allowNull:false,defaultValue:1}},{tableName:"rider_profiles",timestamps:true});M.associate=db=>M.belongsTo(db.User,{foreignKey:"user_id",as:"user"});return M;}; diff --git a/app/models/logistics/shipment.model.js b/app/models/logistics/shipment.model.js new file mode 100644 index 0000000..fc4ffdc --- /dev/null +++ b/app/models/logistics/shipment.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Shipment",{id:{type:D.STRING,primaryKey:true},shipment_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:D.STRING,return_request_id:D.STRING,type:{type:D.ENUM("CUSTOMER_DELIVERY","RETURN_PICKUP"),allowNull:false},status:{type:D.ENUM("PENDING","READY_FOR_ASSIGNMENT","ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY","DELIVERED","DELIVERY_FAILED","RESCHEDULED","CANCELLED"),allowNull:false,defaultValue:"PENDING"},shipping_method_code:D.STRING(60),shipping_method_name:D.STRING(150),recipient_name:{type:D.STRING(150),allowNull:false},recipient_phone:{type:D.STRING(30),allowNull:false},address_snapshot:{type:D.JSON,allowNull:false},assigned_rider_id:D.STRING,assigned_at:D.DATE,picked_up_at:D.DATE,out_for_delivery_at:D.DATE,delivered_at:D.DATE,failed_at:D.DATE,cancelled_at:D.DATE,scheduled_date:D.DATE,time_window_start:D.STRING(20),time_window_end:D.STRING(20),delivery_attempt_count:{type:D.INTEGER,allowNull:false,defaultValue:0},notes:D.STRING(500)},{tableName:"shipments",timestamps:true});M.associate=db=>{M.hasMany(db.ShipmentItem,{foreignKey:"shipment_id",as:"items"});M.hasMany(db.ShipmentAssignment,{foreignKey:"shipment_id",as:"assignments"});M.hasMany(db.ShipmentEvent,{foreignKey:"shipment_id",as:"events"});M.hasMany(db.ShipmentProof,{foreignKey:"shipment_id",as:"proofs"});M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});};return M;}; diff --git a/app/models/logistics/shipmentAssignment.model.js b/app/models/logistics/shipmentAssignment.model.js new file mode 100644 index 0000000..bd1fe1e --- /dev/null +++ b/app/models/logistics/shipmentAssignment.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ShipmentAssignment",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},rider_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ASSIGNED","ACCEPTED","REJECTED","UNASSIGNED","COMPLETED"),allowNull:false},assigned_by:{type:D.STRING,allowNull:false},assigned_at:{type:D.DATE,allowNull:false},accepted_at:D.DATE,rejected_at:D.DATE,unassigned_at:D.DATE,completed_at:D.DATE,reason:D.STRING(500)},{tableName:"shipment_assignments",timestamps:true}); diff --git a/app/models/logistics/shipmentEvent.model.js b/app/models/logistics/shipmentEvent.model.js new file mode 100644 index 0000000..88da049 --- /dev/null +++ b/app/models/logistics/shipmentEvent.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ShipmentEvent",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},event_id:{type:D.STRING(160),allowNull:false,unique:true},type:{type:D.STRING(80),allowNull:false},status:{type:D.STRING(40),allowNull:false},actor_user_id:D.STRING,latitude:D.DECIMAL(10,7),longitude:D.DECIMAL(10,7),note:D.STRING(500),occurred_at:{type:D.DATE,allowNull:false}},{tableName:"shipment_events",timestamps:true,updatedAt:false}); diff --git a/app/models/logistics/shipmentItem.model.js b/app/models/logistics/shipmentItem.model.js new file mode 100644 index 0000000..438f250 --- /dev/null +++ b/app/models/logistics/shipmentItem.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ShipmentItem",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false}},{tableName:"shipment_items",timestamps:true,indexes:[{unique:true,fields:["shipment_id","order_item_id"]}]});M.associate=db=>M.belongsTo(db.Shipment,{foreignKey:"shipment_id",as:"shipment"});return M;}; diff --git a/app/models/logistics/shipmentProof.model.js b/app/models/logistics/shipmentProof.model.js new file mode 100644 index 0000000..6a1e6d2 --- /dev/null +++ b/app/models/logistics/shipmentProof.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ShipmentProof",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},event_id:{type:D.STRING(160),allowNull:false,unique:true},type:{type:D.ENUM("SIGNATURE","PHOTO","RECIPIENT_CONFIRMATION","RETURN_PICKUP_PHOTO"),allowNull:false},recipient_name:D.STRING(150),signature_upload_id:D.INTEGER,photo_upload_id:D.INTEGER,notes:D.STRING(500),captured_at:{type:D.DATE,allowNull:false},captured_by:{type:D.STRING,allowNull:false}},{tableName:"shipment_proofs",timestamps:true}); diff --git a/app/routes/index.js b/app/routes/index.js index 57dc487..78ccc26 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -36,6 +36,9 @@ const shoppingRoutes = require("./shopping.routes"); const shippingAdminRoutes = require("./shipping/admin.routes"); const commerceRoutes = require("./commerce.routes"); const commerceAdminRoutes = require("./commerce/admin.routes"); +const logisticsAdminRoutes = require("./logistics/admin.routes"); +const logisticsRiderRoutes = require("./logistics/rider.routes"); +const logisticsTrackingRoutes = require("./logistics/tracking.routes"); const router = express.Router(); @@ -64,5 +67,8 @@ router.use("/", shoppingRoutes); router.use("/admin", shippingAdminRoutes); router.use("/", commerceRoutes); router.use("/admin", commerceAdminRoutes); +router.use("/admin", logisticsAdminRoutes); +router.use("/rider", logisticsRiderRoutes); +router.use("/", logisticsTrackingRoutes); module.exports = router; diff --git a/app/routes/logistics/admin.routes.js b/app/routes/logistics/admin.routes.js new file mode 100644 index 0000000..38ce53e --- /dev/null +++ b/app/routes/logistics/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/logistics/admin.controller"),riders=require("../../controllers/logistics/rider.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/logistics.schemas");r.use(authenticate);r.get("/shipments",checkPermission("shipments.read",{custom:true}),c.shipments);r.get("/shipments/:id",checkPermission("shipments.read",{custom:true}),c.shipment);r.post("/shipments",checkPermission("shipments.create",{custom:true}),validate(s.shipment),c.create);r.post("/shipments/return-pickup",checkPermission("returns.logistics.manage",{custom:true}),validate(s.returnPickup),c.returnPickup);r.post("/shipments/:id/assign",checkPermission("shipments.assign",{custom:true}),validate(s.assign),c.assign);r.post("/shipments/:id/unassign",checkPermission("shipments.assign",{custom:true}),validate(s.note),c.unassign);r.post("/shipments/:id/reschedule",checkPermission("shipments.manage",{custom:true}),validate(s.reschedule),c.reschedule);r.post("/shipments/:id/cancel",checkPermission("shipments.manage",{custom:true}),c.cancel);r.get("/dispatch",checkPermission("dispatch.read",{custom:true}),c.dispatch);r.get("/riders",checkPermission("riders.read",{custom:true}),riders.adminList);r.get("/riders/:id",checkPermission("riders.read",{custom:true}),riders.adminGet);r.post("/riders",checkPermission("riders.manage",{custom:true}),validate(s.rider),riders.adminCreate);r.patch("/riders/:id",checkPermission("riders.manage",{custom:true}),validate(s.rider),riders.adminUpdate);r.get("/riders/:id/assignments",checkPermission("riders.read",{custom:true}),riders.assignments);module.exports=r; diff --git a/app/routes/logistics/rider.routes.js b/app/routes/logistics/rider.routes.js new file mode 100644 index 0000000..99965bd --- /dev/null +++ b/app/routes/logistics/rider.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/logistics/rider.controller"),{authenticate}=require("../../middleware/auth.middleware"),{authorizedAccountType}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/logistics.schemas");r.use(authenticate,authorizedAccountType(["rider"]));r.get("/shipments",c.list);r.get("/shipments/:id",c.get);r.post("/shipments/:id/accept",validate(s.note),c.accept);r.post("/shipments/:id/reject",validate(s.note),c.reject);r.post("/shipments/:id/pickup",validate(s.note),c.pickup);r.post("/shipments/:id/in-transit",validate(s.note),c.inTransit);r.post("/shipments/:id/out-for-delivery",validate(s.note),c.outForDelivery);r.post("/shipments/:id/deliver",validate(s.proof),c.deliver);r.post("/shipments/:id/fail-delivery",validate(s.reason),c.fail);r.put("/location",validate(s.location),c.location);module.exports=r; diff --git a/app/routes/logistics/tracking.routes.js b/app/routes/logistics/tracking.routes.js new file mode 100644 index 0000000..a1b911b --- /dev/null +++ b/app/routes/logistics/tracking.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/logistics/tracking.controller"),{authenticate}=require("../../middleware/auth.middleware");r.use(authenticate);r.get("/orders/:orderId/tracking",c.order);r.get("/returns/:id/tracking",c.return);module.exports=r; diff --git a/app/services/logistics/shipment.service.js b/app/services/logistics/shipment.service.js new file mode 100644 index 0000000..cbeb18e --- /dev/null +++ b/app/services/logistics/shipment.service.js @@ -0,0 +1,9 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),state=require("./shipmentState.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s}),activeAssignments=["ASSIGNED","ACCEPTED"]; +const event=async({shipment,eventId,type,status,actorUserId,note,latitude,longitude},transaction)=>db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:shipment.id,event_id:eventId,type,status,actor_user_id:actorUserId,note,latitude,longitude,occurred_at:new Date()},{transaction}); +async function createDelivery({orderId,items,actorUserId,eventId}){return db.sequelize.transaction(async transaction=>{const prior=await db.ShipmentEvent.findOne({where:{event_id:eventId},transaction});if(prior)return{shipment:await db.Shipment.findByPk(prior.shipment_id,{transaction}),idempotent:true};const order=await db.Order.findOne({where:{id:orderId,status:{[Op.in]:["PAID","PROCESSING","READY_FOR_FULFILLMENT"]},payment_status:"PAID"},include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Order is not eligible for fulfillment","ORDER_NOT_FULFILLABLE",409);const allocations=[];for(const requested of items){const item=order.items.find(x=>x.id===requested.orderItemId);if(!item||!Number.isInteger(requested.quantity)||requested.quantity<=0)throw fault("Invalid shipment item","INVALID_SHIPMENT_ITEM");const shipped=await db.ShipmentItem.sum("quantity",{where:{order_item_id:item.id},include:[{model:db.Shipment,as:"shipment",where:{status:{[Op.ne]:"CANCELLED"}},attributes:[]}],transaction})||0;if(Number(shipped)+requested.quantity>item.quantity)throw fault("Shipment quantity exceeds purchased quantity","SHIPMENT_OVERALLOCATED",409);allocations.push(requested);}const a=order.shipping_address_snapshot||{},method=order.shipping_method_snapshot||{},shipment=await db.Shipment.create({id:crypto.randomUUID(),shipment_number:`SHP-${new Date().getUTCFullYear()}-${pad(await nextSequence("shipments",transaction),6)}`,order_id:order.id,type:"CUSTOMER_DELIVERY",status:"READY_FOR_ASSIGNMENT",shipping_method_code:method.code,shipping_method_name:method.name,recipient_name:a.recipientName,recipient_phone:a.phone,address_snapshot:a},{transaction});await db.ShipmentItem.bulkCreate(allocations.map(x=>({id:crypto.randomUUID(),shipment_id:shipment.id,order_item_id:x.orderItemId,quantity:x.quantity})),{transaction});await event({shipment,eventId,type:"SHIPMENT_CREATED",status:shipment.status,actorUserId},transaction);await order.update({status:"READY_FOR_FULFILLMENT"},{transaction});return{shipment,idempotent:false};});} +async function createReturnPickup({returnRequestId,actorUserId,eventId}){return db.sequelize.transaction(async transaction=>{const request=await db.ReturnRequest.findOne({where:{id:returnRequestId,status:{[Op.in]:["APPROVED","AWAITING_RETURN"]}},transaction,lock:transaction.LOCK.UPDATE});if(!request)throw fault("Return is not ready for pickup","RETURN_NOT_READY",409);const existing=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},transaction,lock:transaction.LOCK.UPDATE});if(existing)return{shipment:existing,idempotent:true};const order=await db.Order.findByPk(request.order_id,{transaction}),a=order.shipping_address_snapshot||{},shipment=await db.Shipment.create({id:crypto.randomUUID(),shipment_number:`SHP-${new Date().getUTCFullYear()}-${pad(await nextSequence("shipments",transaction),6)}`,return_request_id:request.id,order_id:order.id,type:"RETURN_PICKUP",status:"READY_FOR_ASSIGNMENT",recipient_name:a.recipientName,recipient_phone:a.phone,address_snapshot:a},{transaction});await request.update({status:"AWAITING_RETURN"},{transaction});await event({shipment,eventId,type:"RETURN_PICKUP_CREATED",status:shipment.status,actorUserId},transaction);return{shipment,idempotent:false};});} +async function assign({shipmentId,riderId,actorUserId,eventId}){return db.sequelize.transaction(async transaction=>{const shipment=await db.Shipment.findByPk(shipmentId,{transaction,lock:transaction.LOCK.UPDATE});if(!shipment)throw fault("Shipment not found","NOT_FOUND",404);if(shipment.assigned_rider_id===riderId&&shipment.status==="ASSIGNED")return{shipment,idempotent:true};if(!["READY_FOR_ASSIGNMENT","RESCHEDULED"].includes(shipment.status))throw fault("Shipment is not assignable","SHIPMENT_NOT_ASSIGNABLE",409);const active=await db.ShipmentAssignment.findOne({where:{shipment_id:shipment.id,status:{[Op.in]:activeAssignments}},transaction,lock:transaction.LOCK.UPDATE});if(active)throw fault("Shipment already has an active rider","ACTIVE_ASSIGNMENT_EXISTS",409);const rider=await db.RiderProfile.findOne({where:{id:riderId,status:"ACTIVE",availability_status:{[Op.ne]:"OFFLINE"}},transaction,lock:transaction.LOCK.UPDATE});if(!rider)throw fault("Rider is unavailable","RIDER_UNAVAILABLE",409);const count=await db.ShipmentAssignment.count({where:{rider_id:rider.id,status:{[Op.in]:activeAssignments}},transaction});if(count>=rider.max_active_assignments)throw fault("Rider capacity reached","RIDER_CAPACITY",409);await db.ShipmentAssignment.create({id:crypto.randomUUID(),shipment_id:shipment.id,rider_id:rider.id,status:"ASSIGNED",assigned_by:actorUserId,assigned_at:new Date()},{transaction});await shipment.update({status:"ASSIGNED",assigned_rider_id:rider.id,assigned_at:new Date()},{transaction});await rider.update({availability_status:count+1>=rider.max_active_assignments?"BUSY":rider.availability_status},{transaction});await event({shipment,eventId,type:"RIDER_ASSIGNED",status:"ASSIGNED",actorUserId},transaction);return{shipment,idempotent:false};});} +async function unassign({shipmentId,actorUserId,eventId,reason}){return db.sequelize.transaction(async transaction=>{const shipment=await db.Shipment.findByPk(shipmentId,{transaction,lock:transaction.LOCK.UPDATE});if(!shipment||!["ASSIGNED","PICKUP_PENDING"].includes(shipment.status))throw fault("Shipment cannot be unassigned","SHIPMENT_NOT_ASSIGNABLE",409);const assignment=await db.ShipmentAssignment.findOne({where:{shipment_id:shipment.id,status:{[Op.in]:activeAssignments}},transaction,lock:transaction.LOCK.UPDATE});if(assignment)await assignment.update({status:"UNASSIGNED",unassigned_at:new Date(),reason},{transaction});state.assertTransition(shipment.status,"READY_FOR_ASSIGNMENT");await shipment.update({status:"READY_FOR_ASSIGNMENT",assigned_rider_id:null},{transaction});if(assignment)await db.RiderProfile.update({availability_status:"AVAILABLE"},{where:{id:assignment.rider_id},transaction});await event({shipment,eventId,type:"RIDER_UNASSIGNED",status:shipment.status,actorUserId,note:reason},transaction);return shipment;});} +async function riderAction({shipmentId,userId,to,eventId,note,proof}){return db.sequelize.transaction(async transaction=>{const rider=await db.RiderProfile.findOne({where:{user_id:userId,status:"ACTIVE"},transaction,lock:transaction.LOCK.UPDATE});if(!rider)throw fault("Active rider profile required","RIDER_UNAVAILABLE",403);const shipment=await db.Shipment.findOne({where:{id:shipmentId,assigned_rider_id:rider.id},transaction,lock:transaction.LOCK.UPDATE});if(!shipment)throw fault("Assigned shipment not found","NOT_FOUND",404);const prior=await db.ShipmentEvent.findOne({where:{event_id:eventId},transaction});if(prior)return{shipment,idempotent:true};state.assertTransition(shipment.status,to);if(proof){for(const uploadId of [proof.photoUploadId,proof.signatureUploadId].filter(Boolean)){const upload=await db.Upload.findOne({where:{id:uploadId,uploaded_by:userId,status:"AVAILABLE"},transaction});if(!upload||!String(upload.file_type).startsWith("image/"))throw fault("Owned available proof image required","INVALID_PROOF",400);}await db.ShipmentProof.create({id:crypto.randomUUID(),shipment_id:shipment.id,event_id:`proof:${eventId}`,type:proof.type,recipient_name:proof.recipientName,photo_upload_id:proof.photoUploadId,signature_upload_id:proof.signatureUploadId,notes:proof.notes,captured_at:new Date(),captured_by:userId},{transaction});}const fields={status:to,...(to==="PICKED_UP"&&{picked_up_at:new Date()}),...(to==="OUT_FOR_DELIVERY"&&{out_for_delivery_at:new Date()}),...(to==="DELIVERED"&&{delivered_at:new Date()}),...(to==="DELIVERY_FAILED"&&{failed_at:new Date(),delivery_attempt_count:shipment.delivery_attempt_count+1})};await shipment.update(fields,{transaction});await event({shipment,eventId,type:`SHIPMENT_${to}`,status:to,actorUserId:userId,note},transaction);const assignment=await db.ShipmentAssignment.findOne({where:{shipment_id:shipment.id,status:{[Op.in]:activeAssignments}},transaction,lock:transaction.LOCK.UPDATE});if(to==="DELIVERED"&&assignment){await assignment.update({status:"COMPLETED",completed_at:new Date()},{transaction});await rider.update({availability_status:"AVAILABLE"},{transaction});if(shipment.type==="RETURN_PICKUP")await db.ReturnRequest.update({status:"RECEIVED",received_at:new Date()},{where:{id:shipment.return_request_id,status:{[Op.in]:["APPROVED","AWAITING_RETURN"]}},transaction});else await recalculateOrder(shipment.order_id,transaction);}return{shipment,idempotent:false};});} +async function recalculateOrder(orderId,transaction){const order=await db.Order.findByPk(orderId,{include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE}),delivered=await db.ShipmentItem.findAll({include:[{model:db.Shipment,as:"shipment",where:{order_id:orderId,status:"DELIVERED"},attributes:[]}],transaction}),total=order.items.reduce((n,x)=>n+x.quantity,0),done=delivered.reduce((n,x)=>n+x.quantity,0),fulfillment=done===0?"UNFULFILLED":done>=total?"FULFILLED":"PARTIALLY_FULFILLED";await order.update({fulfillment_status:fulfillment,...(fulfillment==="FULFILLED"&&{status:"FULFILLED"})},{transaction});} +module.exports={createDelivery,createReturnPickup,assign,unassign,riderAction,recalculateOrder,activeAssignments}; diff --git a/app/services/logistics/shipmentState.service.js b/app/services/logistics/shipmentState.service.js new file mode 100644 index 0000000..dcb1b59 --- /dev/null +++ b/app/services/logistics/shipmentState.service.js @@ -0,0 +1 @@ +const transitions={PENDING:new Set(["READY_FOR_ASSIGNMENT","CANCELLED"]),READY_FOR_ASSIGNMENT:new Set(["ASSIGNED","CANCELLED"]),ASSIGNED:new Set(["PICKUP_PENDING","READY_FOR_ASSIGNMENT","CANCELLED"]),PICKUP_PENDING:new Set(["PICKED_UP","READY_FOR_ASSIGNMENT","CANCELLED"]),PICKED_UP:new Set(["IN_TRANSIT"]),IN_TRANSIT:new Set(["OUT_FOR_DELIVERY","DELIVERED"]),OUT_FOR_DELIVERY:new Set(["DELIVERED","DELIVERY_FAILED"]),DELIVERY_FAILED:new Set(["RESCHEDULED","CANCELLED"]),RESCHEDULED:new Set(["ASSIGNED","OUT_FOR_DELIVERY","CANCELLED"]),DELIVERED:new Set(),CANCELLED: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 shipment transition ${from} -> ${to}`),{code:"ILLEGAL_SHIPMENT_TRANSITION",status:409});};module.exports={transitions,canTransition,assertTransition}; diff --git a/app/validation/logistics.schemas.js b/app/validation/logistics.schemas.js new file mode 100644 index 0000000..0fd8d27 --- /dev/null +++ b/app/validation/logistics.schemas.js @@ -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.shipment=wrap(z.object({orderId:id,items:z.array(z.object({orderItemId:id,quantity:z.number().int().positive()}).strict()).min(1),notes:z.string().max(500).optional()}));exports.returnPickup=wrap(z.object({returnRequestId:id}));exports.assign=wrap(z.object({riderId:id}));exports.reason=wrap(z.object({reasonCode:z.enum(["CUSTOMER_UNAVAILABLE","ADDRESS_NOT_FOUND","CUSTOMER_REFUSED","ACCESS_RESTRICTED","VEHICLE_ISSUE","OTHER"]),note:z.string().max(500).optional()}));exports.note=wrap(z.object({note:z.string().max(500).optional()}));exports.reschedule=wrap(z.object({scheduledDate:z.coerce.date(),timeWindowStart:z.string().max(20).optional(),timeWindowEnd:z.string().max(20).optional(),reason:z.string().min(1).max(500)}));exports.proof=wrap(z.object({type:z.enum(["SIGNATURE","PHOTO","RECIPIENT_CONFIRMATION","RETURN_PICKUP_PHOTO"]),recipientName:z.string().max(150).optional(),photoUploadId:z.number().int().positive().optional(),signatureUploadId:z.number().int().positive().optional(),notes:z.string().max(500).optional()}));exports.rider=wrap(z.object({userId:id,employeeCode:z.string().min(1).max(60),phoneOverride:z.string().max(30).optional(),vehicleType:z.string().max(60).optional(),vehicleRegistration:z.string().max(60).optional(),status:z.enum(["ACTIVE","INACTIVE","SUSPENDED"]).default("ACTIVE"),availabilityStatus:z.enum(["AVAILABLE","BUSY","OFFLINE"]).default("OFFLINE"),maxActiveAssignments:z.number().int().positive().max(20).default(1)}));exports.location=wrap(z.object({latitude:z.number().min(-90).max(90),longitude:z.number().min(-180).max(180)})); diff --git a/migrations/20260909080000-phase-8-delivery-riders.js b/migrations/20260909080000-phase-8-delivery-riders.js new file mode 100644 index 0000000..783b1d5 --- /dev/null +++ b/migrations/20260909080000-phase-8-delivery-riders.js @@ -0,0 +1 @@ +"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},INT={type:S.INTEGER,allowNull:false,defaultValue:0},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("rider_profiles",{id:ID,user_id:{type:S.STRING,allowNull:false,unique:true},employee_code:{type:S.STRING(60),allowNull:false,unique:true},phone_override:STR,vehicle_type:STR,vehicle_registration:STR,status:REQ,availability_status:REQ,current_latitude:{type:S.DECIMAL(10,7)},current_longitude:{type:S.DECIMAL(10,7)},last_location_at:DATE,max_active_assignments:{type:S.INTEGER,allowNull:false,defaultValue:1},...TS});await q.addIndex("rider_profiles",["status","availability_status"]);await q.createTable("shipments",{id:ID,shipment_number:{type:S.STRING(80),allowNull:false,unique:true},order_id:STR,return_request_id:STR,type:REQ,status:REQ,shipping_method_code:STR,shipping_method_name:STR,recipient_name:REQ,recipient_phone:REQ,address_snapshot:{type:S.JSON,allowNull:false},assigned_rider_id:STR,assigned_at:DATE,picked_up_at:DATE,out_for_delivery_at:DATE,delivered_at:DATE,failed_at:DATE,cancelled_at:DATE,scheduled_date:DATE,time_window_start:STR,time_window_end:STR,delivery_attempt_count:INT,notes:{type:S.STRING(500)},...TS});for(const fields of [["order_id"],["return_request_id"],["status"],["assigned_rider_id"],["createdAt"]])await q.addIndex("shipments",fields);await q.addConstraint("shipments",{fields:["return_request_id","type"],type:"unique",name:"uq_return_pickup_shipment"});await q.createTable("shipment_items",{id:ID,shipment_id:REQ,order_item_id:REQ,quantity:{type:S.INTEGER,allowNull:false},...TS});await q.addConstraint("shipment_items",{fields:["shipment_id","order_item_id"],type:"unique",name:"uq_shipment_order_item"});await q.createTable("shipment_assignments",{id:ID,shipment_id:REQ,rider_id:REQ,status:REQ,assigned_by:REQ,assigned_at:{type:S.DATE,allowNull:false},accepted_at:DATE,rejected_at:DATE,unassigned_at:DATE,completed_at:DATE,reason:{type:S.STRING(500)},...TS});await q.addIndex("shipment_assignments",["shipment_id","status"]);await q.addIndex("shipment_assignments",["rider_id","status"]);await q.createTable("shipment_events",{id:ID,shipment_id:REQ,event_id:{type:S.STRING(160),allowNull:false,unique:true},type:REQ,status:REQ,actor_user_id:STR,latitude:{type:S.DECIMAL(10,7)},longitude:{type:S.DECIMAL(10,7)},note:{type:S.STRING(500)},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("shipment_events",["shipment_id","occurred_at"]);await q.createTable("shipment_proofs",{id:ID,shipment_id:REQ,event_id:{type:S.STRING(160),allowNull:false,unique:true},type:REQ,recipient_name:STR,signature_upload_id:{type:S.INTEGER},photo_upload_id:{type:S.INTEGER},notes:{type:S.STRING(500)},captured_at:{type:S.DATE,allowNull:false},captured_by:REQ,...TS});},async down(){throw new Error("Phase 8 migration is forward-only; restore from a verified backup instead");}}; diff --git a/tests/unit/logistics.state.test.js b/tests/unit/logistics.state.test.js new file mode 100644 index 0000000..51240cb --- /dev/null +++ b/tests/unit/logistics.state.test.js @@ -0,0 +1 @@ +const state=require("../../app/services/logistics/shipmentState.service");describe("Phase 8 shipment state machine",()=>{test.each([["PENDING","READY_FOR_ASSIGNMENT"],["READY_FOR_ASSIGNMENT","ASSIGNED"],["ASSIGNED","PICKUP_PENDING"],["PICKUP_PENDING","PICKED_UP"],["PICKED_UP","IN_TRANSIT"],["IN_TRANSIT","OUT_FOR_DELIVERY"],["OUT_FOR_DELIVERY","DELIVERED"],["OUT_FOR_DELIVERY","DELIVERY_FAILED"],["DELIVERY_FAILED","RESCHEDULED"]])("allows %s -> %s",(a,b)=>expect(state.canTransition(a,b)).toBe(true));test.each([["DELIVERED","PICKED_UP"],["CANCELLED","DELIVERED"],["PENDING","DELIVERED"]])("rejects %s -> %s",(a,b)=>expect(()=>state.assertTransition(a,b)).toThrow(/Illegal shipment transition/));}); diff --git a/tests/unit/logistics.validation-security.test.js b/tests/unit/logistics.validation-security.test.js new file mode 100644 index 0000000..15a5eb9 --- /dev/null +++ b/tests/unit/logistics.validation-security.test.js @@ -0,0 +1 @@ +const s=require("../../app/validation/logistics.schemas"),{authorizedAccountType}=require("../../app/middleware/permission.middleware");const input=body=>({body,params:{},query:{}});describe("Phase 8 validation and rider scope",()=>{test("shipment address cannot be supplied by client",()=>expect(()=>s.shipment.parse(input({orderId:"o",items:[{orderItemId:"i",quantity:1}],address:{recipient:"attacker"}}))).toThrow());test("delivery failure requires a reason code",()=>expect(()=>s.reason.parse(input({note:"failed"}))).toThrow());test("location validates geographic ranges",()=>expect(()=>s.location.parse(input({latitude:100,longitude:80}))).toThrow());test("customer cannot enter rider routes",()=>{const req={user:{accountType:"customer"}},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();authorizedAccountType(["rider"])(req,res,next);expect(res.status).toHaveBeenCalledWith(403);});}); -- 2.52.0 From abb760cc19c9397b1fbf75f35503f31a2440190a Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Wed, 9 Sep 2026 13:56:37 +0530 Subject: [PATCH 3/4] feat: Implement loyalty and wholesale services with models, routes, and validation - Added models for loyalty points allocation, redemption, rewards, tiers, tier history, and referrals. - Created business credit ledger entries, settlements, and settlement items models. - Developed services for loyalty operations including account management, point allocation, redemption, and referral handling. - Implemented wholesale credit management services for transactions and settlements. - Established routes for loyalty and wholesale admin and customer operations with appropriate middleware for authentication and permission checks. - Introduced validation schemas for loyalty and wholesale operations. - Set up cron jobs for loyalty reconciliation tasks such as birthday rewards and point expirations. - Created migration scripts to set up the database schema for loyalty and wholesale features. - Added unit tests for validation and policy enforcement in loyalty and wholesale services. --- Documentation/API_LOYALTY_REWARDS.md | 30 ++ Documentation/API_WHOLESALE_COMPLETION.md | 25 ++ Documentation/CURRENT_BACKEND_STATUS.md | 3 + Documentation/PHASE_9_LOYALTY_WHOLESALE.md | 90 +++++ app/config/env.config.js | 1 + app/constants/permissions.js | 1 + .../catalogue/review.controller.js | 4 +- app/controllers/loyalty/admin.controller.js | 1 + .../loyalty/customer.controller.js | 1 + app/controllers/wholesale/admin.controller.js | 1 + .../wholesale/creditPurchase.controller.js | 1 + .../wholesale/customer.controller.js | 1 + app/models/index.js | 326 +++++++++++++++--- .../customerCouponEntitlement.model.js | 1 + app/models/loyalty/loyaltyAccount.model.js | 1 + app/models/loyalty/loyaltyEarnRule.model.js | 1 + .../loyalty/loyaltyLedgerEntry.model.js | 1 + .../loyalty/loyaltyPointAllocation.model.js | 1 + app/models/loyalty/loyaltyRedemption.model.js | 1 + app/models/loyalty/loyaltyReward.model.js | 1 + app/models/loyalty/loyaltyTier.model.js | 1 + .../loyalty/loyaltyTierHistory.model.js | 1 + app/models/loyalty/referral.model.js | 1 + .../businessCreditLedgerEntry.model.js | 1 + .../wholesale/businessSettlement.model.js | 1 + .../wholesale/businessSettlementItem.model.js | 1 + .../wholesale/paymentAllocation.model.js | 1 + app/routes/index.js | 8 + app/routes/loyalty/admin.routes.js | 1 + app/routes/loyalty/customer.routes.js | 1 + app/routes/wholesale/admin.routes.js | 1 + app/routes/wholesale/customer.routes.js | 1 + app/services/commerce/payment.service.js | 4 +- app/services/loyalty/loyalty.service.js | 11 + .../loyalty/reconciliation.service.js | 1 + app/services/loyalty/referral.service.js | 1 + app/services/wholesale/credit.service.js | 1 + .../wholesale/creditPurchase.service.js | 1 + app/services/wholesale/settlement.service.js | 1 + app/validation/loyaltyWholesale.schemas.js | 1 + cron/index.js | 3 +- cron/loyaltyReconciliation.cron.js | 1 + ...0260909090000-phase-9-loyalty-wholesale.js | 2 + .../unit/loyalty-wholesale.validation.test.js | 1 + tests/unit/loyalty.policy.test.js | 1 + 45 files changed, 482 insertions(+), 58 deletions(-) create mode 100644 Documentation/API_LOYALTY_REWARDS.md create mode 100644 Documentation/API_WHOLESALE_COMPLETION.md create mode 100644 Documentation/PHASE_9_LOYALTY_WHOLESALE.md create mode 100644 app/controllers/loyalty/admin.controller.js create mode 100644 app/controllers/loyalty/customer.controller.js create mode 100644 app/controllers/wholesale/admin.controller.js create mode 100644 app/controllers/wholesale/creditPurchase.controller.js create mode 100644 app/controllers/wholesale/customer.controller.js create mode 100644 app/models/loyalty/customerCouponEntitlement.model.js create mode 100644 app/models/loyalty/loyaltyAccount.model.js create mode 100644 app/models/loyalty/loyaltyEarnRule.model.js create mode 100644 app/models/loyalty/loyaltyLedgerEntry.model.js create mode 100644 app/models/loyalty/loyaltyPointAllocation.model.js create mode 100644 app/models/loyalty/loyaltyRedemption.model.js create mode 100644 app/models/loyalty/loyaltyReward.model.js create mode 100644 app/models/loyalty/loyaltyTier.model.js create mode 100644 app/models/loyalty/loyaltyTierHistory.model.js create mode 100644 app/models/loyalty/referral.model.js create mode 100644 app/models/wholesale/businessCreditLedgerEntry.model.js create mode 100644 app/models/wholesale/businessSettlement.model.js create mode 100644 app/models/wholesale/businessSettlementItem.model.js create mode 100644 app/models/wholesale/paymentAllocation.model.js create mode 100644 app/routes/loyalty/admin.routes.js create mode 100644 app/routes/loyalty/customer.routes.js create mode 100644 app/routes/wholesale/admin.routes.js create mode 100644 app/routes/wholesale/customer.routes.js create mode 100644 app/services/loyalty/loyalty.service.js create mode 100644 app/services/loyalty/reconciliation.service.js create mode 100644 app/services/loyalty/referral.service.js create mode 100644 app/services/wholesale/credit.service.js create mode 100644 app/services/wholesale/creditPurchase.service.js create mode 100644 app/services/wholesale/settlement.service.js create mode 100644 app/validation/loyaltyWholesale.schemas.js create mode 100644 cron/loyaltyReconciliation.cron.js create mode 100644 migrations/20260909090000-phase-9-loyalty-wholesale.js create mode 100644 tests/unit/loyalty-wholesale.validation.test.js create mode 100644 tests/unit/loyalty.policy.test.js diff --git a/Documentation/API_LOYALTY_REWARDS.md b/Documentation/API_LOYALTY_REWARDS.md new file mode 100644 index 0000000..4936528 --- /dev/null +++ b/Documentation/API_LOYALTY_REWARDS.md @@ -0,0 +1,30 @@ +# Loyalty and Rewards API + +All customer APIs are authenticated and derive the loyalty owner from the session. + +## Customer + +- `GET /api/v1/loyalty` — balance, debt, lifetime points, current tier, benefits, and next-tier progress. +- `GET /api/v1/loyalty/history?page=&limit=` — paginated immutable ledger. +- `GET /api/v1/loyalty/tiers`, `GET /api/v1/loyalty/rewards`. +- `POST /api/v1/loyalty/redeem` — requires `Idempotency-Key` and reward ID. +- `GET /api/v1/loyalty/vouchers` — owner-specific coupon entitlements. +- `GET /api/v1/loyalty/referral`, `POST /api/v1/loyalty/referral/claim`. + +## Administration + +- Loyalty accounts and referral listing. +- Tier, earn-rule, and reward listing/creation. +- `POST /admin/loyalty/adjustments` requires `loyalty.points.adjust`, a non-zero integer delta, reason, and idempotency key. + +## Policy + +Purchase points are awarded only after authoritative PAID processing. Eligible value is discounted merchandise (`subtotal - discountTotal`), excluding shipping, tax, and duties. Money is divided by configured `amountUnit`, floored to whole units, then multiplied by configured points. + +Verified-review rewards require APPROVED and verified purchase. Referral rewards require the referred account's qualifying paid Order. Birthday events use `BIRTHDAY:user:year`; February 29 follows the actual calendar date. + +Ledger entries are never edited. Refund reversals append negative entries. If already-spent points prevent a complete debit, available points floor at zero and the remainder becomes explicit `pointsDebt`; later earnings repay debt first. + +Redemption locks the account/reward, validates limits, spends earliest-expiring allocations first, and creates at most one result per account/idempotency key. Coupon rewards reuse Phase 5 Coupon and issue an owner-specific entitlement. + +Expiry is configured per earn rule and reconciled in bounded daily batches. No direct balance/tier mutation API exists. diff --git a/Documentation/API_WHOLESALE_COMPLETION.md b/Documentation/API_WHOLESALE_COMPLETION.md new file mode 100644 index 0000000..fd18f7f --- /dev/null +++ b/Documentation/API_WHOLESALE_COMPLETION.md @@ -0,0 +1,25 @@ +# Wholesale Completion API + +Business endpoints require an authenticated, ACTIVE `business_customer`; organization identity is never accepted from the request. + +## Business account + +- `GET /api/v1/business/dashboard` +- `GET /api/v1/business/credit` +- `POST /api/v1/business/orders/:id/use-credit` with `Idempotency-Key` +- `GET /api/v1/business/settlements`, `GET /api/v1/business/settlements/:id` +- `GET /api/v1/business/orders/recent` + +The dashboard uses paid Order snapshots for monthly/lifetime volume and discounts, existing BusinessTier for classification, the immutable credit ledger for utilization, and settlement records for next payment information. + +## Administration + +- `POST /admin/wholesale/credit/transactions` (`wholesale.credit.manage`) +- `GET /admin/wholesale/businesses/:businessId/credit` (`wholesale.credit.read`) +- Settlement list/generation/issue/mark-paid endpoints with settlement permissions. + +Credit formula: `available = creditLimit - ledger balance`. Captures/authorizations increase utilization; payment, release, and refund entries decrease it. Every operation locks the existing BusinessCreditAccount, validates ACTIVE business/account status and currency, and uses a unique event ID. Credit-backed Order placement atomically captures credit, consumes reservations, records an INTERNAL_CREDIT Payment, marks the Order paid, and reuses invoice issuance. + +Settlements snapshot ledger activity for a unique business/period. Due dates come from the existing SettlementTerm. Numbers use ReferenceNumber, and only explicit lifecycle actions are accepted. + +Payment allocation storage exists as a foundation. External bank matching, general ledger, ERP, tax-authority integration, and document-worker validation remain outside this phase. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index f3d7c6c..601c315 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -423,3 +423,6 @@ Date: 2026-09-09. Module 09 is 86%, Module 10 is 76%, and Module 13 is revised t ## Phase 8 Completion Update Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/dispatch 86%, tracking 88%, proof of delivery 82%, and return logistics 84%. Module 09 is revised to 90% through physical fulfillment integration. Six logistics models, a forward-only migration, explicit shipment states/actions, partial fulfillment, locked rider capacity/assignment, append-only events, proof media validation, safe tracking, and approved-return pickup were added. Automated checks pass 25 suites/123 tests with 305 JavaScript files syntax-checked. The migration was not executed. Staging must validate real InnoDB concurrency, multi-instance idempotency, proof storage, notification fan-out, permission seeding, and return handoff before production or Phase 9 rollout. Module 16 AI Customer Support Chatbot remains excluded from this backend and will be developed separately. +## Phase 9 Completion Update + +Date: 2026-09-09. Module 12 is 86% and Module 13 is revised to 88%. Loyalty accounts are 92%, points ledger 90%, earning rules 86%, membership 88%, rewards/redemption 86%, referrals 82%, birthday rewards 80%, and expiry 80%. Business tiers are 84%, business credit 84%, settlements 78%, wholesale dashboard 82%, and wholesale analytics 76%. Module 07 is revised to 82% through coupon-entitlement foundations; Modules 09/10 are unchanged except for paid-event loyalty and internal-credit integration. Fourteen models, a forward-only migration, bounded cron reconciliation, new owner/admin APIs, and immutable concurrency-safe ledgers were added. Migration and real MySQL/cron/document/notification validation remain staging requirements. Module 16 AI Customer Support Chatbot remains intentionally excluded and will be developed separately. diff --git a/Documentation/PHASE_9_LOYALTY_WHOLESALE.md b/Documentation/PHASE_9_LOYALTY_WHOLESALE.md new file mode 100644 index 0000000..1ffb15b --- /dev/null +++ b/Documentation/PHASE_9_LOYALTY_WHOLESALE.md @@ -0,0 +1,90 @@ +# ZUMRI Phase 9 Loyalty and Wholesale Completion + +## Objective +Connect immutable loyalty rewards and wholesale credit/settlement accounting to existing users, paid orders, reviews, coupons, payments, and invoices. + +## Existing Components Reused +User/Profile DOB, BusinessCustomer/Tier/CreditAccount/SettlementTerm, Orders, Payments, Refunds, Invoices, Reviews, Coupons, pricing money helpers, ReferenceNumber, cron, notifications, audit, and RBAC. + +## Loyalty Architecture +One account caches balances while an append-only ledger remains authoritative. All mutations lock the account and use unique events. + +## Loyalty Account +ACTIVE/SUSPENDED/CLOSED state, spendable/pending/debt balances, lifetime totals, tier, and last activity are maintained transactionally. + +## Points Ledger +Integer EARN, REDEEM, EXPIRE, ADJUSTMENT, and REVERSAL entries store source, resulting balance, expiry, and sanitized metadata. + +## Earning Rules +Data-driven effective rules support amount-based purchases and fixed review/referral/birthday awards. + +## Purchase Rewards +Confirmed PAID processing awards discounted merchandise value only, with integer-safe floor rounding and deterministic Order event identity. + +## Refund Reversals +Reversals append history. Insufficient available points produce tracked debt rather than silently discarding value. + +## Verified Review Rewards +Only APPROVED verified-purchase reviews earn, once per review. + +## Referral Rewards +Opaque random codes prevent self/multiple referral claims; signup alone does not reward, while a qualifying paid Order does. + +## Birthday Rewards +Daily bounded reconciliation uses a unique user/calendar-year event, preventing DOB edits or retries from duplicating rewards. + +## Points Expiry +Earn rules may assign expiry. Allocation records support earliest-expiring-first redemption and idempotent expiry debits. + +## Membership Tiers +Active data-driven LIFETIME_POINTS thresholds select the highest rank and append tier history. + +## Reward Catalogue +Scheduled, limited COUPON or MANUAL rewards use integer point costs. + +## Reward Redemption +Account/reward/allocation locks enforce balance, stock, per-user limits, FIFO spending, and idempotency. + +## Voucher Integration +COUPON rewards reference Phase 5 Coupon and issue customer entitlements rather than creating another coupon engine. + +## Loyalty Notifications and Permissions +Phase 2 notification/audit remain the delivery boundaries. Accounts read, points adjustment, management, and referral-read permissions were added. + +## Wholesale Architecture +Existing business identity/pricing remain authoritative. Phase 9 adds financial ledgers and period snapshots. + +## Business Credit Ledger +Unique immutable events store amount, balance delta/after, currency, Order/Payment references, and event time. + +## Credit Utilization +Account row locks prevent limits being exceeded. Cached `usedCredit` was not added to BusinessCreditAccount; the latest ledger balance is authoritative. + +## Credit Purchase Integration +An eligible pending wholesale Order is captured, inventory-consumed, paid, and invoiced in one transaction. + +## Settlement Terms and Lifecycle +Existing term days derive due dates. DRAFT, ISSUED, PARTIALLY_PAID, PAID, OVERDUE, and CANCELLED use explicit transitions. + +## Business Statements, Invoice Integration, Payment Allocation +Settlement items reference source Orders/Invoices/ledger entries. Existing Invoice and Payment are reused; allocation persistence prepares later bank matching. + +## Wholesale Dashboard and Analytics +Owner-scoped SQL aggregates provide monthly/lifetime volume, discount totals, credit utilization, next settlement, and recent Orders without loading all history. + +## Security, Audit, Idempotency, Concurrency +No IDs select another organization on business routes. Strict schemas reject balance/tier/client financial fields. Unique event keys plus account/settlement/reward locks protect retries and competing writes. + +## Database Changes +Forward-only `20260909090000` creates fourteen focused loyalty/wholesale tables. Earlier migrations are unchanged. + +## Tests +Policy tests cover integer money conversion, configured caps, referral entropy, strict inputs, terminal settlement state, and permission denial, alongside Phase 0–8 regression. + +## Remaining Known Issues +Refund-completion reversal wiring, coupon-entitlement enforcement in the Phase 6 quote path, payment allocation APIs, settlement document generation, notification fan-out, and broader integration/concurrency tests require hardening. + +## Phase 10 Prerequisites +Run legacy loyalty/credit/voucher/DOB/reference prechecks, migrate restored staging, seed rules/tiers/rewards/permissions, and test real concurrent redemptions, credit purchases, settlements, webhook rewards, refunds, birthday, and expiry jobs. + +Module 16 AI Customer Support Chatbot is intentionally excluded and will be developed as a separate service. diff --git a/app/config/env.config.js b/app/config/env.config.js index 9da3058..626aa1d 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -50,6 +50,7 @@ const envSchema = z.object({ 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(), + LOYALTY_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).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(), diff --git a/app/constants/permissions.js b/app/constants/permissions.js index 7698fd5..53816e8 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -27,4 +27,5 @@ module.exports = { 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", SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage", + LOYALTY_ACCOUNTS_READ:"loyalty.accounts.read",LOYALTY_POINTS_ADJUST:"loyalty.points.adjust",LOYALTY_MANAGE:"loyalty.manage",LOYALTY_REFERRALS_READ:"loyalty.referrals.read",WHOLESALE_CREDIT_READ:"wholesale.credit.read",WHOLESALE_CREDIT_MANAGE:"wholesale.credit.manage",WHOLESALE_SETTLEMENTS_READ:"wholesale.settlements.read",WHOLESALE_SETTLEMENTS_MANAGE:"wholesale.settlements.manage",WHOLESALE_ANALYTICS_READ:"wholesale.analytics.read", }; diff --git a/app/controllers/catalogue/review.controller.js b/app/controllers/catalogue/review.controller.js index cda2dc7..9c7d32a 100644 --- a/app/controllers/catalogue/review.controller.js +++ b/app/controllers/catalogue/review.controller.js @@ -1,4 +1,4 @@ -const crypto=require("crypto");const db=require("../../models");const {logActivity}=require("../../services/activity.service"); +const crypto=require("crypto");const db=require("../../models");const loyalty=require("../../services/loyalty/loyalty.service");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 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);}}; +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()});if(r.status==="APPROVED")await loyalty.earnReview(r);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);}}; diff --git a/app/controllers/loyalty/admin.controller.js b/app/controllers/loyalty/admin.controller.js new file mode 100644 index 0000000..9dbc7cb --- /dev/null +++ b/app/controllers/loyalty/admin.controller.js @@ -0,0 +1 @@ +const crypto=require("crypto"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),{logActivity}=require("../../services/activity.service");const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.accounts=list(db.LoyaltyAccount);exports.tiers=list(db.LoyaltyTier);exports.rules=list(db.LoyaltyEarnRule);exports.rewards=list(db.LoyaltyReward);exports.referrals=list(db.Referral);exports.adjust=async(req,res,next)=>{try{const result=await loyalty.post({userId:req.body.userId,eventId:`LOYALTY:ADMIN:${req.get("Idempotency-Key")}`,type:"ADJUSTMENT",sourceType:"ADMIN_ADJUSTMENT",points:req.body.pointsDelta,descriptionCode:req.body.reasonCode,metadata:{note:req.body.note,actorUserId:req.user.id}});await logActivity({user:req.user,type:"LOYALTY_POINTS_ADJUSTED",module:"Loyalty",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.entry});}catch(e){next(e);}};exports.createTier=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyTier.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,status:b.status,rank:b.rank,qualification_threshold:b.qualificationThreshold,qualification_metric:"LIFETIME_POINTS",benefits_json:b.benefits,sort_order:b.sortOrder});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRule=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyEarnRule.create({id:crypto.randomUUID(),source_type:b.sourceType,status:b.status,points_per_amount:b.pointsPerAmount,amount_unit:b.amountUnit,fixed_points:b.fixedPoints,minimum_amount:b.minimumAmount,maximum_points:b.maximumPoints,expiry_days:b.expiryDays,effective_from:b.effectiveFrom,effective_to:b.effectiveTo});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createReward=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyReward.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,type:b.type,points_cost:b.pointsCost,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,stock_limit:b.stockLimit,per_user_limit:b.perUserLimit,configuration:b.configuration});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/loyalty/customer.controller.js b/app/controllers/loyalty/customer.controller.js new file mode 100644 index 0000000..46b0dfc --- /dev/null +++ b/app/controllers/loyalty/customer.controller.js @@ -0,0 +1 @@ +const{Op}=require("sequelize"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),referrals=require("../../services/loyalty/referral.service");exports.summary=async(req,res,next)=>{try{const account=await db.sequelize.transaction(t=>loyalty.accountFor(req.user.id,t)),tier=account.current_tier_id&&await db.LoyaltyTier.findByPk(account.current_tier_id),next=await db.LoyaltyTier.findOne({where:{status:"ACTIVE",qualification_threshold:{[Op.gt]:account.lifetime_points_earned}},order:[["qualification_threshold","ASC"]]});res.json({success:true,data:{status:account.status,availablePoints:account.available_points,pendingPoints:account.pending_points,pointsDebt:account.points_debt,lifetimePointsEarned:account.lifetime_points_earned,tier:tier&&{code:tier.code,name:tier.name,benefits:tier.benefits_json},nextTier:next&&{name:next.name,pointsRequired:next.qualification_threshold-account.lifetime_points_earned}}});}catch(e){next(e);}};exports.history=async(req,res,next)=>{try{const account=await db.LoyaltyAccount.findOne({where:{user_id:req.user.id}});if(!account)return res.json({success:true,data:[],pagination:{page:1,total:0}});const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),x=await db.LoyaltyLedgerEntry.findAndCountAll({where:{loyalty_account_id:account.id},limit,offset:(page-1)*limit,order:[["occurred_at","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.tiers=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyTier.findAll({where:{status:"ACTIVE"},order:[["rank","ASC"]]})});}catch(e){next(e);}};exports.rewards=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyReward.findAll({where:{status:"ACTIVE"},order:[["points_cost","ASC"]]})});}catch(e){next(e);}};exports.redeem=async(req,res,next)=>{try{const result=await loyalty.redeem({userId:req.user.id,rewardId:req.body.rewardId,idempotencyKey:req.get("Idempotency-Key")});res.status(result.idempotent?200:201).json({success:true,data:result.redemption});}catch(e){next(e);}};exports.vouchers=async(req,res,next)=>{try{res.json({success:true,data:await db.CustomerCouponEntitlement.findAll({where:{user_id:req.user.id,status:"ACTIVE"},attributes:{exclude:["user_id"]}})});}catch(e){next(e);}};exports.referral=async(req,res,next)=>{try{res.json({success:true,data:await referrals.getOrCreate(req.user.id)});}catch(e){next(e);}};exports.claimReferral=async(req,res,next)=>{try{res.status(201).json({success:true,data:await referrals.claim({code:req.body.code,userId:req.user.id})});}catch(e){next(e);}}; diff --git a/app/controllers/wholesale/admin.controller.js b/app/controllers/wholesale/admin.controller.js new file mode 100644 index 0000000..7dea4ea --- /dev/null +++ b/app/controllers/wholesale/admin.controller.js @@ -0,0 +1 @@ +const db=require("../../models"),credit=require("../../services/wholesale/credit.service"),settlements=require("../../services/wholesale/settlement.service"),{logActivity}=require("../../services/activity.service");exports.credit=async(req,res,next)=>{try{if(!req.get("Idempotency-Key"))throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await credit.post({...req.body,currency:req.body.currency.toUpperCase(),eventId:`CREDIT:${req.get("Idempotency-Key")}`,actorUserId:req.user.id});await logActivity({user:req.user,type:`CREDIT_${req.body.type}`,module:"Wholesale",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};exports.creditHistory=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessCreditLedgerEntry.findAll({where:{business_customer_id:req.params.businessId},order:[["occurred_at","DESC"]],limit:200})});}catch(e){next(e);}};exports.generate=async(req,res,next)=>{try{const result=await settlements.generate({...req.body,currency:req.body.currency.toUpperCase()});res.status(result.idempotent?200:201).json({success:true,data:result.settlement});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessSettlement.findAll({order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await settlements.transition(req.params.id,to,req.user.id)});}catch(e){next(e);}}; diff --git a/app/controllers/wholesale/creditPurchase.controller.js b/app/controllers/wholesale/creditPurchase.controller.js new file mode 100644 index 0000000..573118e --- /dev/null +++ b/app/controllers/wholesale/creditPurchase.controller.js @@ -0,0 +1 @@ +const service=require("../../services/wholesale/creditPurchase.service"),{logActivity}=require("../../services/activity.service");exports.purchase=async(req,res,next)=>{try{const key=req.get("Idempotency-Key");if(!key)throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await service.purchase({orderId:req.params.id,userId:req.user.id,eventId:`CREDIT:ORDER:${key}`});if(!result.idempotent)await logActivity({user:req.user,type:"CREDIT_CAPTURED",module:"Wholesale",targetId:result.credit.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:{orderId:result.order.id,paymentStatus:result.order.payment_status,credit:result.credit}});}catch(e){next(e);}}; diff --git a/app/controllers/wholesale/customer.controller.js b/app/controllers/wholesale/customer.controller.js new file mode 100644 index 0000000..8db8069 --- /dev/null +++ b/app/controllers/wholesale/customer.controller.js @@ -0,0 +1 @@ +const{QueryTypes}=require("sequelize"),db=require("../../models"),money=require("../../services/pricing/money");async function business(userId){const row=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"}});if(!row)throw Object.assign(new Error("Active business account required"),{status:403,code:"BUSINESS_REQUIRED"});return row;}exports.dashboard=async(req,res,next)=>{try{const b=await business(req.user.id),credit=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),last=credit&&await db.BusinessCreditLedgerEntry.findOne({where:{business_credit_account_id:credit.id},order:[["occurred_at","DESC"]]}),used=last?String(last.balance_after):"0.00",tier=b.business_tier_id&&await db.BusinessTier.findByPk(b.business_tier_id),settlement=await db.BusinessSettlement.findOne({where:{business_customer_id:b.business_customer_id,status:["ISSUED","PARTIALLY_PAID","OVERDUE"]},order:[["due_date","ASC"]]}),stats=(await db.sequelize.query("SELECT COALESCE(SUM(grand_total),0) AS lifetimeVolume, COALESCE(SUM(CASE WHEN placed_at >= DATE_FORMAT(UTC_DATE(), '%Y-%m-01') THEN grand_total ELSE 0 END),0) AS monthlyVolume, COALESCE(SUM(discount_total),0) AS discountTotal, COALESCE(SUM(subtotal),0) AS subtotalTotal FROM orders WHERE business_customer_id = :id AND payment_status = 'PAID'",{replacements:{id:b.business_customer_id},type:QueryTypes.SELECT}))[0];res.json({success:true,data:{partnerId:b.partnerId,tier:tier&&{code:tier.code,name:tier.name},credit:credit&&{status:credit.status,currency:credit.currency,limit:String(credit.credit_limit),used,available:money.subtractFloor(credit.credit_limit,used)},monthlyVolume:String(stats.monthlyVolume),lifetimeVolume:String(stats.lifetimeVolume),discountTotal:String(stats.discountTotal),subtotalTotal:String(stats.subtotalTotal),nextSettlement:settlement&&{number:settlement.settlement_number,dueDate:settlement.due_date,balanceDue:String(settlement.balance_due)}}});}catch(e){next(e);}};exports.credit=async(req,res,next)=>{try{const b=await business(req.user.id),account=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),rows=account?await db.BusinessCreditLedgerEntry.findAll({where:{business_credit_account_id:account.id},order:[["occurred_at","DESC"]],limit:100}):[];res.json({success:true,data:rows});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.BusinessSettlement.findAll({where:{business_customer_id:b.business_customer_id},order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.settlement=async(req,res,next)=>{try{const b=await business(req.user.id),row=await db.BusinessSettlement.findOne({where:{id:req.params.id,business_customer_id:b.business_customer_id}});if(!row)throw Object.assign(new Error("Settlement not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.orders=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.Order.findAll({where:{business_customer_id:b.business_customer_id},order:[["createdAt","DESC"]],limit:20})});}catch(e){next(e);}}; diff --git a/app/models/index.js b/app/models/index.js index 2d58cbf..a0bbff9 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -9,24 +9,19 @@ // app/models/index.js - const { Sequelize, DataTypes } = require("sequelize"); const dbConfig = require("../config/db.config"); -const loggingOption = process.env.NODE_ENV === "development" ? console.log : false; +const loggingOption = + process.env.NODE_ENV === "development" ? console.log : false; -const sequelize = new Sequelize( - dbConfig.DB, - dbConfig.USER, - dbConfig.PASSWORD, - { - host: dbConfig.HOST, - port: dbConfig.PORT, - dialect: dbConfig.DIALECT, - pool: dbConfig.pool, - logging: loggingOption - } -); +const sequelize = new Sequelize(dbConfig.DB, dbConfig.USER, dbConfig.PASSWORD, { + host: dbConfig.HOST, + port: dbConfig.PORT, + dialect: dbConfig.DIALECT, + pool: dbConfig.pool, + logging: loggingOption, +}); const db = {}; @@ -34,20 +29,38 @@ db.Sequelize = Sequelize; db.sequelize = sequelize; /* Register db models */ - -// User and Authentication + +// User and Authentication db.User = require("./user/user.model")(sequelize, DataTypes); db.Customer = require("./user/customer.model")(sequelize, DataTypes); -db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes); +db.BusinessCustomer = require("./user/businessCustomer.model")( + sequelize, + DataTypes, +); db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes); db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes); -db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); +db.UserActivity = require("./activities/userActivities.model")( + sequelize, + DataTypes, +); db.Profile = require("./user/profile.model")(sequelize, DataTypes); db.Address = require("./user/address.model")(sequelize, DataTypes); -db.BusinessApplication = require("./user/businessApplication.model")(sequelize, DataTypes); -db.BusinessContact = require("./user/businessContact.model")(sequelize, DataTypes); -db.BusinessCreditAccount = require("./user/businessCreditAccount.model")(sequelize, DataTypes); -db.SettlementTerm = require("./user/settlementTerm.model")(sequelize, DataTypes); +db.BusinessApplication = require("./user/businessApplication.model")( + sequelize, + DataTypes, +); +db.BusinessContact = require("./user/businessContact.model")( + sequelize, + DataTypes, +); +db.BusinessCreditAccount = require("./user/businessCreditAccount.model")( + sequelize, + DataTypes, +); +db.SettlementTerm = require("./user/settlementTerm.model")( + sequelize, + DataTypes, +); // Uploads db.Upload = require("./upload/upload.model")(sequelize, DataTypes); @@ -55,57 +68,264 @@ db.Upload = require("./upload/upload.model")(sequelize, DataTypes); // Roles and Permissions db.roles = require("./permission/role.model")(sequelize, DataTypes); db.permission = require("./permission/permission.model")(sequelize, DataTypes); -db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes); -db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes); +db.rolePermission = require("./permission/rolePermission.model")( + sequelize, + DataTypes, +); +db.userPermission = require("./permission/userPermission.model")( + sequelize, + DataTypes, +); db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes); // Document Management db.Document = require("./document/document.model")(sequelize, DataTypes); -db.DocumentType = require("./document/documentType.model")(sequelize, DataTypes); +db.DocumentType = require("./document/documentType.model")( + sequelize, + DataTypes, +); // Reference Numbers -db.referenceNumber = require("./referenceNumbers/referenceNumber.model")(sequelize, DataTypes); +db.referenceNumber = require("./referenceNumbers/referenceNumber.model")( + sequelize, + DataTypes, +); // Notifications -db.notification = require("./notification/notification.model")(sequelize, DataTypes); -db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes); -db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes); +db.notification = require("./notification/notification.model")( + sequelize, + DataTypes, +); +db.userNotification = require("./notification/userNotification.model")( + sequelize, + DataTypes, +); +db.NotificationDelivery = require("./notification/notificationDelivery.model")( + sequelize, + DataTypes, +); // Catalogue and localized content db.Brand = require("./catalogue/brand.model")(sequelize, DataTypes); db.Category = require("./catalogue/category.model")(sequelize, DataTypes); -db.CategoryTranslation = require("./catalogue/categoryTranslation.model")(sequelize, DataTypes); +db.CategoryTranslation = require("./catalogue/categoryTranslation.model")( + sequelize, + DataTypes, +); db.Product = require("./catalogue/product.model")(sequelize, DataTypes); -db.ProductTranslation = require("./catalogue/productTranslation.model")(sequelize, DataTypes); -db.ProductCategory = require("./catalogue/productCategory.model")(sequelize, DataTypes); -db.ProductVariant = require("./catalogue/productVariant.model")(sequelize, DataTypes); -db.ProductOption = require("./catalogue/productOption.model")(sequelize, DataTypes); -db.ProductOptionValue = require("./catalogue/productOptionValue.model")(sequelize, DataTypes); -db.VariantOptionValue = require("./catalogue/variantOptionValue.model")(sequelize, DataTypes); -db.ProductAttribute = require("./catalogue/productAttribute.model")(sequelize, DataTypes); -db.ProductMedia = require("./catalogue/productMedia.model")(sequelize, DataTypes); +db.ProductTranslation = require("./catalogue/productTranslation.model")( + sequelize, + DataTypes, +); +db.ProductCategory = require("./catalogue/productCategory.model")( + sequelize, + DataTypes, +); +db.ProductVariant = require("./catalogue/productVariant.model")( + sequelize, + DataTypes, +); +db.ProductOption = require("./catalogue/productOption.model")( + sequelize, + DataTypes, +); +db.ProductOptionValue = require("./catalogue/productOptionValue.model")( + sequelize, + DataTypes, +); +db.VariantOptionValue = require("./catalogue/variantOptionValue.model")( + sequelize, + DataTypes, +); +db.ProductAttribute = require("./catalogue/productAttribute.model")( + sequelize, + DataTypes, +); +db.ProductMedia = require("./catalogue/productMedia.model")( + sequelize, + DataTypes, +); db.Collection = require("./catalogue/collection.model")(sequelize, DataTypes); -db.CollectionTranslation = require("./catalogue/collectionTranslation.model")(sequelize, DataTypes); -db.CollectionProduct = require("./catalogue/collectionProduct.model")(sequelize, DataTypes); +db.CollectionTranslation = require("./catalogue/collectionTranslation.model")( + sequelize, + DataTypes, +); +db.CollectionProduct = require("./catalogue/collectionProduct.model")( + sequelize, + DataTypes, +); db.SizeGuide = require("./catalogue/sizeGuide.model")(sequelize, DataTypes); -db.ProductRelation = require("./catalogue/productRelation.model")(sequelize, DataTypes); -db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTypes); -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); -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); -db.RiderProfile=require("./logistics/riderProfile.model")(sequelize,DataTypes);db.Shipment=require("./logistics/shipment.model")(sequelize,DataTypes);db.ShipmentItem=require("./logistics/shipmentItem.model")(sequelize,DataTypes);db.ShipmentAssignment=require("./logistics/shipmentAssignment.model")(sequelize,DataTypes);db.ShipmentEvent=require("./logistics/shipmentEvent.model")(sequelize,DataTypes);db.ShipmentProof=require("./logistics/shipmentProof.model")(sequelize,DataTypes); +db.ProductRelation = require("./catalogue/productRelation.model")( + sequelize, + DataTypes, +); +db.ProductReview = require("./catalogue/productReview.model")( + sequelize, + DataTypes, +); +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, +); +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, +); +db.RiderProfile = require("./logistics/riderProfile.model")( + sequelize, + DataTypes, +); +db.Shipment = require("./logistics/shipment.model")(sequelize, DataTypes); +db.ShipmentItem = require("./logistics/shipmentItem.model")( + sequelize, + DataTypes, +); +db.ShipmentAssignment = require("./logistics/shipmentAssignment.model")( + sequelize, + DataTypes, +); +db.ShipmentEvent = require("./logistics/shipmentEvent.model")( + sequelize, + DataTypes, +); +db.ShipmentProof = require("./logistics/shipmentProof.model")( + sequelize, + DataTypes, +); +db.LoyaltyAccount = require("./loyalty/loyaltyAccount.model")( + sequelize, + DataTypes, +); +db.LoyaltyTier = require("./loyalty/loyaltyTier.model")(sequelize, DataTypes); +db.LoyaltyTierHistory = require("./loyalty/loyaltyTierHistory.model")( + sequelize, + DataTypes, +); +db.LoyaltyEarnRule = require("./loyalty/loyaltyEarnRule.model")( + sequelize, + DataTypes, +); +db.LoyaltyLedgerEntry = require("./loyalty/loyaltyLedgerEntry.model")( + sequelize, + DataTypes, +); +db.LoyaltyPointAllocation = require("./loyalty/loyaltyPointAllocation.model")( + sequelize, + DataTypes, +); +db.LoyaltyReward = require("./loyalty/loyaltyReward.model")( + sequelize, + DataTypes, +); +db.LoyaltyRedemption = require("./loyalty/loyaltyRedemption.model")( + sequelize, + DataTypes, +); +db.Referral = require("./loyalty/referral.model")(sequelize, DataTypes); +db.CustomerCouponEntitlement = + require("./loyalty/customerCouponEntitlement.model")(sequelize, DataTypes); +db.BusinessCreditLedgerEntry = + require("./wholesale/businessCreditLedgerEntry.model")(sequelize, DataTypes); +db.BusinessSettlement = require("./wholesale/businessSettlement.model")( + sequelize, + DataTypes, +); +db.BusinessSettlementItem = require("./wholesale/businessSettlementItem.model")( + sequelize, + DataTypes, +); +db.PaymentAllocation = require("./wholesale/paymentAllocation.model")( + sequelize, + DataTypes, +); /* Associations */ -Object.keys(db).forEach(model => { +Object.keys(db).forEach((model) => { if (db[model].associate) { db[model].associate(db); } }); - - - module.exports = db; diff --git a/app/models/loyalty/customerCouponEntitlement.model.js b/app/models/loyalty/customerCouponEntitlement.model.js new file mode 100644 index 0000000..2246f74 --- /dev/null +++ b/app/models/loyalty/customerCouponEntitlement.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("CustomerCouponEntitlement",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false},coupon_id:{type:D.STRING,allowNull:false},source_type:{type:D.STRING(40),allowNull:false},source_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","USED","EXPIRED","REVOKED"),allowNull:false},issued_at:{type:D.DATE,allowNull:false},used_at:D.DATE,expires_at:D.DATE},{tableName:"customer_coupon_entitlements",timestamps:true,indexes:[{unique:true,fields:["user_id","coupon_id","source_type","source_id"]}]}); diff --git a/app/models/loyalty/loyaltyAccount.model.js b/app/models/loyalty/loyaltyAccount.model.js new file mode 100644 index 0000000..6ec2f89 --- /dev/null +++ b/app/models/loyalty/loyaltyAccount.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyAccount",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","SUSPENDED","CLOSED"),allowNull:false,defaultValue:"ACTIVE"},current_tier_id:D.STRING,lifetime_points_earned:{type:D.INTEGER,allowNull:false,defaultValue:0},lifetime_points_redeemed:{type:D.INTEGER,allowNull:false,defaultValue:0},available_points:{type:D.INTEGER,allowNull:false,defaultValue:0},pending_points:{type:D.INTEGER,allowNull:false,defaultValue:0},points_debt:{type:D.INTEGER,allowNull:false,defaultValue:0},last_activity_at:D.DATE},{tableName:"loyalty_accounts",timestamps:true}); diff --git a/app/models/loyalty/loyaltyEarnRule.model.js b/app/models/loyalty/loyaltyEarnRule.model.js new file mode 100644 index 0000000..0ffac40 --- /dev/null +++ b/app/models/loyalty/loyaltyEarnRule.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyEarnRule",{id:{type:D.STRING,primaryKey:true},source_type:{type:D.ENUM("PURCHASE","VERIFIED_REVIEW","REFERRAL","BIRTHDAY"),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false},points_per_amount:{type:D.INTEGER},amount_unit:D.DECIMAL(15,2),fixed_points:D.INTEGER,minimum_amount:D.DECIMAL(15,2),maximum_points:D.INTEGER,expiry_days:D.INTEGER,effective_from:{type:D.DATE,allowNull:false},effective_to:D.DATE},{tableName:"loyalty_earn_rules",timestamps:true}); diff --git a/app/models/loyalty/loyaltyLedgerEntry.model.js b/app/models/loyalty/loyaltyLedgerEntry.model.js new file mode 100644 index 0000000..1b5de52 --- /dev/null +++ b/app/models/loyalty/loyaltyLedgerEntry.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyLedgerEntry",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},loyalty_account_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("EARN","REDEEM","EXPIRE","ADJUSTMENT","REVERSAL"),allowNull:false},source_type:{type:D.STRING(50),allowNull:false},source_id:D.STRING,points_delta:{type:D.INTEGER,allowNull:false},balance_after:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("POSTED"),allowNull:false,defaultValue:"POSTED"},expires_at:D.DATE,description_code:D.STRING(80),metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false}},{tableName:"loyalty_ledger_entries",timestamps:true,updatedAt:false}); diff --git a/app/models/loyalty/loyaltyPointAllocation.model.js b/app/models/loyalty/loyaltyPointAllocation.model.js new file mode 100644 index 0000000..0f92d7e --- /dev/null +++ b/app/models/loyalty/loyaltyPointAllocation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyPointAllocation",{id:{type:D.STRING,primaryKey:true},earn_ledger_id:{type:D.STRING,allowNull:false},loyalty_account_id:{type:D.STRING,allowNull:false},original_points:{type:D.INTEGER,allowNull:false},remaining_points:{type:D.INTEGER,allowNull:false},expires_at:D.DATE},{tableName:"loyalty_point_allocations",timestamps:true}); diff --git a/app/models/loyalty/loyaltyRedemption.model.js b/app/models/loyalty/loyaltyRedemption.model.js new file mode 100644 index 0000000..f29a809 --- /dev/null +++ b/app/models/loyalty/loyaltyRedemption.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyRedemption",{id:{type:D.STRING,primaryKey:true},redemption_number:{type:D.STRING(80),allowNull:false,unique:true},idempotency_key:{type:D.STRING(160),allowNull:false},loyalty_account_id:{type:D.STRING,allowNull:false},reward_id:{type:D.STRING,allowNull:false},points_cost:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("COMPLETED","CANCELLED"),allowNull:false},fulfilled_reference:D.STRING,redeemed_at:{type:D.DATE,allowNull:false}},{tableName:"loyalty_redemptions",timestamps:true,indexes:[{unique:true,fields:["loyalty_account_id","idempotency_key"]}]}); diff --git a/app/models/loyalty/loyaltyReward.model.js b/app/models/loyalty/loyaltyReward.model.js new file mode 100644 index 0000000..5b8b2f3 --- /dev/null +++ b/app/models/loyalty/loyaltyReward.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyReward",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(160),allowNull:false},description:D.TEXT,type:{type:D.ENUM("COUPON","MANUAL"),allowNull:false},points_cost:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE","ARCHIVED"),allowNull:false},starts_at:D.DATE,ends_at:D.DATE,stock_limit:D.INTEGER,per_user_limit:D.INTEGER,configuration:D.JSON},{tableName:"loyalty_rewards",timestamps:true}); diff --git a/app/models/loyalty/loyaltyTier.model.js b/app/models/loyalty/loyaltyTier.model.js new file mode 100644 index 0000000..fce7daf --- /dev/null +++ b/app/models/loyalty/loyaltyTier.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyTier",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(120),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},rank:{type:D.INTEGER,allowNull:false},qualification_threshold:{type:D.INTEGER,allowNull:false},qualification_metric:{type:D.ENUM("LIFETIME_POINTS"),allowNull:false,defaultValue:"LIFETIME_POINTS"},benefits_json:D.JSON,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"loyalty_tiers",timestamps:true}); diff --git a/app/models/loyalty/loyaltyTierHistory.model.js b/app/models/loyalty/loyaltyTierHistory.model.js new file mode 100644 index 0000000..4c93b9b --- /dev/null +++ b/app/models/loyalty/loyaltyTierHistory.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("LoyaltyTierHistory",{id:{type:D.STRING,primaryKey:true},loyalty_account_id:{type:D.STRING,allowNull:false},from_tier_id:D.STRING,to_tier_id:{type:D.STRING,allowNull:false},reason:{type:D.STRING(120),allowNull:false},effective_at:{type:D.DATE,allowNull:false}},{tableName:"loyalty_tier_history",timestamps:true}); diff --git a/app/models/loyalty/referral.model.js b/app/models/loyalty/referral.model.js new file mode 100644 index 0000000..c0d43f3 --- /dev/null +++ b/app/models/loyalty/referral.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("Referral",{id:{type:D.STRING,primaryKey:true},referrer_user_id:{type:D.STRING,allowNull:false},referred_user_id:{type:D.STRING,unique:true},referral_code:{type:D.STRING(32),allowNull:false,unique:true},status:{type:D.ENUM("PENDING","REGISTERED","QUALIFIED","REWARDED","REJECTED","EXPIRED"),allowNull:false},qualified_order_id:D.STRING,qualified_at:D.DATE,rewarded_at:D.DATE},{tableName:"referrals",timestamps:true}); diff --git a/app/models/wholesale/businessCreditLedgerEntry.model.js b/app/models/wholesale/businessCreditLedgerEntry.model.js new file mode 100644 index 0000000..c8cfd81 --- /dev/null +++ b/app/models/wholesale/businessCreditLedgerEntry.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessCreditLedgerEntry",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},business_credit_account_id:{type:D.STRING,allowNull:false},business_customer_id:{type:D.STRING,allowNull:false},order_id:D.STRING,payment_id:D.STRING,type:{type:D.ENUM("AUTHORIZATION","CAPTURE","RELEASE","PAYMENT","REFUND","ADJUSTMENT"),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},balance_delta:{type:D.DECIMAL(15,2),allowNull:false},balance_after:{type:D.DECIMAL(15,2),allowNull:false},reference:D.STRING(160),occurred_at:{type:D.DATE,allowNull:false},metadata:D.JSON},{tableName:"business_credit_ledger_entries",timestamps:true,updatedAt:false}); diff --git a/app/models/wholesale/businessSettlement.model.js b/app/models/wholesale/businessSettlement.model.js new file mode 100644 index 0000000..5673f6e --- /dev/null +++ b/app/models/wholesale/businessSettlement.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessSettlement",{id:{type:D.STRING,primaryKey:true},settlement_number:{type:D.STRING(80),allowNull:false,unique:true},business_customer_id:{type:D.STRING,allowNull:false},period_start:{type:D.DATEONLY,allowNull:false},period_end:{type:D.DATEONLY,allowNull:false},due_date:{type:D.DATEONLY,allowNull:false},status:{type:D.ENUM("DRAFT","ISSUED","PARTIALLY_PAID","PAID","OVERDUE","CANCELLED"),allowNull:false},currency:{type:D.STRING(3),allowNull:false},charge_total:{type:D.DECIMAL(15,2),allowNull:false},payment_total:{type:D.DECIMAL(15,2),allowNull:false},balance_due:{type:D.DECIMAL(15,2),allowNull:false},issued_at:D.DATE,paid_at:D.DATE},{tableName:"business_settlements",timestamps:true,indexes:[{unique:true,fields:["business_customer_id","period_start","period_end"]}]}); diff --git a/app/models/wholesale/businessSettlementItem.model.js b/app/models/wholesale/businessSettlementItem.model.js new file mode 100644 index 0000000..02b16cf --- /dev/null +++ b/app/models/wholesale/businessSettlementItem.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessSettlementItem",{id:{type:D.STRING,primaryKey:true},business_settlement_id:{type:D.STRING,allowNull:false},order_id:D.STRING,invoice_id:D.STRING,credit_ledger_entry_id:D.STRING,description:{type:D.STRING(255),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false}},{tableName:"business_settlement_items",timestamps:true}); diff --git a/app/models/wholesale/paymentAllocation.model.js b/app/models/wholesale/paymentAllocation.model.js new file mode 100644 index 0000000..8823dad --- /dev/null +++ b/app/models/wholesale/paymentAllocation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("PaymentAllocation",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},payment_id:{type:D.STRING,allowNull:false},business_settlement_id:{type:D.STRING,allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},allocated_at:{type:D.DATE,allowNull:false},allocated_by:{type:D.STRING,allowNull:false}},{tableName:"payment_allocations",timestamps:true}); diff --git a/app/routes/index.js b/app/routes/index.js index 78ccc26..ae82513 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -39,6 +39,10 @@ const commerceAdminRoutes = require("./commerce/admin.routes"); const logisticsAdminRoutes = require("./logistics/admin.routes"); const logisticsRiderRoutes = require("./logistics/rider.routes"); const logisticsTrackingRoutes = require("./logistics/tracking.routes"); +const loyaltyCustomerRoutes = require("./loyalty/customer.routes"); +const loyaltyAdminRoutes = require("./loyalty/admin.routes"); +const wholesaleCustomerRoutes = require("./wholesale/customer.routes"); +const wholesaleAdminRoutes = require("./wholesale/admin.routes"); const router = express.Router(); @@ -70,5 +74,9 @@ router.use("/admin", commerceAdminRoutes); router.use("/admin", logisticsAdminRoutes); router.use("/rider", logisticsRiderRoutes); router.use("/", logisticsTrackingRoutes); +router.use("/", loyaltyCustomerRoutes); +router.use("/admin", loyaltyAdminRoutes); +router.use("/", wholesaleCustomerRoutes); +router.use("/admin", wholesaleAdminRoutes); module.exports = router; diff --git a/app/routes/loyalty/admin.routes.js b/app/routes/loyalty/admin.routes.js new file mode 100644 index 0000000..2f8a019 --- /dev/null +++ b/app/routes/loyalty/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/loyalty/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/loyaltyWholesale.schemas"),idempotency=(req,res,next)=>req.get("Idempotency-Key")?next():res.status(400).json({success:false,error:{code:"IDEMPOTENCY_KEY_REQUIRED",message:"Idempotency-Key is required"}});r.use(authenticate);r.get("/loyalty/accounts",checkPermission("loyalty.accounts.read",{custom:true}),c.accounts);r.post("/loyalty/adjustments",checkPermission("loyalty.points.adjust",{custom:true}),idempotency,validate(s.adjust),c.adjust);r.get("/loyalty/tiers",checkPermission("loyalty.manage",{custom:true}),c.tiers);r.post("/loyalty/tiers",checkPermission("loyalty.manage",{custom:true}),validate(s.tier),c.createTier);r.get("/loyalty/rules",checkPermission("loyalty.manage",{custom:true}),c.rules);r.post("/loyalty/rules",checkPermission("loyalty.manage",{custom:true}),validate(s.rule),c.createRule);r.get("/loyalty/rewards",checkPermission("loyalty.manage",{custom:true}),c.rewards);r.post("/loyalty/rewards",checkPermission("loyalty.manage",{custom:true}),validate(s.reward),c.createReward);r.get("/loyalty/referrals",checkPermission("loyalty.referrals.read",{custom:true}),c.referrals);module.exports=r; diff --git a/app/routes/loyalty/customer.routes.js b/app/routes/loyalty/customer.routes.js new file mode 100644 index 0000000..deb2bfa --- /dev/null +++ b/app/routes/loyalty/customer.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/loyalty/customer.controller"),{authenticate}=require("../../middleware/auth.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/loyaltyWholesale.schemas");r.use(authenticate);r.get("/loyalty",c.summary);r.get("/loyalty/history",c.history);r.get("/loyalty/tiers",c.tiers);r.get("/loyalty/rewards",c.rewards);r.post("/loyalty/redeem",validate(s.redeem),c.redeem);r.get("/loyalty/vouchers",c.vouchers);r.get("/loyalty/referral",c.referral);r.post("/loyalty/referral/claim",validate(s.referral),c.claimReferral);module.exports=r; diff --git a/app/routes/wholesale/admin.routes.js b/app/routes/wholesale/admin.routes.js new file mode 100644 index 0000000..dd5ce79 --- /dev/null +++ b/app/routes/wholesale/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/wholesale/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/loyaltyWholesale.schemas");r.use(authenticate);r.post("/wholesale/credit/transactions",checkPermission("wholesale.credit.manage",{custom:true}),validate(s.credit),c.credit);r.get("/wholesale/businesses/:businessId/credit",checkPermission("wholesale.credit.read",{custom:true}),c.creditHistory);r.get("/wholesale/settlements",checkPermission("wholesale.settlements.read",{custom:true}),c.settlements);r.post("/wholesale/settlements",checkPermission("wholesale.settlements.manage",{custom:true}),validate(s.settlement),c.generate);r.post("/wholesale/settlements/:id/issue",checkPermission("wholesale.settlements.manage",{custom:true}),c.action("ISSUED"));r.post("/wholesale/settlements/:id/mark-paid",checkPermission("wholesale.settlements.manage",{custom:true}),c.action("PAID"));module.exports=r; diff --git a/app/routes/wholesale/customer.routes.js b/app/routes/wholesale/customer.routes.js new file mode 100644 index 0000000..24d6f0e --- /dev/null +++ b/app/routes/wholesale/customer.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/wholesale/customer.controller"),purchase=require("../../controllers/wholesale/creditPurchase.controller"),{authenticate}=require("../../middleware/auth.middleware"),{authorizedAccountType}=require("../../middleware/permission.middleware"),guard=[authenticate,authorizedAccountType(["business_customer"])];r.get("/business/dashboard",guard,c.dashboard);r.get("/business/credit",guard,c.credit);r.get("/business/settlements",guard,c.settlements);r.get("/business/settlements/:id",guard,c.settlement);r.get("/business/orders/recent",guard,c.orders);r.post("/business/orders/:id/use-credit",guard,purchase.purchase);module.exports=r; diff --git a/app/services/commerce/payment.service.js b/app/services/commerce/payment.service.js index 95feaa2..98bc713 100644 --- a/app/services/commerce/payment.service.js +++ b/app/services/commerce/payment.service.js @@ -1,6 +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}; +const crypto=require("crypto"),db=require("../../models"),money=require("../pricing/money"),inventory=require("../inventory/inventory.service"),loyalty=require("../loyalty/loyalty.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 issueInvoice(order,transaction){let invoice=await db.Invoice.findOne({where:{order_id:order.id},transaction,lock:transaction.LOCK.UPDATE});if(!invoice){const n=await nextSequence("invoices",transaction);invoice=await 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});}await loyalty.earnPurchase(order,transaction);return invoice;} 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}; diff --git a/app/services/loyalty/loyalty.service.js b/app/services/loyalty/loyalty.service.js new file mode 100644 index 0000000..5604e27 --- /dev/null +++ b/app/services/loyalty/loyalty.service.js @@ -0,0 +1,11 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),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 accountFor(userId,transaction){let account=await db.LoyaltyAccount.findOne({where:{user_id:userId},transaction,lock:transaction?.LOCK.UPDATE});if(!account)account=await db.LoyaltyAccount.create({id:crypto.randomUUID(),user_id:userId,status:"ACTIVE"},{transaction});if(account.status!=="ACTIVE")throw fault("Loyalty account is unavailable","LOYALTY_UNAVAILABLE",409);return account;} +async function post({userId,eventId,type,sourceType,sourceId,points,expiresAt,descriptionCode,metadata},external){const run=async transaction=>{const existing=await db.LoyaltyLedgerEntry.findOne({where:{event_id:eventId},transaction,lock:transaction.LOCK.UPDATE});if(existing)return{entry:existing,idempotent:true};const account=await accountFor(userId,transaction);let available=account.available_points,debt=account.points_debt,spendable=0;if(points>0){const applied=Math.min(points,debt);debt-=applied;spendable=points-applied;available+=spendable;}else{const debit=-points;if(type==="REVERSAL"){const applied=Math.min(available,debit);available-=applied;debt+=debit-applied;}else if(available0)await db.LoyaltyPointAllocation.create({id:crypto.randomUUID(),earn_ledger_id:entry.id,loyalty_account_id:account.id,original_points:spendable,remaining_points:spendable,expires_at:expiresAt},{transaction});await evaluateTier(account,transaction);return{entry,idempotent:false};};return external?run(external):db.sequelize.transaction(run);} +async function evaluateTier(account,transaction){const tier=await db.LoyaltyTier.findOne({where:{status:"ACTIVE",qualification_threshold:{[Op.lte]:account.lifetime_points_earned}},order:[["rank","DESC"]],transaction});if(tier&&tier.id!==account.current_tier_id){await db.LoyaltyTierHistory.create({id:crypto.randomUUID(),loyalty_account_id:account.id,from_tier_id:account.current_tier_id,to_tier_id:tier.id,reason:"LIFETIME_POINTS",effective_at:new Date()},{transaction});await account.update({current_tier_id:tier.id},{transaction});}return tier;} +async function rule(sourceType,at,transaction){return db.LoyaltyEarnRule.findOne({where:{source_type:sourceType,status:"ACTIVE",effective_from:{[Op.lte]:at},[Op.or]:[{effective_to:null},{effective_to:{[Op.gt]:at}}]},order:[["effective_from","DESC"]],transaction});} +async function earnPurchase(order,transaction){const r=await rule("PURCHASE",new Date(),transaction);if(!r||order.payment_status!=="PAID")return null;const eligible=money.subtractFloor(String(order.subtotal),String(order.discount_total)),units=money.parse(eligible)/money.parse(String(r.amount_unit)),raw=Number(units)*Number(r.points_per_amount||0),points=r.maximum_points?Math.min(raw,r.maximum_points):raw;if(!points||r.minimum_amount&&money.parse(eligible){const account=await accountFor(userId,transaction),prior=await db.LoyaltyRedemption.findOne({where:{loyalty_account_id:account.id,idempotency_key:idempotencyKey},transaction,lock:transaction.LOCK.UPDATE});if(prior)return{redemption:prior,idempotent:true};const reward=await db.LoyaltyReward.findOne({where:{id:rewardId,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:new Date()}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:new Date()}}]}]},transaction,lock:transaction.LOCK.UPDATE});if(!reward)throw fault("Reward unavailable","REWARD_UNAVAILABLE",409);const count=await db.LoyaltyRedemption.count({where:{loyalty_account_id:account.id,reward_id:reward.id,status:"COMPLETED"},transaction}),total=await db.LoyaltyRedemption.count({where:{reward_id:reward.id,status:"COMPLETED"},transaction});if(reward.per_user_limit&&count>=reward.per_user_limit||reward.stock_limit&&total>=reward.stock_limit)throw fault("Reward limit reached","REWARD_LIMIT",409);if(account.available_points{const value=(money.parse(eligibleAmount)/money.parse(amountUnit))*BigInt(pointsPerAmount);return Number(maximumPoints?value>BigInt(maximumPoints)?BigInt(maximumPoints):value:value);};module.exports={accountFor,post,evaluateTier,earnPurchase,earnReview,reversePurchase,redeem,qualifyReferral,calculatePurchasePoints}; diff --git a/app/services/loyalty/reconciliation.service.js b/app/services/loyalty/reconciliation.service.js new file mode 100644 index 0000000..ee6b41e --- /dev/null +++ b/app/services/loyalty/reconciliation.service.js @@ -0,0 +1 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),loyalty=require("./loyalty.service");async function birthdays(limit=100){const today=new Date(),profiles=await db.Profile.findAll({where:{dob:{[Op.ne]:null}},limit});let count=0;for(const profile of profiles){const dob=new Date(profile.dob);if(dob.getUTCMonth()!==today.getUTCMonth()||dob.getUTCDate()!==today.getUTCDate())continue;const rule=await db.LoyaltyEarnRule.findOne({where:{source_type:"BIRTHDAY",status:"ACTIVE"}});if(rule?.fixed_points){await loyalty.post({userId:profile.user_id,eventId:`LOYALTY:BIRTHDAY:${profile.user_id}:${today.getUTCFullYear()}`,type:"EARN",sourceType:"BIRTHDAY",sourceId:String(today.getUTCFullYear()),points:rule.fixed_points,descriptionCode:"ANNUAL_BIRTHDAY"});count++;}}return count;}async function expire(limit=100){const allocations=await db.LoyaltyPointAllocation.findAll({where:{remaining_points:{[Op.gt]:0},expires_at:{[Op.lte]:new Date()}},limit,order:[["expires_at","ASC"]]});for(const allocation of allocations)await db.sequelize.transaction(async transaction=>{const row=await db.LoyaltyPointAllocation.findByPk(allocation.id,{transaction,lock:transaction.LOCK.UPDATE});if(!row||row.remaining_points<=0)return;const account=await db.LoyaltyAccount.findByPk(row.loyalty_account_id,{transaction});await loyalty.post({userId:account.user_id,eventId:`LOYALTY:EXPIRE:${row.id}`,type:"EXPIRE",sourceType:"POINT_EXPIRY",sourceId:row.id,points:-row.remaining_points,descriptionCode:"POINTS_EXPIRED"},transaction);await row.update({remaining_points:0},{transaction});});return allocations.length;}module.exports={birthdays,expire}; diff --git a/app/services/loyalty/referral.service.js b/app/services/loyalty/referral.service.js new file mode 100644 index 0000000..0863ea3 --- /dev/null +++ b/app/services/loyalty/referral.service.js @@ -0,0 +1 @@ +const crypto=require("crypto"),db=require("../../models");const code=()=>crypto.randomBytes(9).toString("base64url").toUpperCase();async function getOrCreate(userId){let row=await db.Referral.findOne({where:{referrer_user_id:userId,referred_user_id:null}});if(!row)row=await db.Referral.create({id:crypto.randomUUID(),referrer_user_id:userId,referral_code:code(),status:"PENDING"});return row;}async function claim({code:raw,userId}){const row=await db.sequelize.transaction(async transaction=>{const referral=await db.Referral.findOne({where:{referral_code:raw.trim().toUpperCase()},transaction,lock:transaction.LOCK.UPDATE});if(!referral||referral.referrer_user_id===userId)throw Object.assign(new Error("Referral is invalid"),{code:"INVALID_REFERRAL",status:400});const claimed=await db.Referral.findOne({where:{referred_user_id:userId},transaction});if(claimed)throw Object.assign(new Error("Referral already claimed"),{code:"REFERRAL_ALREADY_CLAIMED",status:409});await referral.update({referred_user_id:userId,status:"REGISTERED"},{transaction});return referral;});return row;}module.exports={getOrCreate,claim,code}; diff --git a/app/services/wholesale/credit.service.js b/app/services/wholesale/credit.service.js new file mode 100644 index 0000000..f060837 --- /dev/null +++ b/app/services/wholesale/credit.service.js @@ -0,0 +1 @@ +const crypto=require("crypto"),db=require("../../models"),money=require("../pricing/money");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});async function balance(accountId,transaction){const last=await db.BusinessCreditLedgerEntry.findOne({where:{business_credit_account_id:accountId},order:[["occurred_at","DESC"],["createdAt","DESC"]],transaction,lock:transaction&&transaction.LOCK.UPDATE});return last?String(last.balance_after):"0.00";}async function post({businessCustomerId,eventId,type,amount,currency,orderId,paymentId,reference,actorUserId},external){const run=async transaction=>{const prior=await db.BusinessCreditLedgerEntry.findOne({where:{event_id:eventId},transaction,lock:transaction.LOCK.UPDATE});if(prior)return{entry:prior,idempotent:true};const business=await db.BusinessCustomer.findOne({where:{business_customer_id:businessCustomerId,status:"ACTIVE"},transaction}),account=business&&await db.BusinessCreditAccount.findOne({where:{business_profile_id:businessCustomerId,status:"ACTIVE",currency},transaction,lock:transaction.LOCK.UPDATE});if(!account)throw fault("Active matching credit account required","CREDIT_UNAVAILABLE",409);const current=await balance(account.id,transaction),positive=["AUTHORIZATION","CAPTURE","ADJUSTMENT"].includes(type),delta=positive?String(amount):`-${String(amount)}`,next=positive?money.add(current,String(amount)):money.subtractFloor(current,String(amount));if(positive&&money.parse(next)>money.parse(account.credit_limit))throw fault("Insufficient business credit","INSUFFICIENT_CREDIT",409);const entry=await db.BusinessCreditLedgerEntry.create({id:crypto.randomUUID(),event_id:eventId,business_credit_account_id:account.id,business_customer_id:businessCustomerId,order_id:orderId,payment_id:paymentId,type,amount:String(amount),currency,balance_delta:delta,balance_after:next,reference,occurred_at:new Date(),metadata:{actorUserId}},{transaction});return{entry,idempotent:false,creditLimit:String(account.credit_limit),usedCredit:next,availableCredit:money.subtractFloor(account.credit_limit,next)};};return external?run(external):db.sequelize.transaction(run);}module.exports={balance,post}; diff --git a/app/services/wholesale/creditPurchase.service.js b/app/services/wholesale/creditPurchase.service.js new file mode 100644 index 0000000..cca24a9 --- /dev/null +++ b/app/services/wholesale/creditPurchase.service.js @@ -0,0 +1 @@ +const crypto=require("crypto"),db=require("../../models"),credit=require("./credit.service"),inventory=require("../inventory/inventory.service"),payments=require("../commerce/payment.service");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});async function purchase({orderId,userId,eventId}){return db.sequelize.transaction(async transaction=>{const business=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"},transaction}),order=business&&await db.Order.findOne({where:{id:orderId,user_id:userId,business_customer_id:business.business_customer_id,status:"PENDING_PAYMENT"},include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Eligible wholesale order not found","ORDER_NOT_CREDIT_ELIGIBLE",409);const result=await credit.post({businessCustomerId:business.business_customer_id,eventId,type:"CAPTURE",amount:String(order.grand_total),currency:order.currency,orderId:order.id,reference:order.order_number},transaction);let payment=await db.Payment.findOne({where:{order_id:order.id,provider:"INTERNAL_CREDIT"},transaction});if(!payment)payment=await db.Payment.create({id:crypto.randomUUID(),order_id:order.id,payment_reference:`CREDIT-${order.order_number}`,provider:"INTERNAL_CREDIT",method:"BUSINESS_CREDIT",status:"PAID",currency:order.currency,amount:order.grand_total,paid_at:new Date()},{transaction});if(!result.idempotent){for(const item of [...order.items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id)))await inventory.consumeReservation({reservationKey:item.reservation_key,requestId:eventId,transaction});await order.update({status:"PAID",payment_status:"PAID"},{transaction});await payments.issueInvoice(order,transaction);}return{order,payment,credit:result,idempotent:result.idempotent};});}module.exports={purchase}; diff --git a/app/services/wholesale/settlement.service.js b/app/services/wholesale/settlement.service.js new file mode 100644 index 0000000..88c3857 --- /dev/null +++ b/app/services/wholesale/settlement.service.js @@ -0,0 +1 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),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 generate({businessCustomerId,periodStart,periodEnd,currency}){return db.sequelize.transaction(async transaction=>{const prior=await db.BusinessSettlement.findOne({where:{business_customer_id:businessCustomerId,period_start:periodStart,period_end:periodEnd},transaction,lock:transaction.LOCK.UPDATE});if(prior)return{settlement:prior,idempotent:true};const business=await db.BusinessCustomer.findByPk(businessCustomerId,{include:[{model:db.SettlementTerm,as:"settlementTerm"}],transaction});if(!business?.settlementTerm?.is_active)throw fault("Settlement term unavailable","SETTLEMENT_TERM_REQUIRED",409);const entries=await db.BusinessCreditLedgerEntry.findAll({where:{business_customer_id:businessCustomerId,currency,occurred_at:{[Op.between]:[new Date(periodStart),new Date(`${periodEnd}T23:59:59.999Z`)]}},transaction}),charges=entries.filter(x=>["CAPTURE","AUTHORIZATION"].includes(x.type)),payments=entries.filter(x=>["PAYMENT","REFUND","RELEASE"].includes(x.type)),chargeTotal=charges.reduce((v,x)=>money.add(v,String(x.amount)),"0.00"),paymentTotal=payments.reduce((v,x)=>money.add(v,String(x.amount)),"0.00"),due=new Date(`${periodEnd}T00:00:00Z`);due.setUTCDate(due.getUTCDate()+business.settlementTerm.days);const settlement=await db.BusinessSettlement.create({id:crypto.randomUUID(),settlement_number:`STM-${new Date().getUTCFullYear()}-${pad(await nextSequence("business-settlements",transaction),6)}`,business_customer_id:businessCustomerId,period_start:periodStart,period_end:periodEnd,due_date:due.toISOString().slice(0,10),status:"DRAFT",currency,charge_total:chargeTotal,payment_total:paymentTotal,balance_due:money.subtractFloor(chargeTotal,paymentTotal)},{transaction});await db.BusinessSettlementItem.bulkCreate(entries.map(x=>({id:crypto.randomUUID(),business_settlement_id:settlement.id,order_id:x.order_id,credit_ledger_entry_id:x.id,description:x.type,amount:x.type==="PAYMENT"?`-${x.amount}`:x.amount,currency})),{transaction});return{settlement,idempotent:false};});}const transitions={DRAFT:["ISSUED","CANCELLED"],ISSUED:["PARTIALLY_PAID","PAID","OVERDUE"],PARTIALLY_PAID:["PAID","OVERDUE"],OVERDUE:["PARTIALLY_PAID","PAID"],PAID:[],CANCELLED:[]};async function transition(id,to,actor){return db.sequelize.transaction(async transaction=>{const row=await db.BusinessSettlement.findByPk(id,{transaction,lock:transaction.LOCK.UPDATE});if(!row||!transitions[row.status]?.includes(to))throw fault("Illegal settlement transition","ILLEGAL_SETTLEMENT_TRANSITION",409);await row.update({status:to,...(to==="ISSUED"&&{issued_at:new Date()}),...(to==="PAID"&&{paid_at:new Date(),balance_due:"0.00"})},{transaction});return row;});}module.exports={generate,transition,transitions}; diff --git a/app/validation/loyaltyWholesale.schemas.js b/app/validation/loyaltyWholesale.schemas.js new file mode 100644 index 0000000..f020039 --- /dev/null +++ b/app/validation/loyaltyWholesale.schemas.js @@ -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(180),money=z.string().regex(/^\d+(\.\d{1,2})?$/);exports.redeem=wrap(z.object({rewardId:id}));exports.referral=wrap(z.object({code:z.string().trim().min(8).max(32).transform(x=>x.toUpperCase())}));exports.adjust=wrap(z.object({userId:id,pointsDelta:z.number().int().refine(x=>x!==0),reasonCode:z.string().min(1).max(80),note:z.string().max(500).optional()}));exports.tier=wrap(z.object({code:z.string().min(1).max(60),name:z.string().min(1).max(120),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),rank:z.number().int().nonnegative(),qualificationThreshold:z.number().int().nonnegative(),benefits:z.record(z.string(),z.unknown()).optional(),sortOrder:z.number().int().default(0)}));exports.rule=wrap(z.object({sourceType:z.enum(["PURCHASE","VERIFIED_REVIEW","REFERRAL","BIRTHDAY"]),status:z.enum(["ACTIVE","INACTIVE"]),pointsPerAmount:z.number().int().positive().optional(),amountUnit:money.optional(),fixedPoints:z.number().int().positive().optional(),minimumAmount:money.optional(),maximumPoints:z.number().int().positive().optional(),expiryDays:z.number().int().positive().optional(),effectiveFrom:z.coerce.date(),effectiveTo:z.coerce.date().optional()}));exports.reward=wrap(z.object({code:z.string().min(1).max(60),name:z.string().min(1).max(160),description:z.string().optional(),type:z.enum(["COUPON","MANUAL"]),pointsCost:z.number().int().positive(),status:z.enum(["ACTIVE","INACTIVE","ARCHIVED"]),startsAt:z.coerce.date().optional(),endsAt:z.coerce.date().optional(),stockLimit:z.number().int().positive().optional(),perUserLimit:z.number().int().positive().optional(),configuration:z.record(z.string(),z.unknown()).default({})}));exports.credit=wrap(z.object({businessCustomerId:id,type:z.enum(["AUTHORIZATION","CAPTURE","RELEASE","PAYMENT","REFUND","ADJUSTMENT"]),amount:money,currency:z.string().length(3),orderId:id.optional(),paymentId:id.optional(),reference:z.string().max(160).optional()}));exports.settlement=wrap(z.object({businessCustomerId:id,periodStart:z.string().date(),periodEnd:z.string().date(),currency:z.string().length(3)})); diff --git a/cron/index.js b/cron/index.js index 4318c8d..3a6e735 100644 --- a/cron/index.js +++ b/cron/index.js @@ -12,11 +12,12 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron"); const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron"); const startCheckoutExpiryCron = require("./checkoutExpiry.cron"); +const startLoyaltyReconciliationCron = require("./loyaltyReconciliation.cron"); function startAllCrons() { console.log("Starting Cron Jobs..."); - const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron()]; + const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron()]; return async () => { for (const task of tasks) { diff --git a/cron/loyaltyReconciliation.cron.js b/cron/loyaltyReconciliation.cron.js new file mode 100644 index 0000000..e9d1dc3 --- /dev/null +++ b/cron/loyaltyReconciliation.cron.js @@ -0,0 +1 @@ +const cron=require("node-cron"),jobs=require("../app/services/loyalty/reconciliation.service");module.exports=()=>cron.schedule("15 1 * * *",async()=>{const limit=Number(process.env.LOYALTY_RECONCILIATION_BATCH_SIZE||100);try{await jobs.birthdays(limit);await jobs.expire(limit);}catch(error){console.error("Loyalty reconciliation failed",{name:error.name,message:error.message});}},{noOverlap:true}); diff --git a/migrations/20260909090000-phase-9-loyalty-wholesale.js b/migrations/20260909090000-phase-9-loyalty-wholesale.js new file mode 100644 index 0000000..5f09722 --- /dev/null +++ b/migrations/20260909090000-phase-9-loyalty-wholesale.js @@ -0,0 +1,2 @@ +"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},INT={type:S.INTEGER,allowNull:false,defaultValue:0},DEC={type:S.DECIMAL(15,2)},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("loyalty_accounts",{id:ID,user_id:{type:S.STRING,allowNull:false,unique:true},status:REQ,current_tier_id:STR,lifetime_points_earned:INT,lifetime_points_redeemed:INT,available_points:INT,pending_points:INT,points_debt:INT,last_activity_at:DATE,...TS});await q.createTable("loyalty_tiers",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:REQ,status:REQ,rank:{type:S.INTEGER,allowNull:false},qualification_threshold:{type:S.INTEGER,allowNull:false},qualification_metric:REQ,benefits_json:{type:S.JSON},sort_order:INT,...TS});await q.addIndex("loyalty_tiers",["status","rank"]);await q.createTable("loyalty_tier_history",{id:ID,loyalty_account_id:REQ,from_tier_id:STR,to_tier_id:REQ,reason:REQ,effective_at:{type:S.DATE,allowNull:false},...TS});await q.createTable("loyalty_earn_rules",{id:ID,source_type:REQ,status:REQ,points_per_amount:{type:S.INTEGER},amount_unit:DEC,fixed_points:{type:S.INTEGER},minimum_amount:DEC,maximum_points:{type:S.INTEGER},expiry_days:{type:S.INTEGER},effective_from:{type:S.DATE,allowNull:false},effective_to:DATE,...TS});await q.createTable("loyalty_ledger_entries",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},loyalty_account_id:REQ,type:REQ,source_type:REQ,source_id:STR,points_delta:{type:S.INTEGER,allowNull:false},balance_after:{type:S.INTEGER,allowNull:false},status:REQ,expires_at:DATE,description_code:STR,metadata:{type:S.JSON},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("loyalty_ledger_entries",["loyalty_account_id","occurred_at"]);await q.addIndex("loyalty_ledger_entries",["source_type","source_id"]);await q.addIndex("loyalty_ledger_entries",["expires_at"]);await q.createTable("loyalty_point_allocations",{id:ID,earn_ledger_id:REQ,loyalty_account_id:REQ,original_points:{type:S.INTEGER,allowNull:false},remaining_points:{type:S.INTEGER,allowNull:false},expires_at:DATE,...TS});await q.createTable("loyalty_rewards",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:REQ,description:{type:S.TEXT},type:REQ,points_cost:{type:S.INTEGER,allowNull:false},status:REQ,starts_at:DATE,ends_at:DATE,stock_limit:{type:S.INTEGER},per_user_limit:{type:S.INTEGER},configuration:{type:S.JSON},...TS});await q.addIndex("loyalty_rewards",["status","starts_at","ends_at"]);await q.createTable("loyalty_redemptions",{id:ID,redemption_number:{type:S.STRING(80),allowNull:false,unique:true},idempotency_key:{type:S.STRING(160),allowNull:false},loyalty_account_id:REQ,reward_id:REQ,points_cost:{type:S.INTEGER,allowNull:false},status:REQ,fulfilled_reference:STR,redeemed_at:{type:S.DATE,allowNull:false},...TS});await q.addConstraint("loyalty_redemptions",{fields:["loyalty_account_id","idempotency_key"],type:"unique",name:"uq_loyalty_redemption_idempotency"});await q.createTable("referrals",{id:ID,referrer_user_id:REQ,referred_user_id:{type:S.STRING,unique:true},referral_code:{type:S.STRING(32),allowNull:false,unique:true},status:REQ,qualified_order_id:STR,qualified_at:DATE,rewarded_at:DATE,...TS});await q.addIndex("referrals",["referrer_user_id","status"]);await q.createTable("customer_coupon_entitlements",{id:ID,user_id:REQ,coupon_id:REQ,source_type:REQ,source_id:REQ,status:REQ,issued_at:{type:S.DATE,allowNull:false},used_at:DATE,expires_at:DATE,...TS});await q.addConstraint("customer_coupon_entitlements",{fields:["user_id","coupon_id","source_type","source_id"],type:"unique",name:"uq_coupon_entitlement_source"}); +await q.createTable("business_credit_ledger_entries",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},business_credit_account_id:REQ,business_customer_id:REQ,order_id:STR,payment_id:STR,type:REQ,amount:{...DEC,allowNull:false},currency:{type:S.STRING(3),allowNull:false},balance_delta:{...DEC,allowNull:false},balance_after:{...DEC,allowNull:false},reference:STR,occurred_at:{type:S.DATE,allowNull:false},metadata:{type:S.JSON},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("business_credit_ledger_entries",["business_credit_account_id","occurred_at"]);await q.createTable("business_settlements",{id:ID,settlement_number:{type:S.STRING(80),allowNull:false,unique:true},business_customer_id:REQ,period_start:{type:S.DATEONLY,allowNull:false},period_end:{type:S.DATEONLY,allowNull:false},due_date:{type:S.DATEONLY,allowNull:false},status:REQ,currency:{type:S.STRING(3),allowNull:false},charge_total:{...DEC,allowNull:false},payment_total:{...DEC,allowNull:false},balance_due:{...DEC,allowNull:false},issued_at:DATE,paid_at:DATE,...TS});await q.addConstraint("business_settlements",{fields:["business_customer_id","period_start","period_end"],type:"unique",name:"uq_business_settlement_period"});await q.addIndex("business_settlements",["business_customer_id","status","due_date"]);await q.createTable("business_settlement_items",{id:ID,business_settlement_id:REQ,order_id:STR,invoice_id:STR,credit_ledger_entry_id:STR,description:REQ,amount:{...DEC,allowNull:false},currency:{type:S.STRING(3),allowNull:false},...TS});await q.createTable("payment_allocations",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},payment_id:REQ,business_settlement_id:REQ,amount:{...DEC,allowNull:false},currency:{type:S.STRING(3),allowNull:false},allocated_at:{type:S.DATE,allowNull:false},allocated_by:REQ,...TS});await q.addIndex("payment_allocations",["payment_id","business_settlement_id"]);},async down(){throw new Error("Phase 9 migration is forward-only; restore from a verified backup instead");}}; diff --git a/tests/unit/loyalty-wholesale.validation.test.js b/tests/unit/loyalty-wholesale.validation.test.js new file mode 100644 index 0000000..f45b0ab --- /dev/null +++ b/tests/unit/loyalty-wholesale.validation.test.js @@ -0,0 +1 @@ +const s=require("../../app/validation/loyaltyWholesale.schemas"),settlements=require("../../app/services/wholesale/settlement.service"),{checkPermission}=require("../../app/middleware/permission.middleware");const input=body=>({body,params:{},query:{}});describe("Phase 9 security and state boundaries",()=>{test("client cannot submit a loyalty balance",()=>expect(()=>s.redeem.parse(input({rewardId:"r",availablePoints:999999}))).toThrow());test("credit amount must be decimal string",()=>expect(()=>s.credit.parse(input({businessCustomerId:"b",type:"CAPTURE",amount:1.1,currency:"LKR"}))).toThrow());test("settlement terminal state has no transitions",()=>expect(settlements.transitions.PAID).toEqual([]));test("admin without loyalty adjustment permission is denied",()=>{const req={user:{accountType:"admin",permissions:[]},method:"POST"},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();checkPermission("loyalty.points.adjust",{custom:true})(req,res,next);expect(res.status).toHaveBeenCalledWith(403);});}); diff --git a/tests/unit/loyalty.policy.test.js b/tests/unit/loyalty.policy.test.js new file mode 100644 index 0000000..cb82bf5 --- /dev/null +++ b/tests/unit/loyalty.policy.test.js @@ -0,0 +1 @@ +const loyalty=require("../../app/services/loyalty/loyalty.service"),referrals=require("../../app/services/loyalty/referral.service");describe("Phase 9 loyalty policies",()=>{test("money-to-points floors incomplete units without float arithmetic",()=>expect(loyalty.calculatePurchasePoints("2499.99","1000.00",10)).toBe(20));test("purchase points respect configured maximum",()=>expect(loyalty.calculatePurchasePoints("99999.00","100.00",10,500)).toBe(500));test("referral codes are opaque and normalized",()=>{const code=referrals.code();expect(code).toMatch(/^[A-Z0-9_-]+$/);expect(code.length).toBeGreaterThanOrEqual(8);});}); -- 2.52.0 From f920ca8920a9f3a8c01e32ae72636450667af855 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Wed, 9 Sep 2026 16:07:31 +0530 Subject: [PATCH 4/4] feat: Implement Phase 10 support and recommendations features - Added models for support messages, SLA policies, tickets, ticket events, and ticket links. - Created routes for help, help admin, newsletter, recommendations, and support for both customer and admin. - Developed services for help, marketing (newsletter), and recommendations. - Introduced support service for ticket management, including creation, replies, transitions, and attachments. - Added validation schemas for support recommendations and ticket management. - Implemented a cron job for support reconciliation and recommendation cleanup. - Created migration for new support and recommendation database tables. - Added unit tests for validation and policy checks related to Phase 10 features. --- .env.sample | 3 ++ Documentation/API_NEWSLETTER.md | 9 ++++ Documentation/API_RECOMMENDATIONS.md | 14 +++++ Documentation/API_SUPPORT_HELP.md | 29 ++++++++++ Documentation/CURRENT_BACKEND_STATUS.md | 18 +++++++ .../PHASE_10_SUPPORT_RECOMMENDATIONS.md | 53 +++++++++++++++++++ app/config/env.config.js | 2 + app/constants/permissions.js | 1 + app/controllers/help.controller.js | 1 + app/controllers/helpAdmin.controller.js | 1 + app/controllers/newsletter.controller.js | 1 + app/controllers/recommendation.controller.js | 1 + app/controllers/support/admin.controller.js | 1 + .../support/customer.controller.js | 1 + app/models/commerce/orderItem.model.js | 2 +- app/models/help/helpArticle.model.js | 1 + .../help/helpArticleTranslation.model.js | 1 + app/models/help/helpCategory.model.js | 1 + .../help/helpCategoryTranslation.model.js | 1 + app/models/index.js | 14 +++++ .../marketing/newsletterSubscription.model.js | 1 + .../productInteractionEvent.model.js | 1 + app/models/support/supportAttachment.model.js | 1 + app/models/support/supportCategory.model.js | 1 + app/models/support/supportEscalation.model.js | 1 + app/models/support/supportMessage.model.js | 1 + app/models/support/supportSlaPolicy.model.js | 1 + app/models/support/supportTicket.model.js | 1 + .../support/supportTicketEvent.model.js | 1 + app/models/support/supportTicketLink.model.js | 1 + app/routes/help.routes.js | 1 + app/routes/helpAdmin.routes.js | 1 + app/routes/index.js | 12 +++++ app/routes/newsletter.routes.js | 1 + app/routes/recommendation.routes.js | 1 + app/routes/support/admin.routes.js | 1 + app/routes/support/customer.routes.js | 1 + app/services/help/help.service.js | 6 +++ app/services/marketing/newsletter.service.js | 1 + .../recommendation/recommendation.service.js | 6 +++ app/services/support/support.service.js | 16 ++++++ .../supportRecommendation.schemas.js | 1 + cron/index.js | 3 +- cron/supportReconciliation.cron.js | 1 + ...100000-phase-10-support-recommendations.js | 16 ++++++ .../phase10-support-recommendation.test.js | 20 +++++++ 46 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 Documentation/API_NEWSLETTER.md create mode 100644 Documentation/API_RECOMMENDATIONS.md create mode 100644 Documentation/API_SUPPORT_HELP.md create mode 100644 Documentation/PHASE_10_SUPPORT_RECOMMENDATIONS.md create mode 100644 app/controllers/help.controller.js create mode 100644 app/controllers/helpAdmin.controller.js create mode 100644 app/controllers/newsletter.controller.js create mode 100644 app/controllers/recommendation.controller.js create mode 100644 app/controllers/support/admin.controller.js create mode 100644 app/controllers/support/customer.controller.js create mode 100644 app/models/help/helpArticle.model.js create mode 100644 app/models/help/helpArticleTranslation.model.js create mode 100644 app/models/help/helpCategory.model.js create mode 100644 app/models/help/helpCategoryTranslation.model.js create mode 100644 app/models/marketing/newsletterSubscription.model.js create mode 100644 app/models/recommendation/productInteractionEvent.model.js create mode 100644 app/models/support/supportAttachment.model.js create mode 100644 app/models/support/supportCategory.model.js create mode 100644 app/models/support/supportEscalation.model.js create mode 100644 app/models/support/supportMessage.model.js create mode 100644 app/models/support/supportSlaPolicy.model.js create mode 100644 app/models/support/supportTicket.model.js create mode 100644 app/models/support/supportTicketEvent.model.js create mode 100644 app/models/support/supportTicketLink.model.js create mode 100644 app/routes/help.routes.js create mode 100644 app/routes/helpAdmin.routes.js create mode 100644 app/routes/newsletter.routes.js create mode 100644 app/routes/recommendation.routes.js create mode 100644 app/routes/support/admin.routes.js create mode 100644 app/routes/support/customer.routes.js create mode 100644 app/services/help/help.service.js create mode 100644 app/services/marketing/newsletter.service.js create mode 100644 app/services/recommendation/recommendation.service.js create mode 100644 app/services/support/support.service.js create mode 100644 app/validation/supportRecommendation.schemas.js create mode 100644 cron/supportReconciliation.cron.js create mode 100644 migrations/20260909100000-phase-10-support-recommendations.js create mode 100644 tests/unit/phase10-support-recommendation.test.js diff --git a/.env.sample b/.env.sample index 0a38a8b..fc62cee 100644 --- a/.env.sample +++ b/.env.sample @@ -75,3 +75,6 @@ EMAIL_VERIFICATION_TTL_SECONDS=86400 PUPPETEER_EXECUTABLE_PATH= GOOGLE_CLIENT_ID= APPLE_CLIENT_ID= +# Phase 10 bounded reconciliation and privacy retention +SUPPORT_SLA_RECONCILIATION_BATCH_SIZE=100 +RECOMMENDATION_EVENT_RETENTION_DAYS=90 diff --git a/Documentation/API_NEWSLETTER.md b/Documentation/API_NEWSLETTER.md new file mode 100644 index 0000000..1907e72 --- /dev/null +++ b/Documentation/API_NEWSLETTER.md @@ -0,0 +1,9 @@ +# Newsletter Consent API + +`POST /api/v1/newsletter/subscribe` accepts `email`, `locale`, and an allowlisted source (`HOME_FOOTER`, `CHECKOUT`, or `ACCOUNT`). Email is trimmed, lowercased, and uniquely stored. Repeated subscription is idempotent and enumeration-safe. + +The current product policy uses immediate single opt-in. Each subscription receives a cryptographically random unsubscribe token, while only its SHA-256 hash is stored. `POST /api/v1/newsletter/unsubscribe/:token` always returns a neutral success response. Resubscription records fresh consent and rotates the token. + +Authenticated users may inspect their linked consent at `GET /api/v1/newsletter/me`. Admin listing is `GET /api/v1/admin/newsletter/subscribers` and requires `newsletter.subscribers.read`; token hashes are never returned. Export/manage permissions are reserved, and Phase 10 does not implement campaign sending. + +Newsletter consent is legally distinct from Phase 3 profile marketing preferences. An email preference does not create newsletter consent, and an explicit newsletter unsubscribe takes precedence until a new explicit subscribe action occurs. diff --git a/Documentation/API_RECOMMENDATIONS.md b/Documentation/API_RECOMMENDATIONS.md new file mode 100644 index 0000000..3b736a5 --- /dev/null +++ b/Documentation/API_RECOMMENDATIONS.md @@ -0,0 +1,14 @@ +# Deterministic Recommendation API + +Phase 10 recommendations are local, explainable rules—not AI or machine learning. + +- `POST /api/v1/recommendations/events` accepts authenticated, idempotent `PRODUCT_VIEW` events only. Clients cannot claim purchases or other authoritative commerce events. +- `GET /api/v1/recommendations/recently-viewed` returns a customer's unique recent public products. +- `GET /api/v1/products/:productId/recommendations/related` prefers Phase 4 `ProductRelation`, then bounded same-category/brand fallback. +- `GET /api/v1/recommendations/trending` ranks a 30-day bounded aggregate with purchase/cart/wishlist/click/view weights. +- `GET /api/v1/recommendations/popular` uses eligible paid order item quantities net of refunded quantity, with a featured fallback. +- `GET /api/v1/recommendations/for-you` combines the authenticated user's recent product interests with aggregate candidates and falls back to trending/featured products. + +Responses reuse the Phase 4 localized product summary and signed media projection. Only active/public products with an active variant are eligible. Exact stock is not exposed. Limits are capped at 24. Business-specific price quotation remains a known integration item; no customer-specific recommendation response is shared in cache. + +Events store no email, IP, access token, cookie, raw session key, or full user agent. Session keys, if enabled later for anonymous ingestion, are hash-only. Retention defaults to 90 days and cleanup is bounded. The clean service boundary can later be replaced by a separately authenticated recommendation service; Phase 10 adds no URL, credential, bypass, LLM, embedding, vector store, or ML model. diff --git a/Documentation/API_SUPPORT_HELP.md b/Documentation/API_SUPPORT_HELP.md new file mode 100644 index 0000000..5b1dfd0 --- /dev/null +++ b/Documentation/API_SUPPORT_HELP.md @@ -0,0 +1,29 @@ +# Support and Help API + +Phase 10 adds a permission-gated support case system and a localized help center. All routes are under `/api/v1`. + +## Customer support + +- `POST /support/tickets` creates a self-owned ticket and initial public message in one transaction. Identity, business context, priority and status are server-derived. +- `GET /support/tickets` and `GET /support/tickets/:id` are self-only; internal notes and internal attachments are excluded. +- `POST /support/tickets/:id/messages` appends a public reply. A resolved ticket is explicitly reopened; closed/cancelled tickets reject replies. +- `POST /support/tickets/:id/close` performs a validated state transition. +- `GET /support/tickets/:id/attachments/:attachmentId` authorizes the ticket and visibility before issuing a short-lived signed S3 URL. + +Ticket states are `OPEN`, `ASSIGNED`, `WAITING_FOR_CUSTOMER`, `WAITING_FOR_SUPPORT`, `RESOLVED`, `CLOSED`, and `CANCELLED`. Customers cannot select priority; new tickets default to `NORMAL`. Subjects are limited to 200 characters, messages to 10,000 characters, and five attachments per message. Attachments reuse Phase 2 uploads and allow JPEG, PNG, WebP, or PDF only. + +Related resources support orders, payments, refunds, returns, shipments, loyalty redemptions, and business settlements. Creation verifies the resource against the authenticated customer or business; a resource identifier alone never grants access. + +## Staff support + +Under `/admin/support/tickets`, staff can list/detail, assign or atomically claim, reply, add internal notes, change priority, resolve, reopen, close, and download attachments. Permissions are granular: `support.tickets.read`, `.assign`, `.reply`, `.status`, `.priority`, `.internal_notes`, and `.escalate`. Category and SLA controls use `support.categories.manage` and `support.sla.manage`. + +State/assignment operations lock the ticket row. Events are append-only. SLA deadlines are snapshotted from the active priority policy at creation using clock time; a bounded five-minute reconciliation creates idempotent first-response or resolution escalations. Business-hour calendars and multi-instance cron validation remain Phase 11 work. + +## Help center + +Public endpoints are `GET /help/categories`, `/help/categories/:slug`, `/help/articles`, `/help/articles/:slug`, and `/help/search?q=`. Only active categories and published articles are returned. `en`, `si`, and `ta` use the Phase 4 locale resolver with English fallback. Search is bounded to 2–100 query characters and at most 50 results. + +Admin endpoints under `/admin/help` list/create categories and articles and explicitly publish/archive articles. They require `help.read`, `help.manage`, or `help.publish`. Article bodies are stored as plain Markdown-like text with HTML tags removed; consumers must render text/Markdown safely and must not treat it as trusted HTML. + +All collection APIs use bounded pagination or bounded results and the standard `{ success, data, pagination? }` envelope. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index 601c315..710d2fc 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -426,3 +426,21 @@ Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/di ## Phase 9 Completion Update Date: 2026-09-09. Module 12 is 86% and Module 13 is revised to 88%. Loyalty accounts are 92%, points ledger 90%, earning rules 86%, membership 88%, rewards/redemption 86%, referrals 82%, birthday rewards 80%, and expiry 80%. Business tiers are 84%, business credit 84%, settlements 78%, wholesale dashboard 82%, and wholesale analytics 76%. Module 07 is revised to 82% through coupon-entitlement foundations; Modules 09/10 are unchanged except for paid-event loyalty and internal-credit integration. Fourteen models, a forward-only migration, bounded cron reconciliation, new owner/admin APIs, and immutable concurrency-safe ledgers were added. Migration and real MySQL/cron/document/notification validation remain staging requirements. Module 16 AI Customer Support Chatbot remains intentionally excluded and will be developed separately. +## Phase 10 Completion Update + +Date: 2026-09-09 + +- Module 15 Support Ticket: **84%** +- Module 17 Product Recommendations: **78%** +- Support tickets 90%; messages 88%; assignment 86%; SLA/escalation 74%; attachments 82%; related-resource integration 78%. +- Help center 84%; help localization 86%; help search 76%; newsletter consent 84%. +- Recommendation events 80%; related 86%; recently viewed 84%; trending 78%; popular 74%; personalized deterministic 70%. +- Module 14 notifications is unchanged: event/template delivery fanout remains outstanding. +- Module 03 catalogue relationships are reused without a revised completion claim. +- Module 04 localization infrastructure is reused without a revised completion claim. +- Verification: **28 suites / 145 tests passing**; syntax passed for **373 JavaScript files**; `npm ls --depth=0` reports no dependency problems. +- Migration: `20260909100000-phase-10-support-recommendations.js` created but **not executed**. Phase 0–9 migrations were not changed. +- Staging requirements: legacy-data precheck; real MySQL migration/FK/query-plan and row-lock validation; Redis/BullMQ/cron multi-instance validation; S3 signed-download and file-content validation; SMTP notification wiring; business-price projection; authoritative shopping/commerce recommendation events; IDOR/E2E/concurrency tests. +- Phase 11 readiness: safe to begin hardening after the Phase 10 migration and infrastructure checks are scheduled; Phase 10 is not a production-readiness claim. + +Module 16 AI Customer Support Chatbot is intentionally excluded from this backend. It will be developed as a separate service. diff --git a/Documentation/PHASE_10_SUPPORT_RECOMMENDATIONS.md b/Documentation/PHASE_10_SUPPORT_RECOMMENDATIONS.md new file mode 100644 index 0000000..adf40cd --- /dev/null +++ b/Documentation/PHASE_10_SUPPORT_RECOMMENDATIONS.md @@ -0,0 +1,53 @@ +# ZUMRI Phase 10 Support, Help Center, Newsletter and Recommendation Foundations + +## Objective + +Provide practical customer support, localized self-service content, explicit newsletter consent, and deterministic recommendations while preserving Phase 0–9 domain ownership. + +## Existing Components Reused + +Phase 1 identity/RBAC; Phase 2 upload, signed S3 access, notification/audit infrastructure; Phase 4 locale, product relations and product DTO; Phase 5 inventory boundary; Phase 7 order/payment truth; Phase 8 shipment truth; Phase 9 loyalty/business ownership; atomic reference numbers and existing cron lifecycle. + +## Support Architecture + +`SupportTicket` owns case lifecycle, `SupportMessage` conversation, `SupportTicketEvent` append-only history, `SupportTicketLink` polymorphic links, and `SupportAttachment` upload references. Ticket creation is transactional. The status graph is explicit; generic status mutation is absent. Agent claim/assignment locks the row. Internal notes and attachments are filtered from customer reads. Resource links validate domain ownership, and signed downloads require ticket authorization. + +## SLA and Escalation + +An active per-priority `SupportSlaPolicy` snapshots first-response and resolution deadlines using `CLOCK_TIME`. The bounded cron detects overdue open work and uses deterministic event keys to create each `SupportEscalation` once. Full calendars, warning tiers, acknowledgement APIs, and verified multi-instance scheduling remain outstanding. + +## Support Notifications, Permissions and Audit + +Phase 2 notification infrastructure is retained as the delivery boundary; full template/fanout wiring is outstanding. New support/help/newsletter/recommendation permissions are explicit. Support lifecycle history is durable in ticket events, and privileged general audit calls are intentionally limited pending real queue integration testing. + +## Help Center Architecture + +Help categories and articles have `en`/`si`/`ta` translation tables and English fallback. Articles implement draft, publish, and archive lifecycle; public APIs query published content only. FAQ is an article type, avoiding a parallel engine. Search uses bounded MySQL `LIKE` queries. HTML tags are removed and bodies are treated as untrusted Markdown-like text. + +## Newsletter Consent + +Newsletter subscriptions are unique by normalized email, source/locale aware, idempotent, independently revocable, and linked to a user when known. Immediate opt-in is the documented policy. Unsubscribe authorization uses a random token with hash-only persistence. Profile marketing preferences never override explicit unsubscribe. + +## Recommendation Architecture + +Privacy-conscious interaction events retain meaningful signals only. Client ingestion is restricted to product views and protected by authentication, validation, rate limiting, and event-id uniqueness. Explicit product relations lead related results. Recently viewed is de-duplicated; trending uses weighted 30-day SQL aggregation; popular uses paid order items net of refunds; for-you deterministically prioritizes recent interests with a featured fallback. Queries and response sizes are bounded, inactive/hidden products are excluded, and the existing localized serializer is reused. + +## Future External Recommendation Service Boundary + +The current implementation is `LOCAL_DETERMINISTIC`. A future external service may implement the same recommendation input/output contract using scoped machine credentials. There is no insecure internal bypass or placeholder service URL. + +## Explicit AI Exclusion + +Module 16 AI Customer Support Chatbot is intentionally excluded. No OpenAI/LLM integration, prompts, embeddings, RAG, vector database, generated replies, agent, or ML training pipeline was implemented. + +## Security and Database Changes + +Customer ownership is server-derived; strict request schemas reject unknown fields; internal visibility is enforced; uploads and linked resources receive independent authorization; newsletter tokens are hash-only; recommendation telemetry cannot spoof purchases. Migration `20260909100000-phase-10-support-recommendations.js` is forward-only and creates 14 tables with uniqueness and query indexes. Precheck legacy support/FAQ/newsletter/event tables and duplicate normalized email addresses before staging. No backfill is fabricated. + +## Tests and Known Limitations + +Unit coverage exercises strict fields, priority/event spoofing, transition policy, token hashing, source allowlists, body safety, and permissions. Real MySQL FK/migration/concurrency, Redis/BullMQ, S3 signed downloads, SMTP notifications, business-price projection, authoritative event hooks, rich CMS update APIs, and multi-instance cron behavior require Phase 11 staging work. + +## Phase 11 Prerequisites + +Apply migrations only after schema/data prechecks and a verified backup. Seed categories/SLA policies and permissions, validate real infrastructure, add concurrency/IDOR/E2E coverage, wire support notifications and authoritative recommendation signals, and measure aggregate query plans before production readiness assessment. diff --git a/app/config/env.config.js b/app/config/env.config.js index 626aa1d..ea7ac9c 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -51,6 +51,8 @@ const envSchema = z.object({ 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(), LOYALTY_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100), + SUPPORT_SLA_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100), + RECOMMENDATION_EVENT_RETENTION_DAYS: z.coerce.number().int().positive().default(90), 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(), diff --git a/app/constants/permissions.js b/app/constants/permissions.js index 53816e8..139e77a 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -28,4 +28,5 @@ module.exports = { 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", SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage", LOYALTY_ACCOUNTS_READ:"loyalty.accounts.read",LOYALTY_POINTS_ADJUST:"loyalty.points.adjust",LOYALTY_MANAGE:"loyalty.manage",LOYALTY_REFERRALS_READ:"loyalty.referrals.read",WHOLESALE_CREDIT_READ:"wholesale.credit.read",WHOLESALE_CREDIT_MANAGE:"wholesale.credit.manage",WHOLESALE_SETTLEMENTS_READ:"wholesale.settlements.read",WHOLESALE_SETTLEMENTS_MANAGE:"wholesale.settlements.manage",WHOLESALE_ANALYTICS_READ:"wholesale.analytics.read", + SUPPORT_TICKETS_READ:"support.tickets.read",SUPPORT_TICKETS_ASSIGN:"support.tickets.assign",SUPPORT_TICKETS_REPLY:"support.tickets.reply",SUPPORT_TICKETS_STATUS:"support.tickets.status",SUPPORT_TICKETS_PRIORITY:"support.tickets.priority",SUPPORT_TICKETS_INTERNAL_NOTES:"support.tickets.internal_notes",SUPPORT_TICKETS_ESCALATE:"support.tickets.escalate",SUPPORT_CATEGORIES_MANAGE:"support.categories.manage",SUPPORT_SLA_MANAGE:"support.sla.manage",HELP_READ:"help.read",HELP_MANAGE:"help.manage",HELP_PUBLISH:"help.publish",NEWSLETTER_SUBSCRIBERS_READ:"newsletter.subscribers.read",NEWSLETTER_SUBSCRIBERS_EXPORT:"newsletter.subscribers.export",NEWSLETTER_SUBSCRIBERS_MANAGE:"newsletter.subscribers.manage",RECOMMENDATIONS_READ:"recommendations.read",RECOMMENDATIONS_MANAGE:"recommendations.manage", }; diff --git a/app/controllers/help.controller.js b/app/controllers/help.controller.js new file mode 100644 index 0000000..b0687ac --- /dev/null +++ b/app/controllers/help.controller.js @@ -0,0 +1 @@ +const db=require("../models"),help=require("../services/help/help.service"),{resolveLocale}=require("../services/catalogue/locale.service");const locale=req=>resolveLocale(req);exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await help.categories(locale(req))});}catch(e){next(e);}};exports.category=async(req,res,next)=>{try{const row=await db.HelpCategory.findOne({where:{slug:req.params.slug,status:"ACTIVE"}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help category not found"}});res.json({success:true,data:{category:(await help.categories(locale(req))).find(x=>x.id===row.id),articles:await help.articles({locale:locale(req),categoryId:row.id})}});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await help.articles({locale:locale(req),categoryId:req.query.category,featured:req.query.featured==null?undefined:req.query.featured==="true",limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};exports.article=async(req,res,next)=>{try{const a=await db.HelpArticle.findOne({where:{slug:req.params.slug,status:"PUBLISHED"}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help article not found"}});const tr=help.pick(await db.HelpArticleTranslation.findAll({where:{article_id:a.id}}),locale(req));res.json({success:true,data:{id:a.id,slug:a.slug,categoryId:a.category_id,type:a.article_type,title:tr?.title||null,summary:tr?.summary||null,body:tr?.body||null,locale:tr?.locale||locale(req),isFeatured:a.is_featured,publishedAt:a.published_at}});}catch(e){next(e);}};exports.search=async(req,res,next)=>{try{const q=String(req.query.q||"").trim();if(q.length<2||q.length>100)return res.status(400).json({success:false,error:{code:"INVALID_QUERY",message:"q must contain 2-100 characters"}});res.json({success:true,data:await help.articles({locale:locale(req),q,limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}}; diff --git a/app/controllers/helpAdmin.controller.js b/app/controllers/helpAdmin.controller.js new file mode 100644 index 0000000..a62f8c5 --- /dev/null +++ b/app/controllers/helpAdmin.controller.js @@ -0,0 +1 @@ +const db=require("../models"),help=require("../services/help/help.service");exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpCategory.findAll({order:[["sort_order","ASC"]]})});}catch(e){next(e);}};exports.createCategory=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createCategory(req.body)});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpArticle.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.createArticle=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createArticle(req.user,req.body)});}catch(e){next(e);}};exports.publish=status=>async(req,res,next)=>{try{res.json({success:true,data:await help.publish(req.params.id,req.user,status)});}catch(e){next(e);}}; diff --git a/app/controllers/newsletter.controller.js b/app/controllers/newsletter.controller.js new file mode 100644 index 0000000..69518c4 --- /dev/null +++ b/app/controllers/newsletter.controller.js @@ -0,0 +1 @@ +const db=require("../models"),service=require("../services/marketing/newsletter.service");exports.subscribe=async(req,res,next)=>{try{const x=await service.subscribe({...req.body,userId:req.user?.id});res.status(x.idempotent?200:201).json({success:true,message:"Subscription request processed",data:{status:x.subscription.status}});}catch(e){next(e);}};exports.unsubscribe=async(req,res,next)=>{try{await service.unsubscribe(req.params.token);res.json({success:true,message:"Unsubscribe request processed"});}catch(e){next(e);}};exports.mine=async(req,res,next)=>{try{res.json({success:true,data:await db.NewsletterSubscription.findOne({where:{user_id:req.user.id},attributes:{exclude:["unsubscribe_token_hash"]}})});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||50,100),x=await db.NewsletterSubscription.findAndCountAll({attributes:{exclude:["unsubscribe_token_hash"]},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);}}; diff --git a/app/controllers/recommendation.controller.js b/app/controllers/recommendation.controller.js new file mode 100644 index 0000000..22cd62b --- /dev/null +++ b/app/controllers/recommendation.controller.js @@ -0,0 +1 @@ +const service=require("../services/recommendation/recommendation.service"),{resolveLocale}=require("../services/catalogue/locale.service");const loc=req=>resolveLocale(req),limit=req=>Math.min(Math.max(+req.query.limit||12,1),24);exports.event=async(req,res,next)=>{try{const x=await service.recordView({...req.body,userId:req.user?.id});res.status(x.created?201:200).json({success:true,data:{accepted:true,idempotent:!x.created}});}catch(e){next(e);}};exports.related=async(req,res,next)=>{try{res.json({success:true,data:await service.related(req.params.productId,loc(req),limit(req))});}catch(e){next(e);}};exports.trending=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"trending",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.popular=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"popular",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.recent=async(req,res,next)=>{try{res.json({success:true,data:await service.recent(req.user.id,loc(req),limit(req))});}catch(e){next(e);}};exports.forYou=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"for-you",userId:req.user.id,locale:loc(req),limit:limit(req)})});}catch(e){next(e);}}; diff --git a/app/controllers/support/admin.controller.js b/app/controllers/support/admin.controller.js new file mode 100644 index 0000000..7e617d9 --- /dev/null +++ b/app/controllers/support/admin.controller.js @@ -0,0 +1 @@ +const{Op}=require("sequelize"),db=require("../../models"),support=require("../../services/support/support.service");exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||20,100),where={...(req.query.status&&{status:req.query.status}),...(req.query.priority&&{priority:req.query.priority}),...(req.query.category&&{category_id:req.query.category}),...(req.query.assignedAgentId&&{assigned_agent_id:req.query.assignedAgentId}),...(req.query.business&&{business_customer_id:req.query.business}),...(req.query.unassigned==="true"&&{assigned_agent_id:null}),...(req.query.overdue==="true"&&{resolution_due_at:{[Op.lt]:new Date()},status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]}})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit,offset:(page-1)*limit});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await db.SupportTicket.findByPk(req.params.id);if(!ticket)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const[messages,attachments,links,events,escalations]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id},order:[["occurred_at","ASC"]]}),db.SupportEscalation.findAll({where:{ticket_id:ticket.id}})]);res.json({success:true,data:{ticket,messages,attachments,links,events,escalations}});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.body.agentId,req.user)});}catch(e){next(e);}};exports.claim=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.user.id,req.user,true)});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.note=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body,true)});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await support.transition(req.params.id,to,req.user)});}catch(e){next(e);}};exports.priority=async(req,res,next)=>{try{const row=await db.SupportTicket.findByPk(req.params.id);if(!row)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const from=row.priority;await row.update({priority:req.body.priority});await db.SupportTicketEvent.create({id:require("crypto").randomUUID(),event_id:require("crypto").randomUUID(),ticket_id:row.id,actor_user_id:req.user.id,type:"PRIORITY_CHANGED",from_value:from,to_value:row.priority,occurred_at:new Date()});res.json({success:true,data:row});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,true)}});}catch(e){next(e);}}; diff --git a/app/controllers/support/customer.controller.js b/app/controllers/support/customer.controller.js new file mode 100644 index 0000000..501a786 --- /dev/null +++ b/app/controllers/support/customer.controller.js @@ -0,0 +1 @@ +const db=require("../../models"),support=require("../../services/support/support.service");const page=req=>({page:Math.max(Number(req.query.page)||1,1),limit:Math.min(Math.max(Number(req.query.limit)||20,1),100)});exports.create=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.createTicket(req.user,req.body)});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const p=page(req),where={customer_user_id:req.user.id,...(req.query.status&&{status:req.query.status}),...(req.query.category&&{category_id:req.query.category})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit:p.limit,offset:(p.page-1)*p.limit});res.json({success:true,data:x.rows,pagination:{...p,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await support.ownTicket(req.params.id,req.user.id),[messages,attachments,links,events]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id,type:["TICKET_CREATED","TICKET_ASSIGNED","TICKET_RESOLVED","TICKET_CLOSED","TICKET_REOPENED"]},attributes:{exclude:["metadata"]},order:[["occurred_at","ASC"]]})]);res.json({success:true,data:{ticket,messages,attachments,links,events}});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.customerReply(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.close=async(req,res,next)=>{try{await support.ownTicket(req.params.id,req.user.id);res.json({success:true,data:await support.transition(req.params.id,"CLOSED",req.user)});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,false)}});}catch(e){next(e);}}; diff --git a/app/models/commerce/orderItem.model.js b/app/models/commerce/orderItem.model.js index 00f51b5..daa0447 100644 --- a/app/models/commerce/orderItem.model.js +++ b/app/models/commerce/orderItem.model.js @@ -1 +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}); +module.exports=(s,D)=>{const M=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});M.associate=db=>M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});return M;}; diff --git a/app/models/help/helpArticle.model.js b/app/models/help/helpArticle.model.js new file mode 100644 index 0000000..719bb4f --- /dev/null +++ b/app/models/help/helpArticle.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("HelpArticle",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(180),allowNull:false,unique:true},category_id:{type:D.STRING,allowNull:false},article_type:{type:D.ENUM("ARTICLE","FAQ"),allowNull:false,defaultValue:"ARTICLE"},status:{type:D.ENUM("DRAFT","PUBLISHED","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},is_featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false},published_at:D.DATE,created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"help_articles",timestamps:true}); diff --git a/app/models/help/helpArticleTranslation.model.js b/app/models/help/helpArticleTranslation.model.js new file mode 100644 index 0000000..2b710e5 --- /dev/null +++ b/app/models/help/helpArticleTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("HelpArticleTranslation",{id:{type:D.STRING,primaryKey:true},article_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},title:{type:D.STRING(220),allowNull:false},summary:D.STRING(500),body:{type:D.TEXT("long"),allowNull:false},seo_title:D.STRING(220),seo_description:D.STRING(500)},{tableName:"help_article_translations",timestamps:true,indexes:[{unique:true,fields:["article_id","locale"]}]}); diff --git a/app/models/help/helpCategory.model.js b/app/models/help/helpCategory.model.js new file mode 100644 index 0000000..8f3fc9f --- /dev/null +++ b/app/models/help/helpCategory.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("HelpCategory",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(160),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},icon_key:D.STRING(80)},{tableName:"help_categories",timestamps:true}); diff --git a/app/models/help/helpCategoryTranslation.model.js b/app/models/help/helpCategoryTranslation.model.js new file mode 100644 index 0000000..7a8dff0 --- /dev/null +++ b/app/models/help/helpCategoryTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("HelpCategoryTranslation",{id:{type:D.STRING,primaryKey:true},category_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(160),allowNull:false},description:D.STRING(500)},{tableName:"help_category_translations",timestamps:true,indexes:[{unique:true,fields:["category_id","locale"]}]}); diff --git a/app/models/index.js b/app/models/index.js index a0bbff9..f2bdbe3 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -320,6 +320,20 @@ db.PaymentAllocation = require("./wholesale/paymentAllocation.model")( sequelize, DataTypes, ); +db.SupportCategory = require("./support/supportCategory.model")(sequelize, DataTypes); +db.SupportTicket = require("./support/supportTicket.model")(sequelize, DataTypes); +db.SupportMessage = require("./support/supportMessage.model")(sequelize, DataTypes); +db.SupportTicketEvent = require("./support/supportTicketEvent.model")(sequelize, DataTypes); +db.SupportTicketLink = require("./support/supportTicketLink.model")(sequelize, DataTypes); +db.SupportAttachment = require("./support/supportAttachment.model")(sequelize, DataTypes); +db.SupportSlaPolicy = require("./support/supportSlaPolicy.model")(sequelize, DataTypes); +db.SupportEscalation = require("./support/supportEscalation.model")(sequelize, DataTypes); +db.HelpCategory = require("./help/helpCategory.model")(sequelize, DataTypes); +db.HelpCategoryTranslation = require("./help/helpCategoryTranslation.model")(sequelize, DataTypes); +db.HelpArticle = require("./help/helpArticle.model")(sequelize, DataTypes); +db.HelpArticleTranslation = require("./help/helpArticleTranslation.model")(sequelize, DataTypes); +db.NewsletterSubscription = require("./marketing/newsletterSubscription.model")(sequelize, DataTypes); +db.ProductInteractionEvent = require("./recommendation/productInteractionEvent.model")(sequelize, DataTypes); /* Associations */ Object.keys(db).forEach((model) => { diff --git a/app/models/marketing/newsletterSubscription.model.js b/app/models/marketing/newsletterSubscription.model.js new file mode 100644 index 0000000..b46dd7d --- /dev/null +++ b/app/models/marketing/newsletterSubscription.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("NewsletterSubscription",{id:{type:D.STRING,primaryKey:true},email_normalized:{type:D.STRING(254),allowNull:false,unique:true},user_id:D.STRING,status:{type:D.ENUM("PENDING","SUBSCRIBED","UNSUBSCRIBED"),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},source:{type:D.ENUM("HOME_FOOTER","CHECKOUT","ACCOUNT","ADMIN_IMPORT"),allowNull:false},consented_at:D.DATE,confirmed_at:D.DATE,unsubscribed_at:D.DATE,unsubscribe_token_hash:{type:D.STRING(64),allowNull:false}},{tableName:"newsletter_subscriptions",timestamps:true}); diff --git a/app/models/recommendation/productInteractionEvent.model.js b/app/models/recommendation/productInteractionEvent.model.js new file mode 100644 index 0000000..6e55aba --- /dev/null +++ b/app/models/recommendation/productInteractionEvent.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ProductInteractionEvent",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},user_id:D.STRING,session_key_hash:D.STRING(64),product_id:{type:D.STRING,allowNull:false},variant_id:D.STRING,event_type:{type:D.ENUM("PRODUCT_VIEW","PRODUCT_CLICK","WISHLIST_ADD","CART_ADD","CHECKOUT_START","PURCHASE"),allowNull:false},source:{type:D.STRING(40),allowNull:false},occurred_at:{type:D.DATE,allowNull:false}},{tableName:"recommendation_events",timestamps:true,updatedAt:false}); diff --git a/app/models/support/supportAttachment.model.js b/app/models/support/supportAttachment.model.js new file mode 100644 index 0000000..8b5b373 --- /dev/null +++ b/app/models/support/supportAttachment.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportAttachment",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},message_id:D.STRING,upload_id:{type:D.INTEGER,allowNull:false},uploaded_by:{type:D.STRING,allowNull:false},visibility:{type:D.ENUM("CUSTOMER_VISIBLE","INTERNAL"),allowNull:false,defaultValue:"CUSTOMER_VISIBLE"}},{tableName:"support_attachments",timestamps:true,updatedAt:false}); diff --git a/app/models/support/supportCategory.model.js b/app/models/support/supportCategory.model.js new file mode 100644 index 0000000..1bae256 --- /dev/null +++ b/app/models/support/supportCategory.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportCategory",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},translations:{type:D.JSON,allowNull:false,defaultValue:{}},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"support_categories",timestamps:true}); diff --git a/app/models/support/supportEscalation.model.js b/app/models/support/supportEscalation.model.js new file mode 100644 index 0000000..1a81608 --- /dev/null +++ b/app/models/support/supportEscalation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportEscalation",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},ticket_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("FIRST_RESPONSE_OVERDUE","RESOLUTION_OVERDUE","MANUAL"),allowNull:false},level:{type:D.INTEGER,allowNull:false,defaultValue:1},reason:{type:D.STRING(300),allowNull:false},status:{type:D.ENUM("OPEN","ACKNOWLEDGED"),allowNull:false,defaultValue:"OPEN"},acknowledged_at:D.DATE},{tableName:"support_escalations",timestamps:true}); diff --git a/app/models/support/supportMessage.model.js b/app/models/support/supportMessage.model.js new file mode 100644 index 0000000..0f27bcc --- /dev/null +++ b/app/models/support/supportMessage.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportMessage",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},sender_user_id:D.STRING,sender_type:{type:D.ENUM("CUSTOMER","AGENT","SYSTEM"),allowNull:false},message_type:{type:D.ENUM("MESSAGE","SYSTEM"),allowNull:false,defaultValue:"MESSAGE"},body:{type:D.TEXT,allowNull:false},visibility:{type:D.ENUM("CUSTOMER_VISIBLE","INTERNAL"),allowNull:false,defaultValue:"CUSTOMER_VISIBLE"}},{tableName:"support_messages",timestamps:true,updatedAt:false}); diff --git a/app/models/support/supportSlaPolicy.model.js b/app/models/support/supportSlaPolicy.model.js new file mode 100644 index 0000000..1798ebe --- /dev/null +++ b/app/models/support/supportSlaPolicy.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportSlaPolicy",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(120),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false},priority:{type:D.ENUM("LOW","NORMAL","HIGH","URGENT"),allowNull:false,unique:true},first_response_minutes:{type:D.INTEGER,allowNull:false},resolution_minutes:{type:D.INTEGER,allowNull:false},business_hours_mode:{type:D.ENUM("CLOCK_TIME"),allowNull:false,defaultValue:"CLOCK_TIME"}},{tableName:"support_sla_policies",timestamps:true}); diff --git a/app/models/support/supportTicket.model.js b/app/models/support/supportTicket.model.js new file mode 100644 index 0000000..af647f7 --- /dev/null +++ b/app/models/support/supportTicket.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportTicket",{id:{type:D.STRING,primaryKey:true},ticket_number:{type:D.STRING(80),allowNull:false,unique:true},customer_user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,subject:{type:D.STRING(200),allowNull:false},category_id:D.STRING,priority:{type:D.ENUM("LOW","NORMAL","HIGH","URGENT"),allowNull:false,defaultValue:"NORMAL"},status:{type:D.ENUM("OPEN","ASSIGNED","WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT","RESOLVED","CLOSED","CANCELLED"),allowNull:false,defaultValue:"OPEN"},assigned_agent_id:D.STRING,source:{type:D.ENUM("WEB","MOBILE","ADMIN"),allowNull:false,defaultValue:"WEB"},sla_policy_id:D.STRING,first_response_due_at:D.DATE,resolution_due_at:D.DATE,first_response_at:D.DATE,resolved_at:D.DATE,closed_at:D.DATE,last_customer_message_at:D.DATE,last_agent_message_at:D.DATE},{tableName:"support_tickets",timestamps:true}); diff --git a/app/models/support/supportTicketEvent.model.js b/app/models/support/supportTicketEvent.model.js new file mode 100644 index 0000000..bad42b7 --- /dev/null +++ b/app/models/support/supportTicketEvent.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportTicketEvent",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},ticket_id:{type:D.STRING,allowNull:false},actor_user_id:D.STRING,type:{type:D.STRING(60),allowNull:false},from_value:D.STRING,to_value:D.STRING,metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false}},{tableName:"support_ticket_events",timestamps:true,updatedAt:false}); diff --git a/app/models/support/supportTicketLink.model.js b/app/models/support/supportTicketLink.model.js new file mode 100644 index 0000000..ee04075 --- /dev/null +++ b/app/models/support/supportTicketLink.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SupportTicketLink",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},resource_type:{type:D.ENUM("ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"),allowNull:false},resource_id:{type:D.STRING,allowNull:false}},{tableName:"support_ticket_links",timestamps:true,updatedAt:false,indexes:[{unique:true,fields:["ticket_id","resource_type","resource_id"]}]}); diff --git a/app/routes/help.routes.js b/app/routes/help.routes.js new file mode 100644 index 0000000..f0b299c --- /dev/null +++ b/app/routes/help.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../controllers/help.controller"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.get("/help/categories",c.categories);r.get("/help/categories/:slug",c.category);r.get("/help/articles",c.articles);r.get("/help/articles/:slug",c.article);r.get("/help/search",sensitiveLimiter,c.search);module.exports=r; diff --git a/app/routes/helpAdmin.routes.js b/app/routes/helpAdmin.routes.js new file mode 100644 index 0000000..7966912 --- /dev/null +++ b/app/routes/helpAdmin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../controllers/helpAdmin.controller"),{authenticate}=require("../middleware/auth.middleware"),{checkPermission}=require("../middleware/permission.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas");r.use(authenticate);r.get("/help/categories",checkPermission("help.read",{custom:true}),c.categories);r.post("/help/categories",checkPermission("help.manage",{custom:true}),validate(s.helpCategory),c.createCategory);r.get("/help/articles",checkPermission("help.read",{custom:true}),c.articles);r.post("/help/articles",checkPermission("help.manage",{custom:true}),validate(s.helpArticle),c.createArticle);r.post("/help/articles/:id/publish",checkPermission("help.publish",{custom:true}),c.publish("PUBLISHED"));r.post("/help/articles/:id/archive",checkPermission("help.publish",{custom:true}),c.publish("ARCHIVED"));module.exports=r; diff --git a/app/routes/index.js b/app/routes/index.js index ae82513..45da770 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -43,6 +43,12 @@ const loyaltyCustomerRoutes = require("./loyalty/customer.routes"); const loyaltyAdminRoutes = require("./loyalty/admin.routes"); const wholesaleCustomerRoutes = require("./wholesale/customer.routes"); const wholesaleAdminRoutes = require("./wholesale/admin.routes"); +const supportCustomerRoutes = require("./support/customer.routes"); +const supportAdminRoutes = require("./support/admin.routes"); +const helpRoutes = require("./help.routes"); +const helpAdminRoutes = require("./helpAdmin.routes"); +const newsletterRoutes = require("./newsletter.routes"); +const recommendationRoutes = require("./recommendation.routes"); const router = express.Router(); @@ -78,5 +84,11 @@ router.use("/", loyaltyCustomerRoutes); router.use("/admin", loyaltyAdminRoutes); router.use("/", wholesaleCustomerRoutes); router.use("/admin", wholesaleAdminRoutes); +router.use("/", supportCustomerRoutes); +router.use("/admin", supportAdminRoutes); +router.use("/", helpRoutes); +router.use("/admin", helpAdminRoutes); +router.use("/", newsletterRoutes); +router.use("/", recommendationRoutes); module.exports = router; diff --git a/app/routes/newsletter.routes.js b/app/routes/newsletter.routes.js new file mode 100644 index 0000000..c380bcf --- /dev/null +++ b/app/routes/newsletter.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../controllers/newsletter.controller"),{authenticate}=require("../middleware/auth.middleware"),{checkPermission}=require("../middleware/permission.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.post("/newsletter/subscribe",sensitiveLimiter,validate(s.newsletter),c.subscribe);r.post("/newsletter/unsubscribe/:token",sensitiveLimiter,c.unsubscribe);r.get("/newsletter/me",authenticate,c.mine);r.get("/admin/newsletter/subscribers",authenticate,checkPermission("newsletter.subscribers.read",{custom:true}),c.list);module.exports=r; diff --git a/app/routes/recommendation.routes.js b/app/routes/recommendation.routes.js new file mode 100644 index 0000000..1c9ab06 --- /dev/null +++ b/app/routes/recommendation.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../controllers/recommendation.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.post("/recommendations/events",authenticate,sensitiveLimiter,validate(s.viewEvent),c.event);r.get("/products/:productId/recommendations/related",c.related);r.get("/recommendations/trending",c.trending);r.get("/recommendations/popular",c.popular);r.get("/recommendations/recently-viewed",authenticate,c.recent);r.get("/recommendations/for-you",authenticate,c.forYou);module.exports=r; diff --git a/app/routes/support/admin.routes.js b/app/routes/support/admin.routes.js new file mode 100644 index 0000000..ce82886 --- /dev/null +++ b/app/routes/support/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/support/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/supportRecommendation.schemas");r.use(authenticate);r.get("/support/tickets",checkPermission("support.tickets.read",{custom:true}),c.list);r.get("/support/tickets/:id",checkPermission("support.tickets.read",{custom:true}),c.detail);r.post("/support/tickets/:id/assign",checkPermission("support.tickets.assign",{custom:true}),validate(s.assign),c.assign);r.post("/support/tickets/:id/claim",checkPermission("support.tickets.assign",{custom:true}),c.claim);r.post("/support/tickets/:id/messages",checkPermission("support.tickets.reply",{custom:true}),validate(s.message),c.reply);r.post("/support/tickets/:id/internal-notes",checkPermission("support.tickets.internal_notes",{custom:true}),validate(s.internalNote),c.note);r.post("/support/tickets/:id/resolve",checkPermission("support.tickets.status",{custom:true}),c.action("RESOLVED"));r.post("/support/tickets/:id/reopen",checkPermission("support.tickets.status",{custom:true}),c.action("WAITING_FOR_SUPPORT"));r.post("/support/tickets/:id/close",checkPermission("support.tickets.status",{custom:true}),c.action("CLOSED"));r.patch("/support/tickets/:id/priority",checkPermission("support.tickets.priority",{custom:true}),validate(s.priority),c.priority);r.get("/support/tickets/:id/attachments/:attachmentId",checkPermission("support.tickets.read",{custom:true}),c.attachment);module.exports=r; diff --git a/app/routes/support/customer.routes.js b/app/routes/support/customer.routes.js new file mode 100644 index 0000000..f6faa19 --- /dev/null +++ b/app/routes/support/customer.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/support/customer.controller"),{authenticate}=require("../../middleware/auth.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../../middleware/rateLimit.middleware");r.use(authenticate);r.get("/support/tickets",c.list);r.post("/support/tickets",sensitiveLimiter,validate(s.ticketCreate),c.create);r.get("/support/tickets/:id",c.detail);r.post("/support/tickets/:id/messages",sensitiveLimiter,validate(s.message),c.reply);r.post("/support/tickets/:id/close",c.close);r.get("/support/tickets/:id/attachments/:attachmentId",c.attachment);module.exports=r; diff --git a/app/services/help/help.service.js b/app/services/help/help.service.js new file mode 100644 index 0000000..f4d240f --- /dev/null +++ b/app/services/help/help.service.js @@ -0,0 +1,6 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),{resolveLocale}=require("../catalogue/locale.service");const clean=s=>String(s).replace(/<\/?[a-z][^>]*>/gi,"").trim(),pick=(rows,locale)=>rows.find(x=>x.locale===locale)||rows.find(x=>x.locale==="en")||rows[0]; +async function categories(locale){const rows=await db.HelpCategory.findAll({where:{status:"ACTIVE"},order:[["sort_order","ASC"]]});return Promise.all(rows.map(async x=>{const tr=pick(await db.HelpCategoryTranslation.findAll({where:{category_id:x.id}}),locale);return{id:x.id,slug:x.slug,iconKey:x.icon_key,name:tr?.name||null,description:tr?.description||null,locale:tr?.locale||locale};}));} +async function articles({locale,categoryId,q,featured,limit=20,offset=0}){const base={status:"PUBLISHED",published_at:{[Op.lte]:new Date()},...(categoryId&&{category_id:categoryId}),...(featured!==undefined&&{is_featured:featured})},translations=q?await db.HelpArticleTranslation.findAll({where:{locale,[Op.or]:[{title:{[Op.like]:`%${q}%`}},{summary:{[Op.like]:`%${q}%`}},{body:{[Op.like]:`%${q}%`}}]},attributes:["article_id"],limit:100}):null;if(q)base.id={[Op.in]:translations.map(x=>x.article_id)};const rows=await db.HelpArticle.findAll({where:base,order:[["is_featured","DESC"],["sort_order","ASC"],["published_at","DESC"]],limit,offset});return Promise.all(rows.map(async x=>{const tr=pick(await db.HelpArticleTranslation.findAll({where:{article_id:x.id}}),locale);return{id:x.id,slug:x.slug,categoryId:x.category_id,type:x.article_type,title:tr?.title||null,summary:tr?.summary||null,body:q?undefined:tr?.body||null,locale:tr?.locale||locale,isFeatured:x.is_featured,publishedAt:x.published_at};}));} +async function createCategory(body){return db.sequelize.transaction(async t=>{const row=await db.HelpCategory.create({id:crypto.randomUUID(),slug:body.slug,status:body.status,sort_order:body.sortOrder,icon_key:body.iconKey},{transaction:t});await db.HelpCategoryTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),category_id:row.id,locale:x.locale,name:clean(x.name),description:x.description&&clean(x.description)})),{transaction:t});return row;});} +async function createArticle(actor,body){return db.sequelize.transaction(async t=>{const category=await db.HelpCategory.findByPk(body.categoryId,{transaction:t});if(!category)throw Object.assign(new Error("Help category not found"),{status:404,code:"CATEGORY_NOT_FOUND"});const row=await db.HelpArticle.create({id:crypto.randomUUID(),slug:body.slug,category_id:body.categoryId,article_type:body.articleType,sort_order:body.sortOrder,is_featured:body.isFeatured,created_by:actor.id,updated_by:actor.id},{transaction:t});await db.HelpArticleTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),article_id:row.id,locale:x.locale,title:clean(x.title),summary:x.summary&&clean(x.summary),body:clean(x.body),seo_title:x.seoTitle&&clean(x.seoTitle),seo_description:x.seoDescription&&clean(x.seoDescription)})),{transaction:t});return row;});} +async function publish(id,actor,status){const row=await db.HelpArticle.findByPk(id);if(!row)throw Object.assign(new Error("Help article not found"),{status:404,code:"ARTICLE_NOT_FOUND"});if(status==="PUBLISHED"&&!await db.HelpArticleTranslation.findOne({where:{article_id:id,locale:"en"}}))throw Object.assign(new Error("English translation required"),{status:409,code:"PUBLISH_REQUIREMENTS_NOT_MET"});await row.update({status,published_at:status==="PUBLISHED"?new Date():row.published_at,updated_by:actor.id});return row;}module.exports={categories,articles,createCategory,createArticle,publish,clean,pick,resolveLocale}; diff --git a/app/services/marketing/newsletter.service.js b/app/services/marketing/newsletter.service.js new file mode 100644 index 0000000..55c99a9 --- /dev/null +++ b/app/services/marketing/newsletter.service.js @@ -0,0 +1 @@ +const crypto=require("crypto"),db=require("../../models");const hash=x=>crypto.createHash("sha256").update(x).digest("hex"),normalize=x=>x.trim().toLowerCase();async function subscribe({email,locale,source,userId}){const normalized=normalize(email),token=crypto.randomBytes(32).toString("base64url");return db.sequelize.transaction(async t=>{let row=await db.NewsletterSubscription.findOne({where:{email_normalized:normalized},transaction:t,lock:t.LOCK.UPDATE});if(row?.status==="SUBSCRIBED")return{subscription:row,idempotent:true};if(row)await row.update({status:"SUBSCRIBED",locale,source,user_id:row.user_id||userId||null,consented_at:new Date(),confirmed_at:new Date(),unsubscribed_at:null,unsubscribe_token_hash:hash(token)},{transaction:t});else row=await db.NewsletterSubscription.create({id:crypto.randomUUID(),email_normalized:normalized,user_id:userId||null,status:"SUBSCRIBED",locale,source,consented_at:new Date(),confirmed_at:new Date(),unsubscribe_token_hash:hash(token)},{transaction:t});return{subscription:row,idempotent:false,unsubscribeToken:token};});}async function unsubscribe(token){const row=await db.NewsletterSubscription.findOne({where:{unsubscribe_token_hash:hash(token)}});if(!row)return false;if(row.status!=="UNSUBSCRIBED")await row.update({status:"UNSUBSCRIBED",unsubscribed_at:new Date()});return true;}module.exports={subscribe,unsubscribe,normalize,hash}; diff --git a/app/services/recommendation/recommendation.service.js b/app/services/recommendation/recommendation.service.js new file mode 100644 index 0000000..4efa839 --- /dev/null +++ b/app/services/recommendation/recommendation.service.js @@ -0,0 +1,6 @@ +const crypto=require("crypto"),{Op,literal}=require("sequelize"),db=require("../../models"),serializer=require("../catalogue/serializer.service"),{resolveLocale}=require("../catalogue/locale.service");const include=[{model:db.ProductTranslation,as:"translations"},{model:db.Brand,as:"brand"},{model:db.ProductVariant,as:"variants",where:{status:"ACTIVE"},required:true},{model:db.ProductMedia,as:"media",required:false,include:[{model:db.Upload,as:"upload"}]}],active={status:"ACTIVE",visibility:"PUBLIC"}; +async function project(rows,locale,reasons={}){return Promise.all(rows.map(async x=>({...await serializer.summary(x,locale),reasonCode:reasons[x.id]||undefined})));}async function recordView({eventId,userId,sessionKey,productId,variantId,source}){const product=await db.Product.findOne({where:{id:productId,...active}});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"PRODUCT_NOT_FOUND"});const [row,created]=await db.ProductInteractionEvent.findOrCreate({where:{event_id:eventId},defaults:{id:crypto.randomUUID(),event_id:eventId,user_id:userId||null,session_key_hash:sessionKey?crypto.createHash("sha256").update(sessionKey).digest("hex"):null,product_id:productId,variant_id:variantId,event_type:"PRODUCT_VIEW",source,occurred_at:new Date()}});return{row,created};} +async function related(productId,locale,limit=12){const source=await db.Product.findOne({where:{id:productId,...active}});if(!source)throw Object.assign(new Error("Product not found"),{status:404,code:"PRODUCT_NOT_FOUND"});const rel=await db.ProductRelation.findAll({where:{source_product_id:productId},order:[["sort_order","ASC"]],limit});let ids=rel.map(x=>x.target_product_id);if(ids.lengthx.id));}const rows=await db.Product.findAll({where:{...active,id:ids},include,limit});const order=new Map(ids.map((id,i)=>[id,i]));rows.sort((a,b)=>order.get(a.id)-order.get(b.id));return project(rows,locale,Object.fromEntries(ids.map(id=>[id,"RELATED_PRODUCT"])));} +async function ranked({type="trending",locale,limit=12,userId}){const since=new Date(Date.now()-30*86400000);let signals=[];if(userId){signals=await db.ProductInteractionEvent.findAll({where:{user_id:userId,occurred_at:{[Op.gte]:since}},attributes:[["product_id","product_id"]],group:["product_id"],order:[[literal("MAX(occurred_at)"),"DESC"]],limit:50,raw:true});}const counts=await db.ProductInteractionEvent.findAll({where:{occurred_at:{[Op.gte]:since}},attributes:["product_id",[literal("SUM(CASE event_type WHEN 'PURCHASE' THEN 8 WHEN 'CART_ADD' THEN 4 WHEN 'WISHLIST_ADD' THEN 3 WHEN 'PRODUCT_CLICK' THEN 2 ELSE 1 END)"),"score"]],group:["product_id"],order:[[literal("score"),"DESC"]],limit:100,raw:true});let ids=[...new Set([...signals.map(x=>x.product_id),...counts.map(x=>x.product_id)])];if(type==="popular"){const orderRows=await db.OrderItem.findAll({attributes:["product_id",[literal("SUM(quantity - refunded_quantity)"),"score"]],include:[{model:db.Order,as:"order",where:{payment_status:{[Op.in]:["PAID","PARTIALLY_REFUNDED"]},status:{[Op.notIn]:["CANCELLED","REFUNDED"]}},attributes:[]}],group:["product_id"],order:[[literal("score"),"DESC"]],limit:100,raw:true}).catch(()=>[]);ids=orderRows.map(x=>x.product_id);}if(!ids.length){const fallback=await db.Product.findAll({where:{...active,featured:true},attributes:["id"],limit});ids=fallback.map(x=>x.id);}const rows=await db.Product.findAll({where:{...active,id:ids.slice(0,limit)},include,limit});const order=new Map(ids.map((id,i)=>[id,i]));rows.sort((a,b)=>order.get(a.id)-order.get(b.id));return project(rows,locale,Object.fromEntries(ids.map(id=>[id,type==="for-you"?"BASED_ON_ACTIVITY":type.toUpperCase()])));} +async function recent(userId,locale,limit=12){const events=await db.ProductInteractionEvent.findAll({where:{user_id:userId,event_type:"PRODUCT_VIEW"},order:[["occurred_at","DESC"]],attributes:["product_id"],limit:Math.min(limit*5,100)}),ids=[...new Set(events.map(x=>x.product_id))].slice(0,limit),rows=await db.Product.findAll({where:{...active,id:ids},include,limit});const order=new Map(ids.map((id,i)=>[id,i]));rows.sort((a,b)=>order.get(a.id)-order.get(b.id));return project(rows,locale,Object.fromEntries(ids.map(id=>[id,"RECENTLY_VIEWED"])));} +async function cleanup(limit=1000){const cutoff=new Date(Date.now()-Number(process.env.RECOMMENDATION_EVENT_RETENTION_DAYS||90)*86400000),rows=await db.ProductInteractionEvent.findAll({where:{occurred_at:{[Op.lt]:cutoff}},attributes:["id"],limit});if(rows.length)await db.ProductInteractionEvent.destroy({where:{id:rows.map(x=>x.id)}});return rows.length;}module.exports={recordView,related,ranked,recent,cleanup,project,active}; diff --git a/app/services/support/support.service.js b/app/services/support/support.service.js new file mode 100644 index 0000000..876ce2c --- /dev/null +++ b/app/services/support/support.service.js @@ -0,0 +1,16 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),{nextSequence,pad}=require("../../utils/referenceNumber.util"),storage=require("../storage/storage.service"),activity=require("../activity.service"); +const fail=(message,code,status=400)=>{throw Object.assign(new Error(message),{code,status});},now=()=>new Date(),event=(ticketId,type,actorUserId,t,extra={})=>db.SupportTicketEvent.create({id:crypto.randomUUID(),event_id:crypto.randomUUID(),ticket_id:ticketId,actor_user_id:actorUserId,type,occurred_at:now(),...extra},{transaction:t}); +const customerContext=async(userId,t)=>{const business=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"},transaction:t});return{userId,businessId:business?.business_customer_id||null};}; +const ownTicket=async(id,userId,t,lock=false)=>{const row=await db.SupportTicket.findOne({where:{id,customer_user_id:userId},transaction:t,...(lock&&{lock:t.LOCK.UPDATE})});if(!row)fail("Support ticket not found","TICKET_NOT_FOUND",404);return row;}; +async function assertResourceOwner(type,id,ctx,t){let ok=false;if(type==="ORDER")ok=await db.Order.findOne({where:{id,user_id:ctx.userId},transaction:t});if(type==="PAYMENT")ok=await db.Payment.findOne({where:{id},include:[{model:db.Order,as:"order",where:{user_id:ctx.userId},attributes:["id"]}],transaction:t});if(type==="REFUND"){const refund=await db.Refund.findByPk(id,{transaction:t});ok=refund&&await db.Order.findOne({where:{id:refund.order_id,user_id:ctx.userId},transaction:t});}if(type==="RETURN")ok=await db.ReturnRequest.findOne({where:{id,user_id:ctx.userId},transaction:t});if(type==="SHIPMENT")ok=await db.Shipment.findOne({where:{id},include:[{model:db.Order,as:"order",where:{user_id:ctx.userId},attributes:["id"]}],transaction:t});if(type==="LOYALTY_REDEMPTION"){const a=await db.LoyaltyAccount.findOne({where:{user_id:ctx.userId},transaction:t});ok=a&&await db.LoyaltyRedemption.findOne({where:{id,loyalty_account_id:a.id},transaction:t});}if(type==="BUSINESS_SETTLEMENT"&&ctx.businessId)ok=await db.BusinessSettlement.findOne({where:{id,business_customer_id:ctx.businessId},transaction:t});if(!ok)fail("Related resource not found or not owned","RELATED_RESOURCE_FORBIDDEN",403);} +async function validateUploads(ids,userId,t){if(!ids.length)return[];const rows=await db.Upload.findAll({where:{id:ids,status:"AVAILABLE",uploaded_by:userId},transaction:t,lock:t.LOCK.UPDATE});const allowed=new Set(["image/jpeg","image/png","image/webp","application/pdf"]);if(rows.length!==new Set(ids).size||rows.some(x=>!allowed.has(x.file_type)))fail("Invalid support attachment","ATTACHMENT_FORBIDDEN",403);return rows;} +async function attach(rows,ticketId,messageId,userId,visibility,t){for(const u of rows){await db.SupportAttachment.create({id:crypto.randomUUID(),ticket_id:ticketId,message_id:messageId,upload_id:u.id,uploaded_by:userId,visibility},{transaction:t});await u.update({owner_type:"SUPPORT_TICKET",owner_id:ticketId,use_for:"SUPPORT_ATTACHMENT",visibility:"PRIVATE"},{transaction:t});}} +async function createTicket(actor,body){const result=await db.sequelize.transaction(async t=>{const ctx=await customerContext(actor.id,t),category=body.categoryId&&await db.SupportCategory.findOne({where:{id:body.categoryId,status:"ACTIVE"},transaction:t});if(body.categoryId&&!category)fail("Support category not found","CATEGORY_NOT_FOUND",404);if(body.relatedResource)await assertResourceOwner(body.relatedResource.type,body.relatedResource.id,ctx,t);const uploads=await validateUploads(body.attachmentIds,actor.id,t),policy=await db.SupportSlaPolicy.findOne({where:{status:"ACTIVE",priority:"NORMAL"},transaction:t}),n=await nextSequence(`SUPPORT-${now().getUTCFullYear()}`,t),created=now();const ticket=await db.SupportTicket.create({id:crypto.randomUUID(),ticket_number:`SUP-${created.getUTCFullYear()}-${pad(n,6)}`,customer_user_id:ctx.userId,business_customer_id:ctx.businessId,subject:body.subject,category_id:body.categoryId,priority:"NORMAL",status:"OPEN",source:body.source,sla_policy_id:policy?.id,first_response_due_at:policy&&new Date(created.getTime()+policy.first_response_minutes*60000),resolution_due_at:policy&&new Date(created.getTime()+policy.resolution_minutes*60000),last_customer_message_at:created},{transaction:t});const message=await db.SupportMessage.create({id:crypto.randomUUID(),ticket_id:ticket.id,sender_user_id:actor.id,sender_type:"CUSTOMER",body:body.message,visibility:"CUSTOMER_VISIBLE"},{transaction:t});if(body.relatedResource)await db.SupportTicketLink.create({id:crypto.randomUUID(),ticket_id:ticket.id,resource_type:body.relatedResource.type,resource_id:body.relatedResource.id},{transaction:t});await attach(uploads,ticket.id,message.id,actor.id,"CUSTOMER_VISIBLE",t);await event(ticket.id,"TICKET_CREATED",actor.id,t);return ticket;});activity.logActivity({user:actor,description:"SUPPORT_TICKET_CREATED",module:"SUPPORT",targetType:"SUPPORT_TICKET",targetId:result.id});return result;} +async function customerReply(actor,id,body){return db.sequelize.transaction(async t=>{const ticket=await ownTicket(id,actor.id,t,true);if(["CLOSED","CANCELLED"].includes(ticket.status))fail("Ticket cannot receive replies","TICKET_NOT_REPLYABLE",409);const uploads=await validateUploads(body.attachmentIds,actor.id,t),m=await db.SupportMessage.create({id:crypto.randomUUID(),ticket_id:id,sender_user_id:actor.id,sender_type:"CUSTOMER",body:body.message,visibility:"CUSTOMER_VISIBLE"},{transaction:t});await attach(uploads,id,m.id,actor.id,"CUSTOMER_VISIBLE",t);const reopened=ticket.status==="RESOLVED";await ticket.update({status:reopened?"WAITING_FOR_SUPPORT":ticket.status,last_customer_message_at:now(),resolved_at:reopened?null:ticket.resolved_at},{transaction:t});await event(id,reopened?"TICKET_REOPENED":"CUSTOMER_REPLIED",actor.id,t);return m;});} +const transitions={OPEN:["RESOLVED","CLOSED","CANCELLED"],ASSIGNED:["WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT","RESOLVED","CLOSED"],WAITING_FOR_CUSTOMER:["WAITING_FOR_SUPPORT","RESOLVED","CLOSED"],WAITING_FOR_SUPPORT:["WAITING_FOR_CUSTOMER","RESOLVED","CLOSED"],RESOLVED:["WAITING_FOR_SUPPORT","CLOSED"],CLOSED:[],CANCELLED:[]}; +async function transition(id,to,actor){return db.sequelize.transaction(async t=>{const row=await db.SupportTicket.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)fail("Ticket not found","TICKET_NOT_FOUND",404);if(!transitions[row.status].includes(to))fail(`Cannot transition ${row.status} to ${to}`,"INVALID_TICKET_TRANSITION",409);const from=row.status;await row.update({status:to,...(to==="RESOLVED"&&{resolved_at:now()}),...(to==="CLOSED"&&{closed_at:now()})},{transaction:t});await event(id,to==="RESOLVED"?"TICKET_RESOLVED":to==="CLOSED"?"TICKET_CLOSED":"STATUS_CHANGED",actor.id,t,{from_value:from,to_value:to});return row;});} +async function agentMessage(actor,id,body,internal=false){return db.sequelize.transaction(async t=>{const ticket=await db.SupportTicket.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!ticket)fail("Ticket not found","TICKET_NOT_FOUND",404);if(["CLOSED","CANCELLED"].includes(ticket.status))fail("Ticket cannot receive replies","TICKET_NOT_REPLYABLE",409);const uploads=await validateUploads(body.attachmentIds,actor.id,t),m=await db.SupportMessage.create({id:crypto.randomUUID(),ticket_id:id,sender_user_id:actor.id,sender_type:"AGENT",body:body.message,visibility:internal?"INTERNAL":"CUSTOMER_VISIBLE"},{transaction:t});await attach(uploads,id,m.id,actor.id,internal?"INTERNAL":"CUSTOMER_VISIBLE",t);if(!internal)await ticket.update({first_response_at:ticket.first_response_at||now(),last_agent_message_at:now(),status:"WAITING_FOR_CUSTOMER"},{transaction:t});await event(id,internal?"INTERNAL_NOTE_CREATED":"AGENT_REPLIED",actor.id,t);return m;});} +async function assign(id,agentId,actor,claim=false){return db.sequelize.transaction(async t=>{const ticket=await db.SupportTicket.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!ticket)fail("Ticket not found","TICKET_NOT_FOUND",404);if(claim&&ticket.assigned_agent_id)fail("Ticket already assigned","TICKET_ALREADY_ASSIGNED",409);const agent=await db.User.findOne({where:{id:agentId,accountStatus:"ACTIVE",accountType:{[Op.in]:["support_agent","admin","superadmin"]}},transaction:t});if(!agent)fail("Eligible agent not found","AGENT_NOT_ELIGIBLE",400);const old=ticket.assigned_agent_id;await ticket.update({assigned_agent_id:agentId,status:ticket.status==="OPEN"?"ASSIGNED":ticket.status},{transaction:t});await event(id,"TICKET_ASSIGNED",actor.id,t,{from_value:old,to_value:agentId});return ticket;});} +async function attachmentUrl(ticketId,attachmentId,actor,isStaff){const ticket=isStaff?await db.SupportTicket.findByPk(ticketId):await ownTicket(ticketId,actor.id);if(!ticket)fail("Ticket not found","TICKET_NOT_FOUND",404);const a=await db.SupportAttachment.findOne({where:{id:attachmentId,ticket_id:ticketId,...(!isStaff&&{visibility:"CUSTOMER_VISIBLE"})}});if(!a)fail("Attachment not found","ATTACHMENT_NOT_FOUND",404);const u=await db.Upload.findByPk(a.upload_id);return storage.createSignedDownloadUrl(u.file_path);} +async function reconcile(limit=100){const rows=await db.SupportTicket.findAll({where:{status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]},[Op.or]:[{first_response_at:null,first_response_due_at:{[Op.lte]:now()}},{resolution_due_at:{[Op.lte]:now()}}]},limit,order:[["createdAt","ASC"]]});for(const x of rows){const type=!x.first_response_at&&x.first_response_due_at<=now()?"FIRST_RESPONSE_OVERDUE":"RESOLUTION_OVERDUE",eventId=`sla:${x.id}:${type}`;await db.SupportEscalation.findOrCreate({where:{event_id:eventId},defaults:{id:crypto.randomUUID(),event_id:eventId,ticket_id:x.id,type,reason:type}});}return rows.length;} +module.exports={createTicket,customerReply,transition,agentMessage,assign,ownTicket,attachmentUrl,reconcile,assertResourceOwner,transitions}; diff --git a/app/validation/supportRecommendation.schemas.js b/app/validation/supportRecommendation.schemas.js new file mode 100644 index 0000000..e71af58 --- /dev/null +++ b/app/validation/supportRecommendation.schemas.js @@ -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(180),text=z.string().trim().min(1).max(10000),locale=z.enum(["en","si","ta"]);exports.ticketCreate=wrap(z.object({subject:z.string().trim().min(3).max(200),categoryId:id.optional(),message:text,relatedResource:z.object({type:z.enum(["ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"]),id}).optional(),attachmentIds:z.array(z.number().int().positive()).max(5).default([]),source:z.enum(["WEB","MOBILE"]).default("WEB")}));exports.message=wrap(z.object({message:text,attachmentIds:z.array(z.number().int().positive()).max(5).default([])}));exports.internalNote=wrap(z.object({message:text,attachmentIds:z.array(z.number().int().positive()).max(5).default([])}));exports.assign=wrap(z.object({agentId:id}));exports.priority=wrap(z.object({priority:z.enum(["LOW","NORMAL","HIGH","URGENT"])}));exports.link=wrap(z.object({type:z.enum(["ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"]),id}));exports.category=wrap(z.object({code:z.string().regex(/^[A-Z][A-Z0-9_]{1,59}$/),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),translations:z.record(locale,z.object({name:z.string().min(1).max(160),description:z.string().max(500).optional()})),sortOrder:z.number().int().default(0)}));exports.sla=wrap(z.object({name:z.string().trim().min(1).max(120),status:z.enum(["ACTIVE","INACTIVE"]),priority:z.enum(["LOW","NORMAL","HIGH","URGENT"]),firstResponseMinutes:z.number().int().positive().max(43200),resolutionMinutes:z.number().int().positive().max(129600)}).refine(x=>x.resolutionMinutes>=x.firstResponseMinutes));const translation=z.object({locale,title:z.string().min(1).max(220),summary:z.string().max(500).optional(),body:text,seoTitle:z.string().max(220).optional(),seoDescription:z.string().max(500).optional()});exports.helpCategory=wrap(z.object({slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),sortOrder:z.number().int().default(0),iconKey:z.string().max(80).optional(),translations:z.array(z.object({locale,name:z.string().min(1).max(160),description:z.string().max(500).optional()})).min(1)}));exports.helpArticle=wrap(z.object({slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),categoryId:id,articleType:z.enum(["ARTICLE","FAQ"]).default("ARTICLE"),sortOrder:z.number().int().default(0),isFeatured:z.boolean().default(false),translations:z.array(translation).min(1)}));exports.newsletter=wrap(z.object({email:z.string().trim().email().max(254),locale:locale.default("en"),source:z.enum(["HOME_FOOTER","CHECKOUT","ACCOUNT"]).default("HOME_FOOTER")}));exports.viewEvent=wrap(z.object({eventId:id,productId:id,variantId:id.optional(),source:z.enum(["WEB","MOBILE"]).default("WEB"),sessionKey:z.string().min(16).max(200).optional()})); diff --git a/cron/index.js b/cron/index.js index 3a6e735..db03938 100644 --- a/cron/index.js +++ b/cron/index.js @@ -13,11 +13,12 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron"); const startCheckoutExpiryCron = require("./checkoutExpiry.cron"); const startLoyaltyReconciliationCron = require("./loyaltyReconciliation.cron"); +const startSupportReconciliationCron = require("./supportReconciliation.cron"); function startAllCrons() { console.log("Starting Cron Jobs..."); - const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron()]; + const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron(), startSupportReconciliationCron()]; return async () => { for (const task of tasks) { diff --git a/cron/supportReconciliation.cron.js b/cron/supportReconciliation.cron.js new file mode 100644 index 0000000..5989351 --- /dev/null +++ b/cron/supportReconciliation.cron.js @@ -0,0 +1 @@ +const cron=require("node-cron"),support=require("../app/services/support/support.service"),recommendations=require("../app/services/recommendation/recommendation.service");module.exports=()=>cron.schedule("*/5 * * * *",async()=>{try{await support.reconcile(Number(process.env.SUPPORT_SLA_RECONCILIATION_BATCH_SIZE||100));await recommendations.cleanup(1000);}catch(e){console.error("Phase 10 reconciliation failed",e.message);}},{noOverlap:true}); diff --git a/migrations/20260909100000-phase-10-support-recommendations.js b/migrations/20260909100000-phase-10-support-recommendations.js new file mode 100644 index 0000000..c952447 --- /dev/null +++ b/migrations/20260909100000-phase-10-support-recommendations.js @@ -0,0 +1,16 @@ +"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},INT={type:S.INTEGER,allowNull:false,defaultValue:0},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}}; +await q.createTable("support_categories",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},status:REQ,translations:{type:S.JSON,allowNull:false},sort_order:INT,...TS}); +await q.createTable("support_sla_policies",{id:ID,name:REQ,status:REQ,priority:{type:S.STRING,allowNull:false,unique:true},first_response_minutes:{type:S.INTEGER,allowNull:false},resolution_minutes:{type:S.INTEGER,allowNull:false},business_hours_mode:REQ,...TS}); +await q.createTable("support_tickets",{id:ID,ticket_number:{type:S.STRING(80),allowNull:false,unique:true},customer_user_id:REQ,business_customer_id:STR,subject:{type:S.STRING(200),allowNull:false},category_id:STR,priority:REQ,status:REQ,assigned_agent_id:STR,source:REQ,sla_policy_id:STR,first_response_due_at:DATE,resolution_due_at:DATE,first_response_at:DATE,resolved_at:DATE,closed_at:DATE,last_customer_message_at:DATE,last_agent_message_at:DATE,...TS});await q.addIndex("support_tickets",["customer_user_id","createdAt"]);await q.addIndex("support_tickets",["business_customer_id","createdAt"]);await q.addIndex("support_tickets",["status","priority"]);await q.addIndex("support_tickets",["assigned_agent_id","status"]);await q.addIndex("support_tickets",["first_response_due_at","resolution_due_at"]); +await q.createTable("support_messages",{id:ID,ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},sender_user_id:STR,sender_type:REQ,message_type:REQ,body:{type:S.TEXT,allowNull:false},visibility:REQ,createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("support_messages",["ticket_id","createdAt"]); +await q.createTable("support_ticket_events",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},actor_user_id:STR,type:REQ,from_value:STR,to_value:STR,metadata:{type:S.JSON},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("support_ticket_events",["ticket_id","occurred_at"]); +await q.createTable("support_ticket_links",{id:ID,ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},resource_type:REQ,resource_id:REQ,createdAt:{type:S.DATE,allowNull:false}});await q.addConstraint("support_ticket_links",{fields:["ticket_id","resource_type","resource_id"],type:"unique",name:"uq_support_ticket_resource"});await q.addIndex("support_ticket_links",["resource_type","resource_id"]); +await q.createTable("support_attachments",{id:ID,ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},message_id:STR,upload_id:{type:S.INTEGER,allowNull:false,references:{model:"uploads",key:"id"}},uploaded_by:REQ,visibility:REQ,createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("support_attachments",["ticket_id","visibility"]); +await q.createTable("support_escalations",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},type:REQ,level:{type:S.INTEGER,allowNull:false},reason:{type:S.STRING(300),allowNull:false},status:REQ,acknowledged_at:DATE,...TS});await q.addIndex("support_escalations",["ticket_id","status"]); +await q.createTable("help_categories",{id:ID,slug:{type:S.STRING(160),allowNull:false,unique:true},status:REQ,sort_order:INT,icon_key:{type:S.STRING(80)},...TS});await q.addIndex("help_categories",["status","sort_order"]); +await q.createTable("help_category_translations",{id:ID,category_id:{...REQ,references:{model:"help_categories",key:"id"}},locale:{type:S.STRING(5),allowNull:false},name:{type:S.STRING(160),allowNull:false},description:{type:S.STRING(500)},...TS});await q.addConstraint("help_category_translations",{fields:["category_id","locale"],type:"unique",name:"uq_help_category_locale"});await q.addIndex("help_category_translations",["locale"]); +await q.createTable("help_articles",{id:ID,slug:{type:S.STRING(180),allowNull:false,unique:true},category_id:{...REQ,references:{model:"help_categories",key:"id"}},article_type:REQ,status:REQ,sort_order:INT,is_featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false},published_at:DATE,created_by:REQ,updated_by:STR,...TS});await q.addIndex("help_articles",["category_id","status","published_at"]); +await q.createTable("help_article_translations",{id:ID,article_id:{...REQ,references:{model:"help_articles",key:"id"}},locale:{type:S.STRING(5),allowNull:false},title:{type:S.STRING(220),allowNull:false},summary:{type:S.STRING(500)},body:{type:S.TEXT("long"),allowNull:false},seo_title:{type:S.STRING(220)},seo_description:{type:S.STRING(500)},...TS});await q.addConstraint("help_article_translations",{fields:["article_id","locale"],type:"unique",name:"uq_help_article_locale"});await q.addIndex("help_article_translations",["locale"]); +await q.createTable("newsletter_subscriptions",{id:ID,email_normalized:{type:S.STRING(254),allowNull:false,unique:true},user_id:STR,status:REQ,locale:{type:S.STRING(5),allowNull:false},source:REQ,consented_at:DATE,confirmed_at:DATE,unsubscribed_at:DATE,unsubscribe_token_hash:{type:S.STRING(64),allowNull:false},...TS});await q.addIndex("newsletter_subscriptions",["status","email_normalized"]); +await q.createTable("recommendation_events",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},user_id:STR,session_key_hash:{type:S.STRING(64)},product_id:{...REQ,references:{model:"products",key:"id"}},variant_id:STR,event_type:REQ,source:{type:S.STRING(40),allowNull:false},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("recommendation_events",["user_id","occurred_at"]);await q.addIndex("recommendation_events",["product_id","occurred_at"]);await q.addIndex("recommendation_events",["event_type","occurred_at"]);await q.addIndex("recommendation_events",["product_id","event_type","occurred_at"]); +},async down(){throw new Error("Phase 10 migration is forward-only; restore from a verified backup instead");}}; diff --git a/tests/unit/phase10-support-recommendation.test.js b/tests/unit/phase10-support-recommendation.test.js new file mode 100644 index 0000000..14ae52b --- /dev/null +++ b/tests/unit/phase10-support-recommendation.test.js @@ -0,0 +1,20 @@ +jest.mock("../../app/services/activity.service",()=>({logActivity:jest.fn()})); +const schemas=require("../../app/validation/supportRecommendation.schemas"),newsletter=require("../../app/services/marketing/newsletter.service"),help=require("../../app/services/help/help.service"),support=require("../../app/services/support/support.service"),permissions=require("../../app/constants/permissions"); +const parse=(schema,body)=>schema.safeParse({body,params:{},query:{}}); +describe("Phase 10 validation and policy",()=>{ + test("ticket accepts safe customer fields",()=>expect(parse(schemas.ticketCreate,{subject:"Missing parcel",message:"Please check this order",attachmentIds:[],source:"WEB"}).success).toBe(true)); + test("ticket rejects ownership injection",()=>expect(parse(schemas.ticketCreate,{subject:"Missing parcel",message:"Please check",customerUserId:"other"}).success).toBe(false)); + test("customer cannot submit priority",()=>expect(parse(schemas.ticketCreate,{subject:"Urgent parcel",message:"Please check",priority:"URGENT"}).success).toBe(false)); + test("empty support messages are rejected",()=>expect(parse(schemas.message,{message:" "}).success).toBe(false)); + test("support attachments are bounded",()=>expect(parse(schemas.message,{message:"ok",attachmentIds:[1,2,3,4,5,6]}).success).toBe(false)); + test("ticket links use a strict resource allowlist",()=>expect(parse(schemas.link,{type:"USER",id:"x"}).success).toBe(false)); + test("closed tickets have no outbound transitions",()=>expect(support.transitions.CLOSED).toEqual([])); + test("resolved tickets may explicitly reopen",()=>expect(support.transitions.RESOLVED).toContain("WAITING_FOR_SUPPORT")); + test("newsletter email normalization is deterministic",()=>expect(newsletter.normalize(" PERSON@Example.COM ")).toBe("person@example.com")); + test("newsletter authorization tokens are hashable without storage of raw token",()=>{const token="a-secure-random-token";expect(newsletter.hash(token)).toMatch(/^[a-f0-9]{64}$/);expect(newsletter.hash(token)).not.toBe(token);}); + test("newsletter source is allowlisted",()=>expect(parse(schemas.newsletter,{email:"a@example.com",source:"SCRAPED_LIST"}).success).toBe(false)); + test("client event schema only accepts product views",()=>expect(parse(schemas.viewEvent,{eventId:"evt-1",productId:"p1",eventType:"PURCHASE"}).success).toBe(false)); + test("client event rejects unknown mutation fields",()=>expect(parse(schemas.viewEvent,{eventId:"evt-1",productId:"p1",source:"WEB",email:"private@example.com"}).success).toBe(false)); + test("help content strips HTML tags",()=>expect(help.clean("Safe")).toBe("alert(1)Safe")); + test("all privileged Phase 10 permissions are explicit",()=>expect([permissions.SUPPORT_TICKETS_READ,permissions.HELP_MANAGE,permissions.NEWSLETTER_SUBSCRIBERS_READ,permissions.RECOMMENDATIONS_MANAGE]).toEqual(["support.tickets.read","help.manage","newsletter.subscribers.read","recommendations.manage"])); +}); -- 2.52.0