feat: Implement loyalty and wholesale services with models, routes, and validation
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:
Sathira Sri Sathara
2026-09-09 13:56:37 +05:30
parent 222483d194
commit abb760cc19
45 changed files with 482 additions and 58 deletions
+1
View File
@@ -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};