feat: Implement loyalty and wholesale services with models, routes, and validation
CI / test (push) Successful in 10m23s
CI / test (push) Successful in 10m23s
- 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.
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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);}};
|
||||
|
||||
@@ -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);}};
|
||||
@@ -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);}};
|
||||
@@ -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);}};
|
||||
@@ -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);}};
|
||||
@@ -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);}};
|
||||
+267
-47
@@ -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,
|
||||
{
|
||||
const sequelize = new Sequelize(dbConfig.DB, dbConfig.USER, dbConfig.PASSWORD, {
|
||||
host: dbConfig.HOST,
|
||||
port: dbConfig.PORT,
|
||||
dialect: dbConfig.DIALECT,
|
||||
pool: dbConfig.pool,
|
||||
logging: loggingOption
|
||||
}
|
||||
);
|
||||
logging: loggingOption,
|
||||
});
|
||||
|
||||
const db = {};
|
||||
|
||||
@@ -38,16 +33,34 @@ db.sequelize = sequelize;
|
||||
// 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;
|
||||
|
||||
@@ -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"]}]});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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"]}]});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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"]}]});
|
||||
@@ -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});
|
||||
@@ -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});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
|
||||
@@ -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(available<debit)throw fault("Insufficient points","INSUFFICIENT_POINTS",409);else available-=debit;}const entry=await db.LoyaltyLedgerEntry.create({id:crypto.randomUUID(),event_id:eventId,loyalty_account_id:account.id,type,source_type:sourceType,source_id:sourceId,points_delta:points,balance_after:available,expires_at:expiresAt,description_code:descriptionCode,metadata,occurred_at:new Date()},{transaction});await account.update({available_points:available,points_debt:debt,lifetime_points_earned:account.lifetime_points_earned+(type==="EARN"?points:0),lifetime_points_redeemed:account.lifetime_points_redeemed+(type==="REDEEM"?-points:0),last_activity_at:new Date()},{transaction});if(spendable>0)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)<money.parse(r.minimum_amount))return null;const expiry=r.expiry_days?new Date(Date.now()+r.expiry_days*86400000):null,result=await post({userId:order.user_id,eventId:`LOYALTY:PURCHASE:${order.id}`,type:"EARN",sourceType:"PURCHASE",sourceId:order.id,points,expiresAt:expiry,descriptionCode:"PAID_MERCHANDISE"},transaction);await qualifyReferral(order,transaction);return result;}
|
||||
async function earnReview(review,transaction){if(review.status!=="APPROVED"||!review.verified_purchase)return null;const r=await rule("VERIFIED_REVIEW",new Date(),transaction);if(!r?.fixed_points)return null;return post({userId:review.user_id,eventId:`LOYALTY:REVIEW:${review.id}`,type:"EARN",sourceType:"VERIFIED_REVIEW",sourceId:review.id,points:r.fixed_points,descriptionCode:"APPROVED_VERIFIED_REVIEW"},transaction);}
|
||||
async function qualifyReferral(order,transaction){const referral=await db.Referral.findOne({where:{referred_user_id:order.user_id,status:{[Op.in]:["REGISTERED","PENDING"]}},transaction,lock:transaction.LOCK.UPDATE});if(!referral)return;const r=await rule("REFERRAL",new Date(),transaction);if(!r?.fixed_points)return;await referral.update({status:"QUALIFIED",qualified_order_id:order.id,qualified_at:new Date()},{transaction});await post({userId:referral.referrer_user_id,eventId:`LOYALTY:REFERRAL:${referral.id}`,type:"EARN",sourceType:"REFERRAL",sourceId:referral.id,points:r.fixed_points,descriptionCode:"QUALIFIED_PURCHASE"},transaction);await referral.update({status:"REWARDED",rewarded_at:new Date()},{transaction});}
|
||||
async function reversePurchase({orderId,userId,points,eventId},transaction){return post({userId,eventId,type:"REVERSAL",sourceType:"PURCHASE_REFUND",sourceId:orderId,points:-Math.abs(points),descriptionCode:"REFUND_REVERSAL"},transaction);}
|
||||
async function redeem({userId,rewardId,idempotencyKey}){return db.sequelize.transaction(async transaction=>{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<reward.points_cost)throw fault("Insufficient points","INSUFFICIENT_POINTS",409);let remaining=reward.points_cost;const allocations=await db.LoyaltyPointAllocation.findAll({where:{loyalty_account_id:account.id,remaining_points:{[Op.gt]:0},[Op.or]:[{expires_at:null},{expires_at:{[Op.gt]:new Date()}}]},order:[["expires_at","ASC"],["createdAt","ASC"]],transaction,lock:transaction.LOCK.UPDATE});for(const a of allocations){const used=Math.min(a.remaining_points,remaining);if(used){await a.update({remaining_points:a.remaining_points-used},{transaction});remaining-=used;}if(!remaining)break;}if(remaining)throw fault("Point allocation invariant violated","POINT_ALLOCATION_INVARIANT",409);const redemption=await db.LoyaltyRedemption.create({id:crypto.randomUUID(),redemption_number:`RWD-${new Date().getUTCFullYear()}-${pad(await nextSequence("loyalty-redemptions",transaction),6)}`,idempotency_key:idempotencyKey,loyalty_account_id:account.id,reward_id:reward.id,points_cost:reward.points_cost,status:"COMPLETED",redeemed_at:new Date()},{transaction});await post({userId,eventId:`LOYALTY:REDEEM:${redemption.id}`,type:"REDEEM",sourceType:"REWARD",sourceId:reward.id,points:-reward.points_cost},transaction);if(reward.type==="COUPON"){const couponId=reward.configuration?.couponId;if(!couponId)throw fault("Coupon reward is misconfigured","REWARD_CONFIGURATION",409);await db.CustomerCouponEntitlement.create({id:crypto.randomUUID(),user_id:userId,coupon_id:couponId,source_type:"LOYALTY_REDEMPTION",source_id:redemption.id,status:"ACTIVE",issued_at:new Date(),expires_at:reward.ends_at},{transaction});await redemption.update({fulfilled_reference:couponId},{transaction});}return{redemption,idempotent:false};});}
|
||||
const calculatePurchasePoints=(eligibleAmount,amountUnit,pointsPerAmount,maximumPoints)=>{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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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)}));
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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});
|
||||
@@ -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");}};
|
||||
@@ -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);});});
|
||||
@@ -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);});});
|
||||
Reference in New Issue
Block a user