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,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};
|
||||
Reference in New Issue
Block a user