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